Files
proxmark3/tools/ePassport/tests/test_dg_parse.py
T
Paul KilarandClaude Opus 5 bd34414c6d ePassport: fix the FILES and LOG panes going blank
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>
2026-08-31 10:45:57 -04:00

219 lines
7.4 KiB
Python

"""Data-group decoding, driven by the checked-in synthetic dumps."""
from __future__ import annotations
from pathlib import Path
import pytest
from epassport.emrtd import dg
from epassport.emrtd.model import FileState
from epassport.emrtd.tlv import encode
@pytest.fixture(scope="session")
def record(td3: Path):
return dg.load_dump(td3)
# ------------------------------------------------------------- synthetic
def test_parse_com_tag_list() -> None:
com = dg.parse_com(
encode(
0x60,
encode(0x5F01, b"0107")
+ encode(0x5F36, b"040000")
+ encode(0x5C, b"\x61\x75\x6b"),
)
)
assert com.lds_version == "01.07"
assert com.unicode_version == "04.00.00"
assert com.present_dgs == [1, 2, 11]
def test_parse_dg1_splits_a_td3_blob_into_two_lines() -> None:
blob = "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C36UTO7408122F1204159ZE184226B<<<<<10"
m = dg.parse_dg1(encode(0x61, encode(0x5F1F, blob.encode())))
assert m is not None and m.kind == "TD3"
assert m.surname == "ERIKSSON"
def test_parse_dg1_returns_none_on_garbage() -> None:
assert dg.parse_dg1(encode(0x61, encode(0x5F1F, b"NOT AN MRZ"))) is None
assert dg.parse_dg1(b"") is None
def test_dg11_separator_expansion() -> None:
p = dg.parse_dg11(
encode(
0x6B,
encode(0x5F0E, b"ERIKSSON<<ANNA<MARIA")
+ encode(0x5F11, b"STOCKHOLM<SWEDEN"),
)
)
assert p.full_name == "ERIKSSON, ANNA MARIA"
assert p.place_of_birth == "STOCKHOLM SWEDEN"
def test_dg12_dates_stay_raw_for_the_model_to_format() -> None:
d = dg.parse_dg12(
encode(
0x6C,
encode(0x5F19, b"UTOPIA PASSPORT AGENCY") + encode(0x5F26, b"20200401"),
)
)
assert d.issuing_authority == "UTOPIA PASSPORT AGENCY"
assert d.date_of_issue == "20200401"
def test_extract_image_finds_jpeg_after_a_cbeff_header() -> None:
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (8, 8), (1, 2, 3)).save(buf, "JPEG")
png = dg.extract_image(b"\x7f\x61\x0a" + b"\x00" * 40 + buf.getvalue())
assert png.startswith(b"\x89PNG")
def test_extract_image_on_empty_and_imageless_input() -> None:
assert dg.extract_image(b"") == b""
assert dg.extract_image(b"\x00" * 64) == b""
# ------------------------------------------------------------ real dumps
def test_full_record_from_sample_dump(record) -> None:
assert record.mrz is not None
assert record.surname == "ERIKSSON"
assert record.given_names == "ANNA MARIA"
assert record.display_name == "ANNA MARIA ERIKSSON"
assert record.mrz.all_checks_ok
assert record.warnings == []
def test_portrait_and_signature_decode(record) -> None:
assert record.portrait_png.startswith(b"\x89PNG")
assert record.signature_png.startswith(b"\x89PNG")
def test_dg11_and_dg12_populate_the_page(record) -> None:
assert record.place_of_birth == "STOCKHOLM SWEDEN"
assert record.issuing_authority == "UTOPIA PASSPORT AGENCY"
assert record.date_of_issue == "01 APR 2020"
assert record.full_date_of_birth == "12 AUG 1974"
def test_dg14_and_dg15_summarised(record) -> None:
assert any("Active Authentication" in p for p in record.security.protocols)
assert record.security.aa_algorithm.startswith("RSA")
assert record.security.aa_key_size == "1024 bit"
def test_sod_hashes_all_match(record) -> None:
assert record.sod.available, record.sod.message
assert record.sod.hash_algorithm == "SHA-256"
assert "Utopia Document Signer 01" in record.sod.signer_subject
assert record.sod.hashes
assert all(h.status == "match" for h in record.sod.hashes)
assert record.sod.mismatches == []
def test_tampered_dg2_is_flagged(td3_tampered: Path) -> None:
tampered = dg.load_dump(td3_tampered)
bad = tampered.sod.mismatches
assert [h.dg for h in bad] == [2]
# everything else still renders
assert tampered.surname == "ERIKSSON"
assert tampered.portrait_png.startswith(b"\x89PNG")
def test_eac_groups_are_protected_not_errors(record) -> None:
assert record.file("EF_DG3").state == FileState.PROTECTED
assert record.file("EF_DG4").state == FileState.PROTECTED
assert record.file("EF_DG1").state == FileState.PRESENT
assert record.file("EF_DG5").state == FileState.ABSENT
assert record.has_dg(2) and not record.has_dg(5)
def test_td1_dump_without_sod(td1: Path) -> None:
r = dg.load_dump(td1)
assert r.mrz.kind == "TD1"
assert r.portrait_png.startswith(b"\x89PNG")
assert not r.sod.available
assert "not dumped" in r.sod.message
# EF_COM announces DG3 which was not dumped: reported as protected.
assert r.file("EF_DG3").state == FileState.PROTECTED
def test_empty_directory_yields_a_warning(tmp_path: Path) -> None:
r = dg.load_dump(tmp_path)
assert r.mrz is None
assert any("EF_DG1 is missing" in w for w in r.warnings)
def test_uppercase_bin_suffix_is_accepted(tmp_path: Path, td3: Path) -> None:
(tmp_path / "EF_DG1.BIN").write_bytes((td3 / "EF_DG1.bin").read_bytes())
r = dg.load_dump(tmp_path)
assert r.mrz is not None and r.surname == "ERIKSSON"
# --------------------------------------------- optional data groups absent
def test_missing_dg11_dg12_are_reported_as_not_on_chip(
tmp_path: Path, td3: Path
) -> None:
"""Plenty of real passports carry no DG11/DG12 at all.
The page must say so rather than showing an empty field, which reads as
"the app failed to parse it".
"""
for name in ("EF_COM", "EF_DG1", "EF_DG2"):
(tmp_path / f"{name}.bin").write_bytes((td3 / f"{name}.bin").read_bytes())
r = dg.load_dump(tmp_path)
assert not r.has_dg(11) and not r.has_dg(12)
assert r.place_of_birth == r.NOT_ON_CHIP
assert r.date_of_issue == r.NOT_ON_CHIP
assert r.issuing_authority == r.NOT_ON_CHIP
assert r.is_missing(r.place_of_birth)
def test_personal_number_falls_back_to_the_mrz_optional_field(
tmp_path: Path, td3: Path
) -> None:
"""Sweden and others put the national number in MRZ optional data."""
for name in ("EF_COM", "EF_DG1", "EF_DG2"):
(tmp_path / f"{name}.bin").write_bytes((td3 / f"{name}.bin").read_bytes())
r = dg.load_dump(tmp_path)
assert r.personal_number == "ZE184226B" # the specimen's optional data
assert r.personal_number_source == "MRZ"
assert not r.is_missing(r.personal_number)
def test_dg11_personal_number_wins_over_the_mrz(record) -> None:
assert record.personal_number == "19740812-1234"
assert record.personal_number_source == "DG11"
def test_present_optional_groups_are_not_marked_missing(record) -> None:
for value in (
record.place_of_birth,
record.date_of_issue,
record.issuing_authority,
):
assert not record.is_missing(value)
def test_image_for_returns_the_decoded_picture(record) -> None:
"""The FILES tab shows a picture for the files that carry one."""
assert record.image_for("EF_DG2").startswith(b"\x89PNG")
assert record.image_for("EF_DG7").startswith(b"\x89PNG")
def test_image_for_is_case_insensitive_like_file_lookup(record) -> None:
assert record.image_for("ef_dg2") == record.image_for("EF_DG2")
def test_image_for_is_empty_for_files_that_carry_no_picture(record) -> None:
assert record.image_for("EF_COM") == b""
assert record.image_for("EF_SOD") == b""
assert record.image_for("nonsense") == b""