Skip to content

Fix ePassport reporting of failed reads, and decode EF_CardAccess - #3565

Open
pkilar wants to merge 1 commit into
RfidResearchGroup:masterfrom
pkilar:epassport-diagnose-failed-reads
Open

Fix ePassport reporting of failed reads, and decode EF_CardAccess#3565
pkilar wants to merge 1 commit into
RfidResearchGroup:masterfrom
pkilar:epassport-diagnose-failed-reads

Conversation

@pkilar

@pkilar pkilar commented Aug 31, 2026

Copy link
Copy Markdown

Problem

Reading a Polish passport produced an empty dump directory, and the app answered with EF_DG1 is missing - no MRZ to render. Every file was absent; DG1 was just the first one checked, so the message sent us after the wrong thing. Digging into that turned up several separate reasons a failed read did not explain itself, and one wrong label in the SECURITY tab.

A read that dumped nothing was reported as a success. _read_finished treated any read the classifier had not flagged as one — it set the status to success, loaded the empty record and switched to the data page, leaving the DG1 line in the log as the only clue.

A failed read left no evidence. The client output was not kept anywhere, so once the run was over there was nothing to say why the chip gave up nothing.

The classifier missed most of what the client reports. Auditing every failure message in cmdhfemrtd.c and emrtd/*.c against the rules found 15 unmatched, all of which surfaced as one generic error.

It also contradicted itself twice. The client tries PACE and falls back to BAC, and every line was matched on its own — so a dump with every file in it announced itself as PACE authentication failed, even though 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 (APDU: no APDU response) was reported as a bad MRZ. That one cost real time on a live document.

EF_CardAccess was never decoded, and DG14's protocols were mislabelled. The tab called 0.4.0.127.0.7.2.2.1.2 "PACE (ECDH, generic mapping)". That arc is the Chip Authentication public key — the client calls the same constant oid_pk_ecdh in emrtd_pace.c:1158 — and PACE lives under ...2.2.4.x.y, as the comment above the client's own table says at emrtd_pace.c:40. ...2.2.3.x was likewise labelled Terminal Authentication when it is Chip Authentication. So a PACE passport had its CA key announced as PACE while its actual PACE protocol went unlabelled. The generated td3 sample shows the same thing.

Change

One commit: dg.py and model.py each carry both halves of this, so splitting it would have meant cutting hunks rather than files.

The decoding half adds emrtd/securityinfos.py, parsing SecurityInfos for EF_CardAccess, EF_CardSecurity and DG14. Protocol names and standardised domain parameters are transcribed from the client's own pace_table and pacesdp_table, so the two agree on what a chip advertises. A Polish passport now reads:

EF_CardAccess: PACE ECDH, Generic Mapping, 3DES-CBC-CBC · NIST P-256 (secp256r1)
DG14:          Chip Authentication public key (ECDH) · 256-bit key
               Chip Authentication ECDH, 3DES-CBC-CBC
               Terminal Authentication
               PACE ECDH, Generic Mapping, 3DES-CBC-CBC · NIST P-256 (secp256r1)

Only the members of the SET count as SecurityInfos — walking every nested SEQUENCE also collected the X9.62 identifiers inside the public key, which are not protocols the chip supports. It parses with the TLV reader already in the tree rather than asn1crypto, which is an optional dependency: the old OID scan returned nothing without it, so these files now decode on a bare install.

The reporting half covers the rest. An empty record is treated as a failure; the loader no longer names DG1 when nothing at all was read; pm3.log is kept beside the dump; 13 of the 15 unmatched messages now classify; the superseded-attempt and MRZ-ranking contradictions are fixed.

Two messages are deliberately left unclassified, with a comment in RULES saying why: Secure select rejected by the document is answered 6A82 for an absent optional DG during a perfectly good read, and Couldn't parse EF_CardAccess, PACE is not available precedes a normal BAC fallback. A rule on either would fail working dumps, and there is a test pinning that.

The log needed two things beyond writing it. The key material is redacted: result.command is the whole built command line and the client echoes it back in its own output, so the MRZ, document number and CAN reach the log unless both are scrubbed. And it does not count as a file when deciding whether a read produced anything — otherwise a failed read would look like it held one file, and the empty-dump prune (which uses rmdir) would never clear it again. Pruning now removes the log with the directory.

Behaviour change

  • A read that writes no file reports a failure and opens the LOG tab, instead of claiming success and showing an empty data page.
  • Every dump directory gains a pm3.log, with the MRZ, document number and CAN redacted. It is ignored when judging whether a dump is empty, and removed along with the directory when clearing failed reads.
  • Failures that surfaced as one generic error now name themselves, and a PACE attempt that BAC recovered from is no longer reported as the outcome.
  • DG14 lists four named protocols where it listed six raw OIDs.

Testing

  • python3 -m pytest tests/ — 312 passed, 2 skipped (was 302 passed, 2 skipped).
  • Checked against three real dumps — two US (BAC), one Polish (PACE) — plus the generated samples. The Polish document is what exposed the OID labels and the empty-read path.
  • Every new classifier rule was verified against the client source to end in a return false, and the two excluded ones were verified to occur during successful reads.
  • Redaction verified against a real captured log: the MRZ is gone, the dump path and all diagnostics survive.
  • black --check clean.

Not tested here: Windows/ProxSpace and macOS. Low risk — Python only, no platform-specific calls; the one filesystem addition is a write_text guarded by OSError. Not tested: hardware beyond the reads above, which were taken on an RDV4.

…ccess

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>
@pkilar
pkilar force-pushed the epassport-diagnose-failed-reads branch from 2899d8d to 92a98b1 Compare August 31, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant