Files
Mykhailo ShevchukandClaude Opus 5 2cbad71c92 T5577: Sniff Password, a user dictionary, and two fixes for the 38-bit frame hazard (#1192)
* RFID: do not send a T5577 password to a chip that is not asking

A passworded read and a plain write are both 38 bits and the tag tells
them apart by length alone, so a password frame that reaches a tag with
no password set is parsed as a write of `password << 1` into the block it
addressed - with the password's bit 31 landing on that block's lock bit,
which is one way.

A restore escalates to that frame when its read back fails, and until now
it decided to from the dump alone. Block 0 goes back last, so every write
lands on the chip as it was found while the read back lands on the one the
order has just made: the two take their answer from different places, and
`t5577_restore_password()` is where that is now said once.

Also here, from the same run:

- the plain detect is retried three times before escalating, because it is
  three reads of block 0 and a badly coupled tag can lose all three; a
  false negative there is what reaches for the dangerous frame
- the restore logs which of the two reasons left it unable to check, which
  the screen's "could not check" cannot distinguish
- a block is checked with t5577_block_holds() before its value is
  recovered. Block 1 of an EM4100 key is nine 1s and then long runs of one
  bit - the fewest transitions of any block to lock onto - so a key that
  was written perfectly is exactly where value recovery loses, and the
  restore then called it a tag that had moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: recover a password by listening to a reader or cloner

Sniff Password holds the coil against another device and decodes the
T5577 commands it sends. It transmits nothing - TIM2's external trigger
is wired to the carrier so the carrier resets the timer rather than
clocking it, and the timer only overflows when the carrier stops, which
is a gap. Every antenna pin stays low, so pointing it at hardware you do
not own cannot change anything.

Nothing is decoded in the handler. It records raw gap widths and the
periods between them into a ring, and the framing and decoding run
afterwards over the whole capture: a reader repeats one frame forever,
but a cloner sends each command once, and a framing rule applied in the
handler throws away the only copy.

Only the frame LENGTH picks the command, and 38 bits is a plain write or
a passworded read with the fields in different places. Both readings are
filled and neither is claimed unless the capture rules one out: a pause
too short for anything to have programmed rules out the write, and a
read-mode command after a programming-time wait rules out the read. That
command follows every read as well as every write, so on its own it
proves nothing - the wait is what separates them.

The bit threshold is measured per capture rather than fixed. A fixed one
cannot decode leading-zero mode at all, whose two classes both sit below
it, and a sender this has never met is the normal case. The classes are
the two peaks of a histogram filled from bursts whose length could be a
frame; clustering was tried and separates the data from its outliers
rather than one class from the other.

Validated against a blue cloner: its password recovered identically over
four runs, with its whole write sequence in clear, including the config
words that put the PWD bit on and take it off again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: do not report an unreadable reader from an empty coil

The verdict for "the carrier was cut but never at a width this can
measure" rested on `stalls > 0 && gaps == 0`, and the first half of that
is always true: the timer's trigger pin is never quiet, and open air
alone logs thousands of stalls with nothing in front of it. So an empty
coil reported a reader this could not read - a claim about somebody's
hardware drawn from a failure to parse, which is the one thing this
feature must not do.

Caught on a desk: "Can't measure - carrier cut, but never for a readable
width", with no reader present.

Removed. "Nothing heard" now carries the counts instead of advice. The
advice was also wrong in the case it mattered: "hold it on the reader's
coil" is unhelpful when the coil already is on one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* RFID: keep T5577 passwords the user found in a dictionary of their own

The shipped list says so itself - "Do not edit, this file will be
overwritten after firmware update" - so a password somebody recovered and
added to it survives until the next update and then does not. The NFC key
dictionaries solved this years ago with a system list and a user list;
this is the same split.

t5577_dict_user.lfrfid sits beside the shipped file and is read first. A
word somebody kept is likelier to open the tag in front of them than one
of the hundred-odd the vendor ships, and the order matters for more than
speed: every attempt before the right one is a password frame sent to a
tag that may not want it, and about a third of the shipped words have bit
31 set.

Both totals are counted at the start so the bar and the "of N" on the
status line do not change under the user when the sweep hands over, and
the hand-over happens when the first list runs out rather than when a
word count reaches its total - a list that stops early would otherwise
take the shipped words down with it.

Nothing creates the file. It opens as an empty list when absent, which is
what the sweep already does with a missing shipped list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: offer to keep a sniffed password

The app could learn a password off the air and had nowhere to put it, so
the next tag protected with it got the whole sweep again. Sniff Password
now offers Save, which appends to the user's own list.

Both lists are checked, not just the one being written to. The shipped
list holds a hundred-odd words a cloner is likely to be using - 51243648,
the first password this ever recovered off the air, is already line 79 of
it - so "already known" is the common answer for a stock cloner, and
saying so is more use than silently writing a duplicate. This follows
nfc_key_dict_import() rather than the hand-typed path, which checks only
the user list: a word that came off a cloner is far likelier to be one of
the shipped ones than a word somebody typed.

Offered only where the capture ESTABLISHED a password - a passworded read
or write, or a plain write into block 7. Never for the unresolved 38-bit
reading: that one is a guess between a password and a write, and the list
it would go into is one Clear Password later sends at tags, which is the
frame that cannot be taken back when it is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: show what the coil is picking up, not a progress bar

A capture has no total to count towards. One press of a cloner is a dozen
commands and every answer it holds, so a bar was wrong twice: first
counting commands against the frame table, which read as a target the
user was failing to fill, then counting down a window nobody had asked
about.

What somebody holding a Flipper against a cloner wants to know is whether
it is hearing anything, and how that changes as they move it. So the
listening screen now draws a trace - one bar per tick, tall where gaps
arrived and flat where none did, scrolling left. It shows activity and
never progress, and it is useful while positioning the coil rather than
only afterwards.

Its own view rather than a mode on the work view: that one's bar is
shared with Read, Restore, Wipe and Clear, where a total genuinely exists
and the bar is right.

A sample goes in every tick whatever happened, so a flat trace is a
measurement - a screen that only moved on activity could not be told from
one that had stopped updating. The scale puts a desk near the floor and a
sender at the ceiling: forty gaps in a tick fills it, against the five a
Flipper on a desk averages and the hundred-plus a cloner puts through one
mid-frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: make the write run findable in a capture full of idling

A sender idles either side of whatever it came to do. A blue cloner's
write run was twelve frames of forty-six, and a second press put the same
run in twice - so the list opened on junk, and its length read as a wall
of findings when most of it was the coil listening to a quiet room.

Two changes. The header now says how many of the frames decoded, so the
count that matters is on screen before any scrolling; it keeps the
dropped count, since the list silently stops at the table's end and a
short list is otherwise indistinguishable from a short capture.

And the Frames button opens on the frame the headline came from rather
than at the top. The verdict already knows which frame it drew the
password from - it just was not saying.

Nothing is hidden. The undecoded rows are the evidence that diagnoses a
sender this cannot frame, and their lengths are the whole of what is
known about them. This is about finding the run, not filtering the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: stop the sniff result's buttons running into each other

With a password found the screen carries three buttons, and the widget
centres the middle one and right-aligns the last - so their widths simply
have to fit. Measured off a device: "Again" 36 px, "Save" 37, "Frames"
44, which put the centre button's right edge at 82 and the right
button's left edge at 83. One pixel between them, and they read as one
shape.

"More" instead, which is what the sister app calls the same screen and
what the app's own read result does with its third button. The lines that
told the user where to look follow the button's name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: drop the picture from the listening screen

It was the work view's, which shows a card being put ONTO a Flipper -
the opposite of what this screen asks for, where the Flipper goes
against somebody else's reader. A FAP cannot link the firmware's own
icons, so there was no better one to reach for, and drawing a cloner
would have replaced a wrong picture with a guess at one.

Removing it is the honest version and the better layout. The picture was
using sixty of the hundred and twenty-eight pixels, which had squeezed
the trace into a 62 px column and cut the status line to one word. The
trace now runs the full width and holds sixty ticks instead of
twenty-eight - about twelve seconds, so a write run is still on screen
after it finishes rather than scrolling away while the user looks up.

What the picture was failing to say is now said in words at the bottom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: review fixes - say which read back failed, and fix the hazard's own wording

Five review agents over the parts of this branch that had never been looked
at. No crashes and no memory faults; what they found was the documentation
of the hazard being wrong about its own frames, and the screens being
silent about the one thing that cannot be taken back.

The rationale on t5577_restore_password() applied the 38-bit collision to
all eleven frames it governs. Ten of them are passworded WRITES at 70 bits,
which no other command is, so a chip that wants no password simply refuses
them. Only the read back - a passworded read, 38 bits, the same length as a
plain write - is the frame the flag exists to withhold. The derivation now
sits where it is true.

The same function claimed the caller gates it again. It does not: the
caller's test reduces to the same expression over the same dump. What
actually keeps the frame from going out first is the plain detect having
failed, which is evidence from the tag rather than from a file, and the
comment now says so. Three agents found this independently.

The restore's three "could not check" outcomes reached one sentence,
identical to what holding a Flipper over an empty desk produces. They are
now three, because they call for different things: a tag that wants a
password this dump does not hold, the password frame having gone out with
nothing answering it, and nothing answering at all. The detect also raises
its near miss - a chip that answered and then turned out not to be asking
means the frame was read as a write - instead of logging it as success.

Sniff's Save now asks before keeping a word the capture deduced from
neighbouring frames rather than read off a length. The headline already
said "if that was a read"; Save dropped the caveat and wrote it as fact
into a list that Clear Password later sends at tags.

"Nothing heard" claimed no readable gap while in-band gaps had been
counted. It reports both numbers now.

Tests: two read-back cases that a sixteen-mutation sweep left uncovered -
a dump with neither block 0 nor a password, which must not send a frame
carrying zero, and a protected dump whose block 7 was never read, which
must carry the word the chip was opened with rather than block 7's padding.
The host suite also only ever asserted t5577_sniff_commanded() false, so a
version returning false unconditionally passed all of it; both sides of
T5577_SNIFF_COMMAND_MIN are pinned now.

Also: the ring doubled to 2048 entries and three comments still said four
kilobytes; two comments still called the More button Frames; the app
struct called the feature Sniff Reader; the burst-versus-command note and
the why-not-a-bar note were each stated twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: simplify after the review pass

Four cleanup agents. Nothing behavioural except where the review pass had
left two ways of saying one thing.

The dictionary handover wrote its failure path twice, verbatim including
the log line, nested two deep. It is a loop that can only run twice - the
user list goes to the shipped one once, and a second miss returns - so it
is a while now.

The restore result picked three sentences with an if/else-if over a
four-value enum, where this app's own convention is a switch with no
default so a value added later is a build failure rather than a wrong
sentence on screen. The enum's fourth value was never read: every path
that reaches the screen assigns one of the other three first, so Silent is
the zero and the initial state both.

The listen view took a level in pixels, which meant the scene owned both
halves of the scaling - how many gaps saturate a tick, which is a radio
measurement, and how tall a saturated tick is drawn, which is not. It now
takes the two numbers the scene measured and does the arithmetic once, and
the pixel count leaves the header.

The password-to-an-open-chip hazard was spelled out in full in three
places; scene_clear.c was pointed at the authoritative @warning during the
review and the other two were not. The read-back gate's branch selector
was a negated compound with the uninteresting case first, and said "block
0 goes back last" three times in one function.

Also: an unreachable saturation guard on the quiet counter, which the
capture's own timeout already bounds; a comment explaining a loop sixteen
lines below it; a kind name returning the same literal its own fallback
does; and four rationales that were each stated twice.

Two findings recorded and not taken. Spreading the restore's three detect
attempts across timer ticks would cut the worst-case input freeze from
5.76 s to 1.44 s and fits an existing 1-byte hole in the app struct, but
it changes the restore path and wants a tag to test against. Reordering
T5577SniffFrame saves 192 bytes of heap at the cost of separating fields
from the comments that explain them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* T5577: list, add and delete the user's own passwords

The app could already learn a password off the air and write it to the
user's dictionary, and it sweeps that dictionary in Clear Password. What
it could not do was show you what is in it.

Passwords, on the main menu, follows the NFC key dictionaries: List, Add
and Delete all act on the USER list only, and the shipped one is only
ever counted. That is not an omission - the shipped file is replaced
wholesale by the next firmware update, so editing it achieves nothing,
and its own header says as much.

Add checks BOTH lists even though it writes to one. The shipped list
holds a hundred and twenty-seven words a cloner is likely to be using, so
"already known" is a more useful answer than a silent duplicate - and it
is the common one. That check is the same helper the sniff result's Save
uses, moved into the app so a word typed in and a word recovered off the
air get the same four answers.

The user's file is still created in exactly one place, by a successful
add. Counting it, listing it and reading a word out of it all open it
OpenExisting, so looking at this screen does not bring a file into being
- which is what T5577_DICT_USER_PATH's documentation promises. NFC uses
OpenAlways throughout and would have created one; this is the one place
the parity is deliberately not followed.

Deleting the last word returns to the menu rather than to a list with
nothing in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: say "system dict" and "user dict", and lift the dialogs off the button

The Passwords screen now reads as the NFC key dictionaries do - same title
shape, same two count lines, same key beside them, Add and List in the
same places. It said "Shipped" and "Yours", which are the same two ideas
under names only this app used; somebody who has met the NFC screen should
not have to learn a second vocabulary for the same file pair.

The key is this app's own. A FAP cannot link the firmware's icons, so
I_Keychain_39x36 is not reachable and Key_39x36.png is drawn to sit in
the same box at the same place.

Separately, t5577_app_say() put its header at y=10 and its body at y=32.
A two line body then ran from 32 to 52 and pushed its descenders through
the OK button, with fourteen empty pixels sitting above it. The app's own
t5577_app_ask() has used 4 and 20 all along and looks right; say() was
the odd one out and now matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbBdNcYKs8cAxYufLgVavW

* CHANGELOG: the T5577 sniffer, the user dictionary, and the restore fix

PR numbers go in once the branch is pushed. fap_version stays at 1.1 - the
app is the same app, with more in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: look for the tag a tick at a time, so Back is answered

Every other step of the restore does one unit of work per tick; the detect was
the exception, spending three plain reads and then the password read back in a
single handler. That is around six seconds in which no input is read, so Back
queued unanswered and the presses arrived together at the end - which also left
the dump's own row in the file browser dead until the list was navigated away
from and back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: the screens say "cloner", since that is what is held against them

A door reader works too - the feature was proven against one - but a cloner is
what a user brings to these screens, and "reader" next to a Flipper reads as
the Flipper. Both lines keep the length they had, so neither can start wrapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: a plain read before the passworded one, a tick each

Reading with a password was the last flow in the app to send the 38 bit frame
on nothing but the user's word. A chip that asks for no password reads it as a
write of that word shifted up, its top bit into the addressed block's lock bit
and not coming back out - so the plain read goes first here as it already does
in Clear Password and in the restore's read back, and a tag that answers it
never needed the frame.

The two detects are separate ticks. Each is most of a second, and two in one
handler is what left Back unanswered in the restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* CHANGELOG: the unfreeze, and the plain read that now goes first everywhere

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: keep the host tests that were only ever in a scratch folder

21 cases over the sniffer's framing and decode, built on synthetic rings whose
answer is known before any of it meets a cloner, and the MU_TEST body for
t5577_restore_password run on the host. Both found real bugs while the feature
was being written and neither lived anywhere a second person could run them.

They are host tests rather than unit_tests cases because unit_tests cannot link
in this tree - a pre-existing DWARF error, nothing to do with this app.

application.fam has to say "!test": the default source glob is "*.c*" and it
recurses, so the suites would otherwise be compiled into the app, where their
host stubs cannot build and their main() collides with the entry point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: warn about the frame that went out, not the word that was typed

Review of the previous commit. Before a plain read ran first, password_entered
implied the 38 bit frame had been sent, so the read result screen could say
"that frame wrote block 0" on password_entered && !with_pass. The plain read
broke that: it succeeds with with_pass clear and no frame sent at all, so the
one path added to AVOID the hazard became the path reporting it - and the
remedy it offered was to wipe a healthy tag. The fact is now recorded where
the frame is actually built.

Also from the review, all comment or reporting rather than behaviour:
- "That frame is 38 bits" sat above the PLAIN read, which is 6 bits and the
  safe one. It now names the passworded frame and gives both lengths.
- An attempt costs most of two seconds, not "most of a second": a detect is
  three reads of block 0, one per rate. Both places said the smaller figure.
- The restore's "no read back" line credited the tag with an answer it never
  gave - `asks` reads the dump, and by there nothing has been heard from the
  tag at all. It now says so, and logs how many plain reads were spent.
- The restore now says "Trying password" on the tick before the risky frame,
  as the read already did; it showed "Finding tag" throughout.
- CHANGELOG: the menu item is "Passwords", not the heading on the screen it
  opens; a password typed at the prompt is not kept, only one added by hand.
  And the Back entry counted a bug state that never shipped - the three
  attempt loop lands on this same branch, so what users have is two detects.
  The dead file browser row came out with it: that went in on observation and
  file_browser.c drops OK for a row outside the loaded window all by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: cut the MU_TEST body out of the unit test instead of copying it

The copy committed with the harness was already two cases behind the original:
it predated f13ee509c, which added the `blind` and `kept` cases - and those are
exactly the two the README advertised as the gaps mutation testing had found.
So the suite claimed coverage it did not run, and the "keep it in step" note
had been violated before it was written.

run.py now slices the named MU_TEST out of lfrfid_protocols.c on every run and
writes it to a gitignored file the harness includes, so editing that test
cannot leave this suite behind. 164 lines now, against the copy's 144.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: say why the tests live on the host, including the reason that outlives the bug

The DWARF link error is the reason they were written here; it is not the reason
they belong here. unit_tests is its own app set in fbt_options.py, replacing
main_apps and system_apps, so a case there costs a second firmware and a flash -
which is the wrong price for arithmetic over a synthetic ring buffer. Worth
writing down, so that fixing the link error does not read as an invitation to
move them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: six seconds, not seven - the figure is four attempts of 1.47 s

Derived rather than estimated: a failed block 0 read is 450 ms settle plus a
30 ms silence break plus two frames, about 490 ms, and a failed detect pays
that at all three rates. Three plain attempts and the password read back come
to 5.88 s for a silent tag; seven is only reached when the capture window runs
its full 150 ms each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: fold the step into the find, and stop saying things twice

Moving on to the blocks is what finding the tag means, so it belongs in
t5577_app_scene_read_found() rather than at both call sites - where one of the
two wrote a step the scene never reads again, the failure path leaving for the
result screen without looking at it.

The restore's password doc comment ended by saying a plain read goes first,
which the paragraph four lines above it now says: that sentence earned its
place when this function still contained the loop.

run.py: the suite names carried their own trailing spaces, stripped back off at
both failure sites and still not lining up with the extract line; the format
string does it now. HERE is gone from the restore include path - the only
quoted include is the generated body, which sits beside the file including it.
And the docstring restated the README's own paragraphs, so two files had to
agree about the same facts.

Left alone on the reviewers' own advice: the restore's three-level detect
branch, where every flatter form trades the nesting for a subtler obscurity;
open_try as a struct field, which is where this app keeps every per-run
counter; and password_entered beside password_sent, which have different
lifetimes as well as different meanings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* CHANGELOG: PR #1192

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* T5577: drop the host test harness from the repo

Not something this repo carries. The MU_TESTs for t5577_restore_password stay
where they belong, in applications/debug/unit_tests/tests/lfrfid.

application.fam goes back to the default source glob with them: "!test" was
only there to keep the harness out of the app build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 23:24:15 +02:00
..
2024-07-15 20:02:45 +03:00
2024-07-15 20:02:45 +03:00
2026-06-09 06:14:32 +03:00
2024-07-15 20:02:45 +03:00