diff --git a/implementations/main.cpp b/implementations/main.cpp index bad0016..67f4062 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -66,7 +66,7 @@ namespace { // Bumping this is what publishes a package: the registry answers 409 for a // version it already has, which a build treats as a no-op. -constexpr const char* Version = "0.1.2"; +constexpr const char* Version = "0.1.3"; bool g_verbose = false; // 500 ms was the research harness's pace, chosen so a human could read the @@ -1269,6 +1269,46 @@ public: // callbacks at all -- so a group that is already active does not need // selecting again. `force` is for the cases that genuinely change the // store: an enrolment, or a template removed underneath us. + // Remove templates from the TRUSTLET, which is what makes a delete a + // delete. Dropping the name from the map only stops the finger being + // offered; the template keeps its slot, and with + // enable_duplicated_finger_checking on, that slot is what refuses the + // re-enrolment of the same finger. + // + // The group must be active first: the trustlet compares the gid against + // device+0x30 and will not search a group it has not loaded. Returns the + // number removed; a per-fid failure is logged and does not stop the rest, + // because a partial delete is still better than none and the caller has + // already committed to losing these fingers. + int RemoveTemplates(std::uint32_t gid, const std::vector& fids) { + namespace ta = fingerprintd::ta; + if (fids.empty()) return 0; + if (SetActiveGroup(gid) < 0) { + std::println(std::cerr, "remove: group {} would not load, nothing removed", gid); + return -1; + } + int removed = 0; + for (std::uint32_t fid : fids) { + // The trustlet refuses this itself, with a log line nobody reads. + if (fid == 0) continue; + std::vector rm(ta::RemovePayloadSize); + ta::BuildRemovePayload(rm, gid, fid); + auto r = SendCommand(app_, ta::Cmd::Remove, rm); + if (r.rc == 0) { + std::println(" removed template fid={} from group {}", fid, gid); + removed++; + } else { + std::println(std::cerr, " REMOVE fid={} failed rc={} ({})", fid, r.rc, + ta::StrError(r.rc)); + } + } + // The trustlet's own accounting moved, so ours must be re-read rather + // than assumed: a later claim that trusts a stale count skips the + // reload it now needs. + if (removed) SetActiveGroup(gid, /*force*/ true); + return removed; + } + int SetActiveGroup(std::uint32_t gid, bool force = false) { namespace ta = fingerprintd::ta; // > 0, never >= 0: caching a ZERO turns a failed load into a @@ -1880,17 +1920,19 @@ private: // jobs; results and progress come back through the GLib main loop. // ============================================================================= struct Job { - enum class Kind { Claim, Enroll, Verify } kind; + enum class Kind { Claim, Enroll, Verify, Remove } kind; std::uint32_t uid = 0; std::string finger; std::vector acceptFids; // Verify: which fids count - GDBusMethodInvocation* invocation = nullptr; // Claim replies asynchronously + // Remove: which fids to drop + GDBusMethodInvocation* invocation = nullptr; // Claim/Remove reply asynchronously }; // Everything the worker sends back to the main thread. Delivered by g_idle_add // so the D-Bus emission happens on the thread that owns the connection. struct Event { - enum class Kind { Ready, StartFailed, ClaimDone, EnrollStatus, VerifyStatus } kind; + enum class Kind { Ready, StartFailed, ClaimDone, EnrollStatus, VerifyStatus, + RemoveDone } kind; bool ok = false; bool done = false; std::string status; @@ -1968,6 +2010,17 @@ private: PostEvent(std::move(ev)); break; } + case Job::Kind::Remove: { + int n = session_.RemoveTemplates(j.uid, j.acceptFids); + std::println("remove: {} of {} template(s) removed for uid {}", + n < 0 ? 0 : n, j.acceptFids.size(), j.uid); + auto ev = std::make_unique(Event{ .kind = Event::Kind::RemoveDone }); + ev->ok = n >= 0; + ev->templates = n; + ev->invocation = j.invocation; + PostEvent(std::move(ev)); + break; + } case Job::Kind::Enroll: { auto o = session_.Enrol( j.uid, cancel_, @@ -2258,6 +2311,20 @@ void PostEvent(std::unique_ptr ev) { g_pendingClaim = nullptr; } break; + case Event::Kind::RemoveDone: + if (ev->invocation) { + if (ev->ok) { + g_dbus_method_invocation_return_value(ev->invocation, nullptr); + } else { + // The names are already gone from the map by this point. + // Saying so is better than a bare failure: the fingers + // will not be offered again, but their templates still + // hold slots, which is what will refuse a re-enrolment. + ReturnError(ev->invocation, "Internal", + "names removed, but the trustlet templates were not"); + } + } + break; case Event::Kind::EnrollStatus: if (!ev->status.empty()) EmitDevice("EnrollStatus", g_variant_new("(sb)", ev->status.c_str(), ev->done ? TRUE : FALSE)); @@ -2384,17 +2451,28 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant* } } auto m = LoadMap(uid); + // The fids have to be read BEFORE the names go: the map is the only + // place that remembers which template belongs to which finger. + std::vector fids; + for (const auto& e : m.Entries()) + if (!one || e.finger == *one) fids.push_back(e.fid); if (one) m.Remove(*one); else m.Clear(); SaveMap(uid, m); if (g_claim.held && g_claim.uid == uid) g_claim.fingers = m; - // The trustlet-side template is NOT removed. FF_CMD_TA_REMOVE (0x2006) - // exists but its payload has not been reverse-engineered, and guessing - // at a command that writes to the store is how an index gets - // invalidated. Until it is, a deleted finger loses its name and stops - // being offered, but its template still occupies a slot in the group. - std::println("deleted finger name(s) for uid {} -- trustlet template(s) NOT removed " - "(FF_CMD_TA_REMOVE not yet implemented)", uid); - g_dbus_method_invocation_return_value(inv, nullptr); + // 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 + // offering a finger that can no longer match. + std::println("deleting finger name(s) for uid {}; removing {} trustlet template(s)", + uid, fids.size()); + if (fids.empty()) { + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + // Only the worker thread ever invokes the trustlet, so the reply waits + // for it -- fprintd's Delete methods are synchronous to the client. + g_worker->Post(Job{ .kind = Job::Kind::Remove, .uid = uid, + .acceptFids = fids, .invocation = inv }); return; } if (method == "EnrollStart" || method == "VerifyStart") { @@ -2553,6 +2631,29 @@ int RunDaemon() { // ----------------------------------------------------------------------------- // Diagnostic modes: the probe flow, kept for a phone with no bus client at hand. // ----------------------------------------------------------------------------- +// --probe-remove=: send ONE 0x2006 with no map involvement. +// +// It exists because the recovered payload had to be provable without +// destroying anything. Every wrong call the trustlet can receive here is a +// LOGGED REFUSAL rather than damage -- a fid it does not hold is not found, a +// gid that is not the active group is "hasn't been loaded", and zero is +// refused outright -- so the negative cases establish that the two words are +// being read where we think they are, at no risk to an enrolled finger. The +// positive case deletes a template container and cannot be undone: QTEE seals +// every stored object to a hardware anti-rollback counter, so a removed +// template is gone, not archived. +int RunProbeRemove(std::uint32_t gid, std::uint32_t fid) { + namespace ta = fingerprintd::ta; + Session s; + if (!s.Start()) return 1; + int loaded = s.SetActiveGroup(gid); + std::println("=== probe: REMOVE gid={} fid={} ({} template(s) loaded) ===", + gid, fid, loaded); + int removed = s.RemoveTemplates(gid, { fid }); + std::println("PROBE RESULT: {} template(s) removed", removed); + return removed > 0 ? 0 : 2; +} + int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, bool doLearnProbe, std::uint32_t gid, int frames) { namespace ta = fingerprintd::ta; @@ -2674,6 +2775,7 @@ int main(int argc, char** argv) { std::span args(argv, static_cast(argc)); bool probe = false, daemon = false, doAuth = false, doEnrol = false, doCalSave = false; bool doLearnProbe = false; + std::uint32_t probeRemoveFid = 0; std::string probeTa; std::uint32_t gid = 0; int frames = 120; @@ -2693,6 +2795,10 @@ int main(int argc, char** argv) { if (a == "--enrol") { doEnrol = true; probe = true; } if (a == "--cal-save") { doCalSave = true; probe = true; g_verbose = true; } if (a == "--probe-learn") { doLearnProbe = true; probe = true; g_verbose = true; } + if (a.starts_with("--probe-remove=")) { + probeRemoveFid = static_cast(std::stoul(std::string(a.substr(15)))); + g_verbose = true; + } if (a.starts_with("--frames=")) frames = std::stoi(std::string(a.substr(9))); if (a.starts_with("--frame-gap=")) g_frameGapMs = std::stoi(std::string(a.substr(12))); if (a == "--undecided=nomatch") g_undecidedIsNoMatch = true; @@ -2715,6 +2821,7 @@ int main(int argc, char** argv) { if (!daemon || g_logDirExplicit) StartTranscript(g_logDir); if (!probeTa.empty()) return RunProbeTaLoad(probeTa); if (daemon) return RunDaemon(); + if (probeRemoveFid) return RunProbeRemove(gid, probeRemoveFid); if (probe) return RunProbe(doAuth, doEnrol, doCalSave, doLearnProbe, gid, frames); std::println(std::cerr, @@ -2724,6 +2831,9 @@ int main(int argc, char** argv) { " --probe-ta-load=PATH load one TA image and report the loader result\n" " --auth | --enrol | --cal-save diagnostic loops (see README)\n" " --probe-learn send one UPDATE_TEMPLATE, no finger needed\n" + " --probe-remove=FID [--gid=N] send one REMOVE. A fid the group does not\n" + " hold is refused harmlessly; one it DOES hold\n" + " is deleted and cannot be recovered\n" " --ta-log print the trustlet's own log lines\n" " --learn=0|1 [--learn-frames=N] fold a matched press back into the\n" " template, as stock does (default on, 8)\n" diff --git a/interfaces/Fingerprintd-Ta.cppm b/interfaces/Fingerprintd-Ta.cppm index f8a077c..efc011f 100644 --- a/interfaces/Fingerprintd-Ta.cppm +++ b/interfaces/Fingerprintd-Ta.cppm @@ -44,6 +44,7 @@ export namespace fingerprintd::ta { Cancel = 0x2004, ResetLockout = 0x200a, Enumerate = 0x2005, + Remove = 0x2006, SetActiveGroup = 0x2007, Authenticate = 0x2008, }; @@ -330,6 +331,45 @@ export namespace fingerprintd::ta { out[AuthCoveredOff] = static_cast(covered ? 1 : 0); } + // REMOVE (TA 0xd7b0, reached from the 0x2006 stub at 0xa15c, which is a + // bare `ldp w0, w1, [payload]`): + // +0x00 u32 gid + // +0x04 u32 fid + // Declared length 0x08. The 0x2000-range dispatcher range-checks the + // command id and jumps; it validates no length, so the payload is exactly + // the two fields. + // + // Three preconditions, all of them the trustlet's own: + // + // gid must equal the ACTIVE group. ff_trustlet_remove compares it + // against device+0x30 -- the same field SET_ACTIVE_GROUP writes and + // AUTHENTICATE checks -- and logs "templates with gid(%u != %u) hasn't + // been loaded." on a mismatch. + // + // fid must be NON-ZERO. Zero is not "remove them all": the trustlet + // logs "error at %s[%s:%u]: removing template with fid equ 0." and + // refuses. Removing every finger means calling this once per fid. + // + // The fid must be among the templates currently LOADED. The trustlet + // walks its loaded list for a matching id and removes by SLOT INDEX, + // not by id -- libfp_template_remove takes the index it found. + // + // It persists. On a hit the trustlet logs "template (gid = %u, fid = %u) + // is found at slot %d.", formats "%s/ff_template_%d_%d.bin" and calls + // ff_file_delete, which arrives on the gpfile listener as an unlink -- so + // the daemon must be serving the store WRITABLE or the container survives + // the call that reported success. + inline constexpr std::size_t RemovePayloadSize = 0x08; + inline constexpr std::size_t RemoveGidOff = 0x00; + inline constexpr std::size_t RemoveFidOff = 0x04; + + inline void BuildRemovePayload(std::span out, std::uint32_t gid, + std::uint32_t fid) { + std::ranges::fill(out.first(RemovePayloadSize), std::byte{0}); + detail::StoreU32(out, RemoveGidOff, gid); + detail::StoreU32(out, RemoveFidOff, fid); + } + // SET_ACTIVE_GROUP writes its gid to device+0x30 and AUTHENTICATE compares // its own against the same field (0xeb08), logging // "templates with gid(%u != %u) hasn't been loaded." and returning -200 on diff --git a/packaging/APKBUILD b/packaging/APKBUILD index 2d2b5c4..3eac6b7 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -10,7 +10,7 @@ # Alpine, so an APKBUILD that compiled from source could not be built by # anyone but us either. pkgname=fingerprintd -pkgver=0.1.2 +pkgver=0.1.3 pkgrel=0 pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)" url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd" diff --git a/tests/Ta/main.cpp b/tests/Ta/main.cpp index 54cc8c1..fdd84a9 100644 --- a/tests/Ta/main.cpp +++ b/tests/Ta/main.cpp @@ -239,6 +239,21 @@ int main() { Check(std::to_integer(au[AuthRelightOff]) == 0, "flags clearable"); } + // ---- REMOVE payload. Recovered from the 0x2006 stub at 0xa15c, which is + // a bare `ldp w0, w1, [payload]` into ff_trustlet_remove. + { + std::vector rm(RemovePayloadSize); + BuildRemovePayload(rm, 10000, 1768306590); + Check(RemovePayloadSize == 0x08, "declared length is exactly the two fields"); + Check(Get32(rm, RemoveGidOff) == 10000, "gid at +0"); + Check(Get32(rm, RemoveFidOff) == 1768306590, "fid at +4"); + // Order matters and is not symmetric: the trustlet compares the FIRST + // word against device+0x30 (the active group) and searches its loaded + // list for the SECOND. Swap them and it reports the wrong-group error. + Check(RemoveGidOff < RemoveFidOff, "gid precedes fid"); + Check(static_cast(Cmd::Remove) == 0x2006, "command id"); + } + // ---- ENROLL payload: an all-zero token is accepted when trusted // enrolment is off, which is why pmOS needs no Gatekeeper. {