UPDATE_TEMPLATE: the command stock learns with, and the field that was killing it

Stock rewrites the stored template on every successful press. Its post-match
loop is QUERY_FINGER_STATUS, CAPTURE_IMAGE, 0x1015 UPDATE_TEMPLATE while the
finger stays down, with no REPORT_EVENT in it -- so the matcher does not re-run
and the verdict cannot change. Forty-six of those against eighty-six captures in
one reference session, and the stored body measurably grows: 333278 bytes at
enrolment, 360822 at the next session's load, 371734 after one authentication
session. This daemon sent none of them.

The command shares REPORT_EVENT's event context. The stock wrapper memsets 732
bytes and writes six fields: a zero byte at +0x2a4, the scan-slot count, a zero
word, the count of frames folded so far in this press, a flags word of
0x00080000 with bit 6 set on the frame whose event was FingerTouched, and a zero
at +0x2d8. Declared length 0x2dc.

+0x2d8 is the one that matters, and it matters by staying zero. The dispatcher
stub reads it after the handler returns and only if it is non-zero does it read
+0x2dc and make that the response length. Every earlier attempt in this project
set both fields and varied the declared length across 0x2e0, 0x400 and 0x1000;
all of them answered -90, the trustlet gone, and the conclusion recorded was
"do not retry until a template is loaded". A loaded template was necessary but
not sufficient. The stock HAL sets neither field.

Measured on the device with two templates loaded and no finger: both branches
answer rc=0 and ENUMERATE still reports 2, so the app did not fault.

Those fields are little endian, assembled low-address-first by the handler. An
earlier reading called them big endian, off the bfi order, and was wrong.

Bit 6 selects which algorithm entry runs: clear takes libfp_template_x_update,
set takes the other, which also reads the scan-slot count.
This commit is contained in:
Jorijn van der Graaf 2026-09-03 17:45:27 +02:00
commit e03ce4ae5a
2 changed files with 124 additions and 0 deletions

View file

@ -175,6 +175,56 @@ int main() {
Check((SaveMaskCalibration & (1u << 30)) == 0, "calibration save clears bit 30");
Check(SaveMaskTemplate != SaveMaskCalibration, "the two masks differ");
// ---- UPDATE_TEMPLATE: template learning
{
std::vector<std::byte> up(UpdateTemplatePayloadSize);
BuildUpdateTemplate(up, /*slotIndex*/ 0, /*touchFrame*/ true);
// The declared length stock sends. 0x2e0 was tried in this project and
// answered -90; the length is not a free parameter.
Check(UpdateTemplatePayloadSize == 0x2dc, "declared length is 732, as stock sends");
Check(Get32(up, UpdScanSlotsOff) == 1, "scan slots default to 1, as REPORT_EVENT");
Check(Get32(up, UpdZeroAOff) == 0, "+716 is zero");
Check(Get32(up, UpdSlotIndexOff) == 0, "the first folded frame is slot 0");
Check(Get32(up, UpdFlagsOff) == 0x00080040, "a touch frame sets bit 6 over the base");
// THE invariant. The dispatcher stub reads this word after the handler
// returns and, if it is non-zero, computes the response length from
// +0x2dc. Every attempt in this project that set it killed the app.
Check(Get32(up, UpdRespLenOff) == 0,
"+728 MUST be zero or the stub computes a response length");
BuildUpdateTemplate(up, /*slotIndex*/ 3, /*touchFrame*/ false);
Check(Get32(up, UpdSlotIndexOff) == 3, "the slot index counts folded frames");
Check(Get32(up, UpdFlagsOff) == 0x00080000, "a held frame leaves bit 6 clear");
Check(Get32(up, UpdRespLenOff) == 0, "+728 stays zero on every frame");
// The flags word is NOT the event context's, and confusing the two is
// an easy mistake because the payloads are otherwise the same struct.
Check(UpdFlagsBase != EvDefaultFlags,
"the update flags are 0x00080000, not the event context's 0x08080000");
// The fields it shares with REPORT_EVENT really are at the same
// offsets; that is why one struct serves both commands.
Check(UpdScanSlotsOff == EvScanSlotsOff && UpdSlotIndexOff == EvSlotIndexOff
&& UpdFlagsOff == EvFlagsOff,
"the update payload reuses the event context's field offsets");
// The event id is deliberately NOT written: stock memsets and never
// touches +4, and this command must not re-run the matcher.
Check(Get32(up, EvEventOff) == 0, "no event id -- the matcher must not re-run");
// Every byte outside the written fields stays zero: the whole 732-byte
// payload carries three non-zero bytes here -- the scan-slot count,
// the slot index, and the one set byte of 0x00080000. Anything else
// non-zero means a field was written that stock does not write.
std::size_t nonZero = 0;
for (std::size_t i = 0; i < UpdateTemplatePayloadSize; i++)
if (up[i] != std::byte{0}) nonZero++;
Check(nonZero == 3, "only scan slots, slot index and the flags byte are set");
}
// ---- AUTHENTICATE payload
{
std::vector<std::byte> au(AuthPayloadSize);