restore_bufferS32() returns the length it put back and every graph-buffer
caller dropped it, so g_GraphTraceLen kept whatever value the code between
save and restore had left.
In try_detect_modulation() that is the 160 sample psk antenna-settle trim:
`data modulation` gave the buffer's samples back but left it 160 short, and
a following demod lost round(160 / clk) bits off the end. check_chiptype()
loses the same way through the acquisitions its detections make, on the
`lf search` path, and for g_DemodBuffer too. demodTI() loses convLen + 16
on its error path.
save_graphbuffer() / restore_graphbuffer() pair the length and grid offset
with the contents so no call site has to remember them. The visa2000 and
zx8211 sites do not change the length, so converting them is a no-op today
and keeps the next one from being a bug.
Regression test in tools/pm3_tests.sh: `data modulation` on a saved psk1
trace must leave all 20000 samples.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Simulation now completes the full exchange with a genuine Paxton reader in
password mode, and crypto mode read/write passes Proxmark-to-Proxmark.
Firmware:
- SOF was one bit period short. The lead-in that compensated for the lost
head half bit was removed and nothing replaced it, so readers rejected
every answer with a second START_AUTH. Default is now 6.
- The edge-detect threshold was latched before being measured, so the value
chosen depended on whether the Proxmark was in a field when sim started.
It is now measured on field entry and re-armed when the reader leaves.
- The percentile walk latched on run-scoped variables, so one attempt made
outside a field poisoned every later one.
- Field loss was detected from TIMESTAMP, which is free-running MCU time and
never stalls. Detect it from receive silence instead.
- Frames of a length the protocol does not have no longer reach the state
machine; our own modulation tail was resetting the session and breaking
every write.
- A dropped edge merges two or three reader bit periods into one gap. Those
bits were discarded; they are now recovered by decomposition, which is what
made crypto mode work (AUTH decode 15% -> 100%).
- Threshold selection is limited to 20 and 32 and settles in under 25 ms.
Client:
- lf hitag info printed a hardcoded 0x06 and reported 'Password mode' for
every tag. It now reads page 3, takes -k (4 bytes password, 6 bytes
crypto), and says so when the config cannot be read.
- lf hitag restore: writes a dump back in dependency order - user pages,
then key material, then config last - validates the config byte, and
prints the credential the tag will require afterwards.
- lf hitag crack2 now reports why it failed instead of a bare 'fail'.
- trace list: bit count moved to its own column, relative mode shows a
Frame Delay Time row rather than renaming Start/End, --frame and -r
rejected together.
Adds support for more attribute types and safe
error handling for unknown types. Fixes a bug
where the version attribute didn't increment the
cursor correctly causing an infinite loop.
9303 Part 4, positions 29 to 42: "Any special characters, including spaces,
in the personal identification number ... shall be replaced by the filler
character (<). The number shall be followed by the filler character (<)
repeated up to position 42."
So the field holds two different kinds of filler. The trailing run is
padding, which was already stripped, but a filler inside the number stands
for a space or special character substituted on the way in, and those were
printed as they came - a US passport showed its personal number with a '<'
in the middle of it.
Strip the padding, then put a space back for what remains. It is the
closest recovery available: the original could have been a hyphen, and the
MRZ does not keep which.
optional_data itself stays a faithful copy of the field. Nothing else
reads it, and it is the personal number that is being rendered here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ICAO spills a document number longer than nine characters into the
optional-data field: positions 1 to 9 hold the first nine, position 10 a
filler in place of the check digit, and the optional field opens with the
rest of the number and the check digit for the whole of it.
_extended_document_number() already puts that back together and returns
the optional field with the overflow removed. _parse_td3() bound that
fourth value and then ignored it, re-slicing l2[28:42] raw, so the tail of
the document number stayed in the optional field. From there it was
reported as the holder's personal number, which falls back to the MRZ
optional-data field when DG11 carries none - a 12-character number showed
its last three digits and a check digit as a personal number.
Use the value the function already returns. The check digit still answers
for the field as transmitted, overflow included, since that is what the
MRZ actually carries.
_parse_td1() has always used the return value, which is where the intended
shape comes from. _parse_td2() does not call the helper at all, so TD2
carries no support for long numbers; that is a gap rather than this bug and
is left alone.
Found while checking the decoder against ICAO 9303 Part 4. Nothing else
came out of that: the composite check digit covers lower-line positions
1-10, 14-20 and 22-43 as the spec requires, verified by mutating all 43 and
confirming that nationality and sex are ignored and every other position is
caught; the 7-3-1 digits agree with an independent implementation across
four real TD3 documents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab headers carried their file names as fixed text - "Additional
personal details · EF_DG11" - so a Polish passport, which ships DG13 and
no DG11, was told its details came from a file it does not carry. The
ISSUER header claimed EF_DG12 on the same document, and SECURITY listed
EF_DG15 whether or not it was there.
personal_files, document_files and security_files report what each tab drew
from, and the pages compose their header from those. They sit on the
record rather than in the widgets so they can be tested without a display,
which is how the rest of the suite is built.
PERSONAL names EF_DG1 when the personal number came from the MRZ, that
being the file which supplied it, rather than crediting DG11 for a value
DG11 did not provide. With nothing read the header stays the bare
description: the record is an empty PassportRecord at startup rather than
None, and reporting an absence on a chip nobody read reads as a fault.
"none present" rather than "nothing on this chip", since the app opens
offline dumps too.
The same document showed the other half of this: "Personal number (from
DG13)" renders 203px against a dp(190) caption column, so it wrapped and
left the source on a line of its own. Without the "from" it is 167px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Personal number (from DG13)" renders 203px wide and the caption column is
dp(190), so it wrapped and left the source sitting on a line of its own.
Without the "from" it comes to 167px and fits, with room to spare over the
140px of the longest caption already in that tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kivy's stock disabled button texture is pale at the edges and dark in the
middle. Stretched across a wide button the edges stay put and the middle
grows, so it reads as three vertical bands with the label sitting on the
dark one and disappearing - "Delete dump" is unreadable at startup, where
it is disabled because no dump is loaded yet.
Keep the normal background for the disabled state and let the dimmed label
carry it instead. The text goes to 45% rather than Kivy's 30%, which is
faint on that grey.
Applied as a Button rule because nine buttons across the app bind
disabled: Export, Open folder, the camera button, Samples, the bin button
and the rest all band the same way. The one button that sets its own
transparent background stays transparent, since background_color
multiplies the texture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Polish passport carries EF_DG13 and no EF_DG11, so the PERSONAL tab had
nothing to show and the data page fell back to "not on chip", even though
the document does carry a personal number.
DG13 is optional details and ICAO assigns it no meaning, so parse_dg13()
reads it as tagged values and names none of them. The 5C tag list is
skipped: it enumerates what follows rather than being a field itself.
Poland is the one case worth naming. It puts the PESEL, its national
identity number, in tag 5F70, and is_pesel() checks the length and the
check digit - DG13 being issuer-defined, the checksum is what separates a
PESEL from eleven digits that happen to share a tag. That is applied only
when the MRZ nationality is POL, so the same tag on another state's
document is carried but not claimed as a personal number.
It slots in behind the two sources personal_number already had, which its
docstring anticipated: DG11, then the MRZ optional-data field, then this.
personal_number_source reports DG13 so the PERSONAL tab and the data page
can say where the value came from rather than implying DG11.
Confirmed against a Polish passport: the 11 digits validate as a PESEL,
their first six match the MRZ date of birth under PESEL's century-offset
month encoding, and the gender digit agrees with the MRZ sex field. The
two US documents to hand are unaffected - they keep taking the number from
the MRZ - and a document with none of the three shows no row, as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading a Polish passport produced an empty dump directory and the app
answered with "EF_DG1 is missing - no MRZ to render", which sent us after
one file when every file was absent. Chasing that turned up several
reasons a failed read did not explain itself, and one wrong label in the
SECURITY tab that the same document exposed.
Nothing was reported as a success. _read_finished treated any read the
classifier had not flagged as one: it set status to "success", loaded the
empty record and switched to the data page, leaving the DG1 line in the
log as the only clue. It now treats an empty record as a failure, and the
loader no longer names DG1 when nothing at all was read.
A failed read left no evidence, so pm3.log is kept beside the dump. The
key material is redacted out of it: result.command is the whole built
command line and the client echoes it back in its own output, so the MRZ,
the document number and the CAN all reach the log unless both are
scrubbed. The log does not count as a file when judging whether a read
produced anything - otherwise a failed read looks like it holds one and
the empty-dump prune, which uses rmdir, could never clear it again.
The classifier missed a lot. Fifteen of the client's failure messages
matched no rule and surfaced as the generic error; thirteen now classify,
each verified to end in a return false in the client. Two are deliberately
left alone with a comment saying why: "Secure select rejected" is answered
6A82 for an absent optional DG during a good read, and "PACE is not
available" precedes a normal BAC fallback.
It also contradicted itself twice. A superseded PACE attempt was reported
as the outcome, so a dump with every file in it announced itself as "PACE
authentication failed" - the client falls back to BAC, and
detect_mechanism already knew BAC had got in. And "Did you supply the
correct MRZ info?" is printed whenever external authentication fails,
whatever the cause, so a chip that had stopped answering was reported as a
bad MRZ; a missing APDU response now outranks it and says the key was
never tested.
EF_CardAccess was listed in the file table but never parsed, and DG14's
protocols were mislabelled: 0.4.0.127.0.7.2.2.1.2 was called "PACE (ECDH,
generic mapping)" when that arc is the Chip Authentication public key -
the client calls the same constant oid_pk_ecdh - and PACE lives under
0.4.0.127.0.7.2.2.4.x.y, as the comment above the client's own table says.
emrtd/securityinfos.py parses SecurityInfos for EF_CardAccess,
EF_CardSecurity and DG14, taking its names and domain parameters from the
client's pace_table and pacesdp_table so the two agree. Only the members
of the SET count: walking every nested SEQUENCE also collected the X9.62
identifiers inside the public key, which are not protocols the chip
supports. It uses the TLV reader already in the tree rather than
asn1crypto, an optional dependency the old OID scan silently needed.
Behaviour change: a read that writes no file reports failure and opens the
LOG tab instead of an empty data page; every dump gains a redacted
pm3.log, ignored when judging emptiness and removed with the directory;
failures that surfaced as one generic error now name themselves; DG14
lists four named protocols where it listed six raw OIDs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a file that holds a picture - EF_DG2, EF_DG7 - showed nothing at
all: no image, no hex dump, and no error either.
Both panes drew their text into a single Kivy Label, and a Label is one
OpenGL texture, capped at commonly 16384px. A monospace line here is 13px,
so past roughly 1260 lines the texture fails to be created and the pane
draws nothing. A DG2 portrait is 15-25kB, which is 940-1560 dump lines at
16 bytes a row and four times that in a narrow pane, where the dump falls
back to 4 bytes a row. The LOG pane holds 4000 lines, so it blanked as
well once a dump got talkative. The 64kB cap already in the hex view
guarded the wrong bound: it was sized for how long a Label takes to lay
out, not for what the GPU will hold.
Split both over several Labels. split_blocks() chunks at 512 rows, and the
log at 256 because its lines wrap and so cover more rows than they hold.
The log reuses its Labels and only assigns the ones whose text changed, so
streaming output still re-renders just the tail.
The FILES tab now also shows the picture above the dump for the files that
carry one, which is what you actually want to see for a DG2. The PNG is
already decoded at load time for the data page, so image_for() only hands
over what is there.
Behaviour change: a file carrying an image shows it above the hex dump,
with the dump still underneath. Nothing is truncated that was not before.
Testing: 270 passed, 2 skipped (was 265). Checked on screen against a
generated sample - EF_DG2 shows the portrait, EF_DG7 the signature, EF_SOD
the dump at full height, and the LOG tab renders a full 4000-line buffer
as 16 Labels, tallest texture 3328px against the 16384px maximum. black
clean. Python only, so no client or firmware build matrix applies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app died with SIGSEGV before drawing anything, on every entry point
including --help.
The window minimum size was pre-seeded into Kivy's Config, and
WindowBase.__init__ is the only place Kivy reads it - so it was applied from
inside WindowSDL.create_window(), while Window.initialized was still False.
If the window SDL has just made is smaller than that minimum, SDL resizes it
on the spot; the resize comes back through the SDL event filter into
EventLoop.idle(), which runs the clock and re-enters create_window(). Still
not initialised, that call runs setup_window() again and resizes again, until
the C stack gives out.
A scaled HiDPI session is enough to reach it: with sdl2-compat on SDL3 under
Wayland sizes come back in logical points, so the inherited 800x600 default
arrives as a 500x375 window, under the 760x520 minimum. Set the minimum on
Window once it exists instead, where the same resize takes Kivy's cheap
already-initialised path.
That exposed a second fault on the same path. _install_kivy_logging attached a
bare StreamHandler() to Kivy's own logger, and kivy.logger replaces sys.stderr
with a stream that feeds whatever is written to it back in as a warning -
kivy/logger.py cautions about exactly this. Any warning _KivyNoise did not
drop answered itself until the recursion limit stopped it, with stderr as the
broken part, so nothing legible came out. Not theoretical: this machine logs
"MTD: Unable to open device" at startup, which would have killed the app as
soon as the segfault was out of the way. Point the handler at the real stderr,
which Kivy leaves alone.
Behaviour change: a window that opens smaller than 760x520 is now grown to it,
rather than the constraint being imposed while the window is built.
Testing: 257 passed, 2 skipped (was 254/2); --help, a plain launch, --dump on
a generated sample and -v all start and stay up; black clean. Python-only
change, so no client or firmware build matrix applies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e2cf7dee5 removed sim014.bin but the top-level Makefile still lists it
in INSTALLSIMFW, so 'make install' fails on cp. Install sim020 instead,
and point the pm3_tests.sh existence check at the same file.