Delete a finger's template, not just its name
FF_CMD_TA_REMOVE, recovered the way AUTHENTICATE was: read the stub, read the handler. The 0x2006 stub at 0xa15c is a bare `ldp w0, w1, [payload]`, so the request is two u32s -- gid at +0, fid at +4 -- and the 0x2000 dispatcher validates no length. Walking the jump table reproduces authenticate at 0xa180, which is the address already on record, so the table read is sound. Three preconditions, all the trustlet's own. The gid must be the ACTIVE group (it compares against device+0x30, the field SET_ACTIVE_GROUP writes). The fid must be non-zero: zero is not "remove all", it is an error the trustlet logs and refuses. And the fid must be among the loaded templates, because it removes by the SLOT INDEX it finds, not by id. It persists: on a hit the trustlet formats ff_template_<gid>_<slot>.bin and calls ff_file_delete, which arrives on our gpfile listener as an unlink -- so this only works with the store served writable. Proven harmlessly first. --probe-remove sends one command with no map involvement, and a fid the group does not hold answers rc=-2 with the real template untouched -- which is what established that both words are read where we send them, before anything was deleted. Then for real, through fprintd-delete: both 347202-byte containers and their .bak companions unlinked, templates loaded 1 -> 0, and a re-enrolment afterwards completed 20 stages with SAVE_DATA rc=0, so the store is consistent after a removal rather than merely emptier. The ordering the transcript shows is worth keeping: the group index is rewritten and the RPMB anti-rollback counter bumped BEFORE each unlink. That is precisely why an orderly removal leaves a valid store where restoring an older container leaves a tampered one -- the counter has already moved past it. The delete reply now waits for the worker, because only that thread invokes the trustlet and fprintd's Delete methods are synchronous. Names are dropped before templates on purpose: a template that survives a failed removal is a slot leak, while a name that survives a successful one keeps offering a finger that can no longer match.
This commit is contained in:
parent
b228287c5b
commit
41e86f84f4
4 changed files with 178 additions and 13 deletions
|
|
@ -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<std::uint32_t>& 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<std::byte> 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<std::uint32_t> 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>(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<Event> 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<std::uint32_t> 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=<fid>: 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<char*> args(argv, static_cast<std::size_t>(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::uint32_t>(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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue