mirror of
https://github.com/RfidResearchGroup/proxmark3.git
synced 2026-10-10 04:57:35 +00:00
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>
219 lines
7.4 KiB
Python
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""
|