Add enrolment, and let it choose its own namespace

Enrolment is the first thing here that writes: template containers through the
gpfile listener and counter records through RPMB. It refuses to run unless both
--sfs-writable and --rpmb-write are given, and it refuses to call SAVE_DATA if
the sample count did not reach zero, because a partial template is worse than
none.

The sequence is stock's: cancel, reset-lockout, authenticate, cancel,
reset-lockout, PRE_ENROLL, authenticate, cancel, ENROLL, the sample loop,
POST_ENROLL, SAVE_DATA with bit 30 set. AUTHENTICATE is what arms the capture
session, which is why it appears in an enrolment at all.

Enrolment takes one sample per PRESS: touch on the rising edge, release on the
falling one, nothing in between. Stock's entire enrolment trace contains no
image-ready event, and feeding every held frame gives the algorithm
near-duplicate images from a single press.

Two things named honestly. The ENROLL payload's u32 at +69 was recorded here as
a "timeout"; the trustlet reports it back as the GROUP ID, and filling a
mislabelled field with a plausible number is the entire provenance of gid 60.
It is the gid now, so an enrolment can choose its own group.

And --group-path exposes the namespace key the trustlet hashes into the group's
directory name. It defaults to Android's, which is where this device's existing
store lives and how that template is readable. But SAVE_DATA rewrites the
group's index container, and an index QTEE later fails to verify takes every
template listed in it -- so enrolling into a DIFFERENT namespace is complete
isolation from a store we did not write.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 20:12:24 +02:00
commit 4e8a763cf9
3 changed files with 146 additions and 9 deletions

View file

@ -54,10 +54,22 @@ constexpr const char* Version = "0.0.3";
bool g_verbose = false;
bool g_listeners = false;
bool g_auth = false;
bool g_enrol = false;
int g_frames = 40;
int g_frameGapMs = 500;
std::string g_logDir = "/var/log/fingerprintd";
int g_rescan = -1; // -1 = leave the config's value alone
// The namespace key the trustlet hashes into the SFS group's directory name.
// Defaults to Android's, because that is where the store this device already
// holds was written and it is what an existing template can be read under.
//
// A DIFFERENT path is a different group directory, i.e. complete isolation
// from the Android groups. That matters for enrolment: SAVE_DATA rewrites the
// group's index container, and an index QTEE later fails to verify takes every
// template listed in it with it. Enrolling into our own namespace cannot
// damage a store we did not write.
std::string g_groupPath{fingerprintd::ta::GroupNamespacePath};
std::uint32_t g_gid = 0;
std::string g_taPath = "/lib/firmware/focal64.mbn";
std::string g_cfgPath = "/lib/firmware/fingerprintd.json";
@ -1037,9 +1049,8 @@ int Probe() {
// the chain answers -2 and loads nothing, which reads like a missing
// container and is an ordering bug.
if (g_listeners) {
auto sag = fingerprintd::ta::BuildSetActiveGroup(g_gid);
std::println("\nSET_ACTIVE_GROUP gid={} path='{}'", g_gid,
fingerprintd::ta::GroupNamespacePath);
auto sag = fingerprintd::ta::BuildSetActiveGroup(g_gid, g_groupPath);
std::println("\nSET_ACTIVE_GROUP gid={} path='{}'", g_gid, g_groupPath);
auto g = SendCommand(app, fingerprintd::ta::Cmd::SetActiveGroup, sag);
Report(fingerprintd::ta::Cmd::SetActiveGroup, g);
@ -1174,6 +1185,119 @@ int Probe() {
std::println(" {}", tally.Identified() ? "FINGER IDENTIFIED" : "no match");
}
// ---- Enrolment
//
// The first thing here that WRITES: template containers through the gpfile
// listener and counter records through RPMB. Both are gated behind
// explicit flags, and RPMB writes cannot be undone.
if (g_enrol) {
namespace ta = fingerprintd::ta;
namespace en = fingerprintd::engine;
if (g_sfsReadOnly || !g_rpmbWrite) {
std::println(std::cerr,
"enrolment needs --sfs-writable and --rpmb-write; refusing");
return 1;
}
// Stock's opening sequence. AUTHENTICATE is what arms the capture
// session; CANCEL and RESET_LOCKOUT bracket it.
std::vector<std::byte> au(ta::AuthPayloadSize);
ta::BuildAuthPayload(au, 1, 0);
std::println("\n=== enrol pre-sequence ===");
SendCommand(app, ta::Cmd::Cancel, {});
SendCommand(app, ta::Cmd::ResetLockout, {});
SendCommand(app, ta::Cmd::Authenticate, au);
SendCommand(app, ta::Cmd::Cancel, {});
SendCommand(app, ta::Cmd::ResetLockout, {});
auto pe = SendCommand(app, ta::Cmd::PreEnroll, {});
Report(ta::Cmd::PreEnroll, pe);
SendCommand(app, ta::Cmd::Authenticate, au);
SendCommand(app, ta::Cmd::Cancel, {});
// The token is all zero: with trustlet.enable_trusted_enrollment false
// the trustlet skips the version check, the challenge compare and the
// HMAC verify outright, which is why pmOS needs no Gatekeeper. The u32
// at +69 is the GID this enrolment lands under.
std::vector<std::byte> tok(ta::EnrollPayloadSize);
ta::BuildEnrollPayload(tok, g_gid);
std::println("\n=== ENROLL gid={} ===", g_gid);
auto er = SendCommand(app, ta::Cmd::Enroll, tok);
Report(ta::Cmd::Enroll, er);
if (!er.invoked || er.result != 0 || er.rc != 0) {
std::println(std::cerr, "ENROLL refused; nothing written");
return 1;
}
for (int c = 3; c > 0; c--) {
std::println("*** press and LIFT, repeatedly, in {}... ***", c);
std::fflush(stdout);
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::println("\n*** GO -- press, hold briefly, lift, and move the finger "
"slightly each time ***\n");
// Enrolment takes ONE sample per PRESS. Stock sends touch on the
// rising edge and release on the falling one and nothing in between;
// its whole enrolment trace contains no image-ready event. Feeding
// every held frame instead gives the algorithm near-duplicate images
// from a single press.
en::TouchTracker tracker;
en::EnrolSession enrol;
for (int i = 0; i < g_frames && !enrol.Complete(); i++) {
std::vector<std::byte> q(0x10, std::byte{0});
SendCommand(app, ta::Cmd::QueryEventStatus, q);
std::vector<std::byte> cap(ta::CaptureDeclaredLen);
ta::BuildCapturePayload(cap);
auto c = SendCommand(app, ta::Cmd::CaptureImage, cap);
bool finger = baseline.IsFinger(c.metric);
std::string note;
for (ta::Event ev : tracker.Observe(finger, en::Mode::Enrol)) {
std::vector<std::byte> evbuf(ta::EventContextSize);
ta::BuildEventContext(evbuf, { .event = ev });
auto r = SendCommand(app, ta::Cmd::ReportEvent, evbuf);
if (!r.invoked) continue;
// Samples remaining rides in the response on the common path,
// whether or not the sample was accepted -- which matters
// because the trustlet's log starves exactly when one is.
enrol.Observe(r.samplesRemaining);
note += std::format(" ev{} rem={}", static_cast<unsigned>(ev),
r.samplesRemaining);
}
SendCommand(app, ta::Cmd::QueryEventStatus, q);
std::println(" frame {:2}: metric={:<4}{}{}", i + 1, c.metric,
finger ? " FINGER" : " ", note);
std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs));
}
std::println("\nsamples: {} of {} accepted", enrol.Accepted(), enrol.Total());
if (!enrol.Complete()) {
std::println(std::cerr,
"enrolment did not complete -- NOT saving a partial template");
return 1;
}
auto po = SendCommand(app, ta::Cmd::PostEnroll, {});
Report(ta::Cmd::PostEnroll, po);
// Bit 30 set is the template path; clear is calibration.
std::vector<std::byte> sd(0x10, std::byte{0});
for (std::size_t k = 0; k < 4; k++)
sd[k] = static_cast<std::byte>((ta::SaveMaskTemplate >> (8 * k)) & 0xFF);
std::println("\n=== SAVE_DATA (template) ===");
auto sv = SendCommand(app, ta::Cmd::SaveData, sd);
Report(ta::Cmd::SaveData, sv);
auto en2 = SendCommand(app, ta::Cmd::Enumerate, {});
Report(ta::Cmd::Enumerate, en2);
std::println(" templates now in group {}: {}", g_gid, en2.rc);
}
std::println("\ntrustlet initialised against a powered sensor.");
pthread_cancel(th);
pthread_join(th, nullptr);
@ -1200,9 +1324,11 @@ int main(int argc, char** argv) {
if (a == "--sfs-writable") g_sfsReadOnly = false;
if (a == "--rpmb-write") g_rpmbWrite = true;
if (a == "--auth") { g_auth = true; g_listeners = true; }
if (a == "--enrol") { g_enrol = true; g_listeners = true; }
if (a.starts_with("--frames=")) g_frames = std::stoi(std::string(a.substr(9)));
if (a.starts_with("--log-dir=")) g_logDir = a.substr(10);
if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9)));
if (a.starts_with("--group-path=")) g_groupPath = a.substr(13);
if (a.starts_with("--sfs-root=")) g_sfsRoot = a.substr(11);
if (a.starts_with("--gid=")) g_gid = static_cast<std::uint32_t>(
std::stoul(std::string(a.substr(6))));

View file

@ -41,6 +41,7 @@ export namespace fingerprintd::ta {
Enroll = 0x2001,
PostEnroll = 0x2002,
Cancel = 0x2004,
ResetLockout = 0x200a,
Enumerate = 0x2005,
SetActiveGroup = 0x2007,
Authenticate = 0x2008,
@ -208,11 +209,17 @@ export namespace fingerprintd::ta {
// ---- ENROLL / AUTHENTICATE payloads -----------------------------------
// ENROLL takes a 69-byte hw_auth_token, a u32 timeout at +69 and a u8 flag
// at +73 (stub 0xa0c8).
// ENROLL takes a 69-byte hw_auth_token, a u32 at +69 and a u8 flag at +73
// (stub 0xa0c8).
//
// The u32 at +69 was recorded in this project as a "timeout". It is not:
// the trustlet reports it back as the GROUP ID. Setting it to 60 is where
// `gid = 60` came from, and the whole gid-60 store exists because a
// mislabelled field was filled with a plausible-looking number. Naming it
// honestly is what makes an enrolment able to choose its own group.
inline constexpr std::size_t EnrollPayloadSize = 74;
inline constexpr std::size_t EnrollTokenSize = 69;
inline constexpr std::size_t EnrollTimeoutOff = 69;
inline constexpr std::size_t EnrollGidOff = 69;
// No Gatekeeper is needed. ff_trustlet_enroll reads config
// trustlet.enable_trusted_enrollment and, when false, skips the version
@ -220,9 +227,9 @@ export namespace fingerprintd::ta {
// outright (0xce34 tbz -> 0xd198), so an all-zero token is accepted.
// pmOS has no Gatekeeper to mint one and nothing there verifies auth
// tokens anyway.
inline void BuildEnrollPayload(std::span<std::byte> out, std::uint32_t timeoutSeconds) {
inline void BuildEnrollPayload(std::span<std::byte> out, std::uint32_t gid) {
std::ranges::fill(out.first(EnrollPayloadSize), std::byte{0});
detail::StoreU32(out, EnrollTimeoutOff, timeoutSeconds);
detail::StoreU32(out, EnrollGidOff, gid);
}
// AUTHENTICATE (TA 0xea88 takes these as x0/w1/w2/w3):

View file

@ -195,7 +195,11 @@ int main() {
std::vector<std::byte> tok(EnrollPayloadSize);
BuildEnrollPayload(tok, 60);
Check(EnrollPayloadSize == 74 && EnrollTokenSize == 69, "enroll payload sizes");
Check(Get32(tok, EnrollTimeoutOff) == 60, "timeout at +69");
// +69 is the GID, not a timeout. The trustlet reports it back as the
// group, which is the entire provenance of gid 60.
Check(Get32(tok, EnrollGidOff) == 60, "gid at +69");
BuildEnrollPayload(tok, 1000);
Check(Get32(tok, EnrollGidOff) == 1000, "an enrolment chooses its own group");
bool tokenZero = true;
for (std::size_t i = 0; i < EnrollTokenSize; i++)
if (tok[i] != std::byte{0}) tokenZero = false;