diff --git a/README.md b/README.md index a3bdbb6..31dc3b3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,29 @@ fprintd-verify user (wrong finger) verify-no-match, on the first press fprintd-verify user (enrolled finger) verify-match, on the first press ``` +## Lock-screen unlock + +Two routes, both on by default: + +- **PAM.** The daemon provides `fprintd`, so a lock screen that runs + `pam_fprintd` beside its password prompt unlocks by finger the usual way. + Plasma's does (`kde-fingerprint`; the `fingerprint-auth` and `postlogin` + services it needs are shipped here). Phosh's cannot: its PAM conversation + answers every prompt with the password, so a fingerprint module in its stack + would only stall the password behind the fingerprint timeout. +- **Standby.** While the session in front on its seat is locked and the + display is on, the daemon runs a verify of its own and answers a match with + logind's `Session.Unlock`, which Phosh honours. No verify runs while the + display is off (an open verify polls the sensor at ~5 Hz; the display state + comes from the backlight's `bl_power` or the panel's DRM `dpms`, and a + device with neither is treated as always on). Any fprintd client takes + precedence: a `Claim` cancels the standby verify, a `Release` re-arms it. + Only fingers named in that session owner's map unlock, and the map is + re-read at every arm, so a finger deleted while locked stops counting at + once. Per-finger `no-unlock` rules apply here too, and every match is + announced as `FingerMatched` for the session agent. `--standby-unlock=off` + disables it. + ## Runtime dependencies The `focal64` trustlet is proprietary and is not in this repo. It is diff --git a/implementations/main.cpp b/implementations/main.cpp index 693085f..99736c1 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1943,7 +1943,7 @@ private: // jobs; results and progress come back through the GLib main loop. // ============================================================================= struct Job { - enum class Kind { Claim, Enroll, Verify, Remove } kind; + enum class Kind { Claim, Enroll, Verify, Remove, Standby } kind; std::uint32_t uid = 0; std::string finger; std::vector acceptFids; // Verify: which fids count @@ -1955,7 +1955,7 @@ struct Job { // so the D-Bus emission happens on the thread that owns the connection. struct Event { enum class Kind { Ready, StartFailed, ClaimDone, EnrollStatus, VerifyStatus, - RemoveDone } kind; + RemoveDone, StandbyStatus } kind; bool ok = false; bool done = false; std::string status; @@ -1966,6 +1966,14 @@ struct Event { void PostEvent(std::unique_ptr ev); // defined with the D-Bus code +// Standby unlock (see the block before PostEvent): "wanted" is the main +// thread's intent, read by the worker when a standby job starts and between +// its scan windows; "running" is the worker's word that a standby verify is +// the op in progress, so a cancel from the main thread can be sure whose op +// it is stopping. +std::atomic g_standbyWanted{false}; +std::atomic g_standbyRunning{false}; + class Worker { public: void Start() { @@ -2076,6 +2084,42 @@ private: PostEvent(std::move(ev)); break; } + case Job::Kind::Standby: { + // The daemon's own verify, no client behind it. Same scan as + // a client's, but it ends the moment the main thread stops + // wanting it (display off, session unlocked, a client + // claiming the device), and its verdict goes to the standby + // code rather than onto the bus. + auto ev = std::make_unique(Event{ .kind = Event::Kind::StandbyStatus }); + // `running` is published BEFORE `wanted` is read; StandbyCancel + // does the reverse. Whichever side goes second sees the other, + // so a cancel cannot fall between this check and the verify + // (cancel_ was reset at dequeue, so a CancelOp from here on holds). + g_standbyRunning.store(true); + if (!g_standbyWanted.load()) { + g_standbyRunning.store(false); + ev->status = ""; ev->done = false; // cancelled while queued + PostEvent(std::move(ev)); + break; + } + auto o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids); + while (!o.decided && !o.cancelled && o.presses == 0 && g_standbyWanted.load()) + o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids); + g_standbyRunning.store(false); + std::println("standby verify: {} over {} press(es), {} frame(s)", + o.cancelled ? "cancelled" : !o.decided ? "undecided" + : o.matched ? "MATCH" : "NO MATCH", o.presses, o.frames); + ev->done = true; + ev->fid = o.fid; + if (o.cancelled || (!o.decided && o.presses == 0)) { ev->status = ""; ev->done = false; } + else if (!o.decided) ev->status = "verify-retry-scan"; + else if (o.matched) ev->status = "verify-match"; + else ev->status = "verify-no-match"; + PostEvent(std::move(ev)); + if (o.matched && g_learn) session_.HarvestTemplate(g_learnMaxFrames); + session_.FlushTemplate(); + break; + } case Job::Kind::Verify: { // fprintd's contract is that a verify runs until the client // stops it. The frame cap bounds one trustlet scan session, @@ -2283,6 +2327,9 @@ Claim g_claim; GDBusMethodInvocation* g_pendingClaim = nullptr; guint g_claimWatch = 0; +void StandbyEvaluate(); +void StandbyCancel(); + void DropClaim(const char* why) { if (g_claimWatch) { g_dbus_connection_signal_unsubscribe(g_conn, g_claimWatch); @@ -2292,6 +2339,7 @@ void DropClaim(const char* why) { if (g_claim.held) std::println("claim by {} for {} dropped: {}", g_claim.sender, g_claim.user, why); g_claim = {}; + StandbyEvaluate(); // the sensor is free again } // A claim is held by a bus connection. If that connection goes away -- the @@ -2318,16 +2366,16 @@ std::string MapPath(std::uint32_t uid) { return fingerprintd::store::PathForUid(g_stateDir, uid); } -fingerprintd::store::Map LoadMap(std::uint32_t uid) { +fingerprintd::store::Map LoadMap(std::uint32_t uid, bool quiet = false) { std::string path = MapPath(uid); std::ifstream f(path); if (!f) { - std::println("map {}: {}", path, ::strerror(errno)); + if (!quiet) std::println("map {}: {}", path, ::strerror(errno)); return {}; } std::string text((std::istreambuf_iterator(f)), std::istreambuf_iterator()); auto m = fingerprintd::store::Map::Decode(text); - std::println("map {}: {} bytes, {} finger(s)", path, text.size(), m.Size()); + if (!quiet) std::println("map {}: {} bytes, {} finger(s)", path, text.size(), m.Size()); return m; } @@ -2390,6 +2438,302 @@ bool Authorised(GDBusMethodInvocation* inv, std::uint32_t targetUid) { return caller && (*caller == 0 || *caller == targetUid); } + +// ----------------------------------------------------------------------------- +// Standby unlock: a verify the daemon arms ITSELF while a seat session is +// locked and its display is on, answered with logind's Session.Unlock. +// +// The PAM route needs a lock screen that runs pam_fprintd beside its password +// prompt. Plasma's does; Phosh's cannot (its PAM conversation answers every +// prompt with the password, so a fingerprint module would only stall the +// password behind its timeout). Phosh honours logind's Unlock, and root may +// call it, so this needs no polkit and no desktop cooperation. It yields to +// any fprintd client at once: a Claim cancels the standby verify and the +// claim's release re-arms it. +// +// It follows ONE session: the one that is locked AND in front on its seat, +// i.e. the lock screen actually on the display. Whose finger counts is +// decided by that session's owner and a map re-read at every arm and verdict, +// so a finger deleted while locked stops counting at once. +// +// Cost: an open verify polls the trustlet at ~5 Hz, so it only runs while the +// display is on (sysfs bl_power, the DRM connector's dpms as a fallback; a +// device with neither is treated as always on). While a session is locked the +// display state itself is read at 2 Hz. --standby-unlock=off turns it all off. +bool g_standbyEnabled = true; +struct Standby { + std::string sessionPath, sessionId; + std::uint32_t uid = 0; + bool locked = false; + bool screenOn = false; + bool armed = false; // a standby job is queued or running + unsigned lockGen = 0; // bumped whenever a session starts being followed + unsigned armedGen = 0; // lockGen the outstanding job was armed under + fingerprintd::store::Map fingers; + guint pollSource = 0; + guint rearmSource = 0; +}; +Standby g_standby; +std::string g_blPowerPath, g_dpmsPath; + +void FindDisplayNodes() { + std::error_code ec; + for (const auto& d : std::filesystem::directory_iterator("/sys/class/backlight", ec)) { + auto p = d.path() / "bl_power"; + if (std::filesystem::exists(p, ec)) { g_blPowerPath = p.string(); break; } + } + for (const auto& d : std::filesystem::directory_iterator("/sys/class/drm", ec)) { + std::string n = d.path().filename().string(); + if (n.find("-DSI-") == std::string::npos && n.find("-eDP-") == std::string::npos) continue; + auto p = d.path() / "dpms"; + if (std::filesystem::exists(p, ec)) { g_dpmsPath = p.string(); break; } + } +} + +bool ReadTrimmed(const std::string& path, std::string& out) { + std::ifstream f(path); + if (!f) return false; + std::getline(f, out); + while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) out.pop_back(); + return true; +} + +// bl_power: 0 = FB_BLANK_UNBLANK, anything else is some flavour of off. +bool ScreenOn() { + std::string v; + if (!g_blPowerPath.empty() && ReadTrimmed(g_blPowerPath, v)) return v == "0"; + if (!g_dpmsPath.empty() && ReadTrimmed(g_dpmsPath, v)) return v == "On"; + return true; // nothing to read: better armed than deaf +} + +void StandbyCancel() { + g_standbyWanted.store(false); + if (g_standbyRunning.load() && g_worker) g_worker->CancelOp(); +} + +void StandbyEvaluate() { + bool want = g_ready && g_standbyEnabled && g_worker && g_standby.locked && g_standby.screenOn + && !g_claim.held && !g_pendingClaim; + if (want && !g_standby.armed) { + g_standby.fingers = LoadMap(g_standby.uid, /*quiet=*/true); + if (g_standby.fingers.Size() == 0) return; + std::vector fids; + for (const auto& e : g_standby.fingers.Entries()) fids.push_back(e.fid); + g_standby.armed = true; + g_standby.armedGen = g_standby.lockGen; + g_standbyWanted.store(true); + std::println("standby: listening for a finger (session {}, uid {}, {} finger(s))", + g_standby.sessionId, g_standby.uid, fids.size()); + g_worker->Post(Job{ .kind = Job::Kind::Standby, .uid = g_standby.uid, + .acceptFids = std::move(fids) }); + } else if (!want && g_standby.armed) { + StandbyCancel(); // `armed` clears when the job reports back + } +} + +void StandbyScheduleEvaluate(unsigned ms) { + if (g_standby.rearmSource) g_source_remove(g_standby.rearmSource); + g_standby.rearmSource = g_timeout_add(ms, [](gpointer) -> gboolean { + g_standby.rearmSource = 0; + StandbyEvaluate(); + return G_SOURCE_REMOVE; + }, nullptr); +} + +gboolean StandbyPoll(gpointer) { + bool on = ScreenOn(); + if (on != g_standby.screenOn) { + g_standby.screenOn = on; + std::println("standby: display {}", on ? "on" : "off"); + StandbyEvaluate(); + } + return G_SOURCE_CONTINUE; +} + +void StandbyOnStatus(const Event& ev) { + namespace ac = fingerprintd::actions; + namespace store = fingerprintd::store; + g_standby.armed = false; + if (ev.status.empty()) { // cancelled, or skipped while queued + StandbyEvaluate(); + return; + } + if (!g_standby.locked) return; + if (g_standby.armedGen != g_standby.lockGen) { // armed for a lock we have since left + StandbyEvaluate(); + return; + } + if (ev.status == "verify-match" && ev.fid != 0) { + g_standby.fingers = LoadMap(g_standby.uid, /*quiet=*/true); + auto matched = g_standby.fingers.Lookup(ev.fid); + const ac::Rule* rule = matched ? ac::Find(g_actions, *matched) : nullptr; + if (!matched) { + std::println("standby: matched fid {} that no finger name maps to; not unlocking", ev.fid); + } else if (rule && !rule->unlocks) { + std::println("standby: {} is configured no-unlock; not unlocking", store::NameOf(*matched)); + } else { + std::println("standby: {} matched -- unlocking session {}", + store::NameOf(*matched), g_standby.sessionId); + g_dbus_connection_call( + g_conn, "org.freedesktop.login1", g_standby.sessionPath.c_str(), + "org.freedesktop.login1.Session", "Unlock", nullptr, nullptr, + G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, + [](GObject* src, GAsyncResult* res, gpointer) { + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err); + if (!r) { + std::println(std::cerr, "standby: Unlock failed: {}", err ? err->message : "?"); + if (err) g_error_free(err); + } else { + g_variant_unref(r); + } + }, nullptr); + } + // The same announcement a client-driven match makes (see VerifyStatus). + if (matched && g_conn) + g_dbus_connection_emit_signal( + g_conn, nullptr, DevicePath, ActionIface, "FingerMatched", + g_variant_new("(su)", std::string(store::NameOf(*matched)).c_str(), + static_cast(g_standby.uid)), + nullptr); + if (rule && !rule->command.empty()) { + std::println("action: {} -> running {}", store::NameOf(*matched), rule->command); + RunSystemAction(rule->command, std::string(store::NameOf(*matched))); + } + // LockedHint normally flips first and disarms us; if the unlock did + // not take, this listens again. + StandbyScheduleEvaluate(1500); + return; + } + std::println("standby: {} -- listening again", ev.status); + StandbyScheduleEvaluate(300); +} + +struct SessionInfo { std::string id, seat; std::uint32_t uid = 0; bool locked = false, active = false; }; + +std::optional QuerySession(const std::string& path) { + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_sync( + g_conn, "org.freedesktop.login1", path.c_str(), "org.freedesktop.DBus.Properties", "GetAll", + g_variant_new("(s)", "org.freedesktop.login1.Session"), G_VARIANT_TYPE("(a{sv})"), + G_DBUS_CALL_FLAGS_NONE, 3000, nullptr, &err); + if (!r) { + std::println(std::cerr, "standby: cannot read session {}: {}", path, err ? err->message : "?"); + if (err) g_error_free(err); + return std::nullopt; + } + SessionInfo si; + GVariant* dict = g_variant_get_child_value(r, 0); + const gchar* id = nullptr; + if (g_variant_lookup(dict, "Id", "&s", &id) && id) si.id = id; + guint32 uid = 0; const gchar* upath = nullptr; + if (g_variant_lookup(dict, "User", "(u&o)", &uid, &upath)) si.uid = uid; + const gchar* seat = nullptr; const gchar* spath = nullptr; + if (g_variant_lookup(dict, "Seat", "(&s&o)", &seat, &spath) && seat) si.seat = seat; + gboolean locked = FALSE, active = FALSE; + if (g_variant_lookup(dict, "LockedHint", "b", &locked)) si.locked = locked; + if (g_variant_lookup(dict, "Active", "b", &active)) si.active = active; + g_variant_unref(dict); + g_variant_unref(r); + return si; +} + +// Re-reads logind and follows the seat session that is locked AND active, or +// none. Called on every LockedHint/Active change and session removal, so a +// lock, an unlock and a user switch all land here. +void StandbyRescan() { + GError* err = nullptr; + GVariant* r = g_dbus_connection_call_sync( + g_conn, "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", + "ListSessions", nullptr, G_VARIANT_TYPE("(a(susso))"), G_DBUS_CALL_FLAGS_NONE, 3000, nullptr, &err); + if (!r) { + std::println(std::cerr, "standby: ListSessions failed: {}", err ? err->message : "?"); + if (err) g_error_free(err); + return; + } + std::string frontPath; + SessionInfo front; + GVariant* arr = g_variant_get_child_value(r, 0); + GVariantIter it; + g_variant_iter_init(&it, arr); + const gchar *id, *user, *seat, *path; guint32 uid; + while (g_variant_iter_next(&it, "(&su&s&s&o)", &id, &uid, &user, &seat, &path)) { + if (!seat || !*seat) continue; // not a seat session: nothing to unlock on + auto si = QuerySession(path); + if (si && si->locked && si->active && !si->seat.empty()) { frontPath = path; front = *si; break; } + } + g_variant_unref(arr); + g_variant_unref(r); + + if (g_standby.locked && g_standby.sessionPath == frontPath) return; // unchanged + if (g_standby.locked) { + std::println("standby: session {} unlocked or no longer in front", g_standby.sessionId); + g_standby.locked = false; + if (g_standby.pollSource) { g_source_remove(g_standby.pollSource); g_standby.pollSource = 0; } + if (g_standby.armed) StandbyCancel(); // armed for the session just left + } + if (!frontPath.empty()) { + g_standby.sessionPath = frontPath; + g_standby.sessionId = front.id; + g_standby.uid = front.uid; + g_standby.lockGen++; + g_standby.fingers = LoadMap(front.uid); + g_standby.locked = true; + g_standby.screenOn = ScreenOn(); + if (!g_standby.pollSource) g_standby.pollSource = g_timeout_add(500, StandbyPoll, nullptr); + std::println("standby: session {} (uid {}) locked; display {}; {} finger(s) enrolled", + front.id, front.uid, g_standby.screenOn ? "on" : "off", g_standby.fingers.Size()); + } + StandbyEvaluate(); +} + +bool PropertyTouched(GVariant* changed, GVariant* invalidated, std::string_view key) { + if (GVariant* v = g_variant_lookup_value(changed, std::string(key).c_str(), nullptr)) { + g_variant_unref(v); + return true; + } + GVariantIter it; + const gchar* name = nullptr; + g_variant_iter_init(&it, invalidated); + while (g_variant_iter_next(&it, "&s", &name)) + if (name && key == name) return true; + return false; +} + +void OnLogindProperties(GDBusConnection*, const gchar*, const gchar* path, const gchar*, const gchar*, + GVariant* params, gpointer) { + if (!path || std::string_view(path).find("/org/freedesktop/login1/session/") != 0) return; + const gchar* iface = nullptr; + GVariant* changed = nullptr; + GVariant* invalidated = nullptr; + g_variant_get(params, "(&s@a{sv}@as)", &iface, &changed, &invalidated); + if (iface && std::string_view(iface) == "org.freedesktop.login1.Session" + && (PropertyTouched(changed, invalidated, "LockedHint") + || PropertyTouched(changed, invalidated, "Active"))) + StandbyRescan(); + g_variant_unref(changed); + g_variant_unref(invalidated); +} + +void OnSessionRemoved(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar*, + GVariant*, gpointer) { + StandbyRescan(); +} + +void StandbyInit() { + if (!g_standbyEnabled) { std::println("standby unlock: off"); return; } + FindDisplayNodes(); + std::println("standby unlock: on (display state from {})", + !g_blPowerPath.empty() ? g_blPowerPath : !g_dpmsPath.empty() ? g_dpmsPath : "nothing -- assumed on"); + g_dbus_connection_signal_subscribe(g_conn, "org.freedesktop.login1", "org.freedesktop.DBus.Properties", + "PropertiesChanged", nullptr, nullptr, G_DBUS_SIGNAL_FLAGS_NONE, + OnLogindProperties, nullptr, nullptr); + g_dbus_connection_signal_subscribe(g_conn, "org.freedesktop.login1", "org.freedesktop.login1.Manager", + "SessionRemoved", "/org/freedesktop/login1", nullptr, + G_DBUS_SIGNAL_FLAGS_NONE, OnSessionRemoved, nullptr, nullptr); + StandbyRescan(); // a session locked before we started (a daemon restart under a lock screen) +} + void PostEvent(std::unique_ptr ev) { g_idle_add([](gpointer data) -> gboolean { std::unique_ptr ev(static_cast(data)); @@ -2397,6 +2741,10 @@ void PostEvent(std::unique_ptr ev) { case Event::Kind::Ready: g_ready = true; std::println("fingerprintd: ready"); + StandbyEvaluate(); + break; + case Event::Kind::StandbyStatus: + StandbyOnStatus(*ev); break; case Event::Kind::StartFailed: std::println(std::cerr, "fingerprintd: session bring-up failed -- exiting for systemd"); @@ -2413,6 +2761,9 @@ void PostEvent(std::unique_ptr ev) { ReturnError(ev->invocation, "Internal", "could not select the user's group"); } g_pendingClaim = nullptr; + // A claim dropped while it was pending (the claimant left, or + // the group failed) evaluated standby too early to arm it. + StandbyEvaluate(); } break; case Event::Kind::RemoveDone: @@ -2542,6 +2893,7 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant* g_claim.fingers = LoadMap(g_claim.uid); g_pendingClaim = inv; WatchClaimant(g_claim.sender); + StandbyCancel(); // the client's op comes first; standby returns with Release g_worker->Post(Job{ .kind = Job::Kind::Claim, .uid = g_claim.uid, .invocation = inv }); return; } @@ -2603,6 +2955,13 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant* if (one) m.Remove(*one); else m.Clear(); SaveMap(uid, m); if (g_claim.held && g_claim.uid == uid) g_claim.fingers = m; + // A standby verify for this user holds the fids it was armed with and + // keeps the worker busy; stop it so the removal below runs, and so it + // re-arms from the new map. The verdict check re-reads the map anyway. + if (g_standby.locked && g_standby.uid == uid) { + g_standby.fingers = m; + StandbyCancel(); + } // The name is dropped first and the template second, in that order on // purpose: a finger whose template survives a failed removal is a slot // leak, while a name that survives a successful one would keep @@ -2731,6 +3090,7 @@ void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) { g_dbus_connection_register_object(conn, DevicePath, node->interfaces[1], &g_vtable, nullptr, nullptr, nullptr); g_dbus_node_info_unref(node); std::println("objects registered at {} and {}", ManagerPath, DevicePath); + StandbyInit(); } gboolean OnTerm(gpointer) { @@ -2952,6 +3312,8 @@ int main(int argc, char** argv) { // The observer thread and the loop would both drain the same fd, so // the diagnostic and the wake are mutually exclusive. if (a == "--edge-wake") g_edgeWake = true; + if (a == "--standby-unlock=off") g_standbyEnabled = false; + if (a == "--standby-unlock=on") g_standbyEnabled = true; if (a.starts_with("--log-dir=")) { g_logDir = a.substr(10); g_logDirExplicit = true; } if (a.starts_with("--state-dir=")) g_stateDir = a.substr(12); if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9))); @@ -2985,6 +3347,8 @@ int main(int argc, char** argv) { " template, as stock does (default on, 8)\n" " --actions=FILE per-finger actions (default\n" " /etc/fingerprintd/actions.conf, absent = off)\n" + " --standby-unlock=on|off unlock the locked session in front by finger,\n" + " via logind (default on)\n" " --sfs-root=DIR --sfs-writable --rpmb-write storage policy", Version); return 1;