Files
proxmark3/tools/ePassport/tests/test_securityinfos.py
T
Paul KilarandClaude Opus 5 92a98b1e14 ePassport: say what went wrong when a read fails, and decode EF_CardAccess
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>
2026-08-31 16:31:53 -04:00

105 lines
3.6 KiB
Python

"""SecurityInfos - the protocol list in EF_DG14, EF_CardAccess, EF_CardSecurity.
The OID arcs are the ones the client uses in client/src/emrtd/emrtd_pace.c:
PACE is 0.4.0.127.0.7.2.2.4.x.y, and 0.4.0.127.0.7.2.2.1.2 is the Chip
Authentication public key the client calls oid_pk_ecdh. The viewer used to
label the second one as PACE, so a PACE passport had its CA key announced as
PACE while its actual PACE protocol went unlabelled.
"""
from __future__ import annotations
from epassport.emrtd import securityinfos as si
#: A real EF_CardAccess: PACE ECDH generic mapping, 3DES, NIST P-256.
CARD_ACCESS = bytes.fromhex("31143012060A04007F0007020204020102010202010C")
def test_the_public_key_oid_is_not_pace() -> None:
name = si.describe("0.4.0.127.0.7.2.2.1.2")
assert "PACE" not in name
assert "Chip Authentication" in name
def test_the_chip_authentication_oid_is_not_terminal_authentication() -> None:
name = si.describe("0.4.0.127.0.7.2.2.3.2.1")
assert "Terminal Authentication" not in name
assert "Chip Authentication" in name
def test_pace_is_the_arc_the_client_uses() -> None:
assert "PACE" in si.describe("0.4.0.127.0.7.2.2.4.2.1")
assert "ECDH" in si.describe("0.4.0.127.0.7.2.2.4.2.1")
def test_terminal_authentication_has_its_own_arc() -> None:
assert "Terminal Authentication" in si.describe("0.4.0.127.0.7.2.2.2")
def test_an_unknown_oid_comes_back_as_itself() -> None:
assert si.describe("1.2.840.10045.2.1") == "1.2.840.10045.2.1"
def test_card_access_decodes_to_protocol_version_and_curve() -> None:
(entry,) = si.parse(CARD_ACCESS)
assert entry.oid == "0.4.0.127.0.7.2.2.4.2.1"
assert "PACE" in entry.name
assert entry.version == 2
assert entry.parameter_id == 12
assert entry.curve == "NIST P-256 (secp256r1)"
def test_the_display_line_names_the_protocol_and_the_curve() -> None:
(entry,) = si.parse(CARD_ACCESS)
assert "PACE" in entry.text
assert "NIST P-256 (secp256r1)" in entry.text
def test_parsing_rubbish_yields_nothing_rather_than_raising() -> None:
assert si.parse(b"") == []
assert si.parse(b"\xff\xff\xff") == []
def test_domain_parameters_match_the_client_table() -> None:
assert si.DOMAIN_PARAMETERS[12] == "NIST P-256 (secp256r1)"
assert si.DOMAIN_PARAMETERS[13] == "BrainpoolP256r1"
assert 3 not in si.DOMAIN_PARAMETERS # 3..7 are not assigned
def test_the_icao_active_authentication_oid_is_named() -> None:
assert si.describe("2.23.136.1.1.5") == "Active Authentication"
def _der(tag: int, value: bytes) -> bytes:
from epassport.emrtd import tlv
return tlv.encode(tag, value)
def _public_key_info() -> bytes:
"""A ChipAuthenticationPublicKeyInfo carrying a P-256 point."""
algorithm = _der(
0x30,
_der(0x06, bytes.fromhex("2A8648CE3D0201"))
+ _der(0x06, bytes.fromhex("2A8648CE3D0101")),
)
key = _der(0x30, algorithm + _der(0x03, b"\x00\x04" + bytes(64)))
info = _der(0x30, _der(0x06, bytes.fromhex("04007F000702020102")) + key)
return _der(0x31, info)
def test_nested_algorithm_oids_are_not_listed_as_protocols() -> None:
"""SecurityInfos is a SET OF SecurityInfo - only its members count.
Walking every nested SEQUENCE also picked up the X9.62 identifiers inside
the public key, which are not protocols the chip supports.
"""
entries = si.parse(_public_key_info())
assert [e.oid for e in entries] == ["0.4.0.127.0.7.2.2.1.2"]
def test_a_public_key_entry_reports_its_size() -> None:
(entry,) = si.parse(_public_key_info())
assert entry.key_bits == 256
assert "256" in entry.text