From 30b57591c7c84daa466f8b08f104dd20c8d5d5b6 Mon Sep 17 00:00:00 2001 From: Paul Kilar Date: Tue, 1 Sep 2026 13:51:44 -0400 Subject: [PATCH] ePassport: decode EF_DG13, and read Poland's PESEL from it 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 --- CHANGELOG.md | 1 + tools/ePassport/epassport/emrtd/dg.py | 24 +++++ tools/ePassport/epassport/emrtd/model.py | 45 +++++++++ tools/ePassport/epassport/ui/passport.kv | 2 +- tools/ePassport/epassport/ui/tabs/pages.py | 19 +++- tools/ePassport/tests/test_dg13.py | 110 +++++++++++++++++++++ 6 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tools/ePassport/tests/test_dg13.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f04e7cc3..cac7d43db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. This project uses the changelog in accordance with [keepchangelog](http://keepachangelog.com/). Please use this to write notable changes, which is not the same as git commit log... ## [unreleased][unreleased] +- Added `tools/ePassport` - EF_DG13 is decoded, and a Polish document's PESEL is shown as the personal number when it carries no EF_DG11 (@pkilar) - Fixed `hf 14b` - a card asking for a waiting time extension of 4 or more left the timeout at zero, so any ISO14443-B card needing time to answer looked like it had stopped responding (@pkilar) - Fixed `hf mfdes chk` - now handles `-k` user supplied keys properly (@iceman1001) - Fixed `BigBuf_Clear_keep_EM()` - now also clear the trace length set (@iceman1001) diff --git a/tools/ePassport/epassport/emrtd/dg.py b/tools/ePassport/epassport/emrtd/dg.py index 7cf2112e9..cd81c577f 100644 --- a/tools/ePassport/epassport/emrtd/dg.py +++ b/tools/ePassport/epassport/emrtd/dg.py @@ -15,9 +15,11 @@ from .model import ( DocumentDetails, DumpFile, FileState, + OptionalDetails, PassportRecord, PersonalDetails, SecurityInfo, + is_pesel, # noqa: F401 - re-exported, DG13 is where it is used ) from .mrz import MrzError, parse as parse_mrz @@ -203,6 +205,26 @@ def _clean_field(raw: bytes, *, numeric: bool = False) -> str: return text.replace("<<", ", ").replace("<", " ").strip().rstrip(",").strip() +# --------------------------------------------------------------- EF_DG13 +#: A tag list, enumerating what follows. Metadata, not a field of its own. +_DG13_TAG_LIST = 0x5C + + +def parse_dg13(data: bytes) -> OptionalDetails: + """Read DG13 as tagged values. ICAO assigns it no meaning, so neither do we.""" + out = OptionalDetails() + try: + nodes = tlv.parse(data) + except Exception: + return out + for node in nodes: + for child in node.children: + if child.constructed or child.tag == _DG13_TAG_LIST: + continue + out.fields.append((child.tag_hex, tlv.text(child.value))) + return out + + # ----------------------------------------------------------- EF_DG14/15 def parse_security(dg14: bytes, dg15: bytes, card_access: bytes = b"") -> SecurityInfo: out = SecurityInfo() @@ -303,6 +325,8 @@ def load_dump(directory: Path) -> PassportRecord: record.personal = parse_dg11(raw["EF_DG11"]) if "EF_DG12" in raw: record.document = parse_dg12(raw["EF_DG12"]) + if "EF_DG13" in raw: + record.optional = parse_dg13(raw["EF_DG13"]) record.security = parse_security( raw.get("EF_DG14", b""), raw.get("EF_DG15", b""), diff --git a/tools/ePassport/epassport/emrtd/model.py b/tools/ePassport/epassport/emrtd/model.py index 519166d47..e98973ae2 100644 --- a/tools/ePassport/epassport/emrtd/model.py +++ b/tools/ePassport/epassport/emrtd/model.py @@ -81,6 +81,32 @@ class DocumentDetails: return not any(v for k, v in vars(self).items() if not k.startswith("image_")) +#: Poland carries the PESEL, its national identity number, in this DG13 tag. +PESEL_TAG = "5F70" + +_PESEL_WEIGHTS = (1, 3, 7, 9, 1, 3, 7, 9, 1, 3) + + +def is_pesel(value: str) -> bool: + """11 digits whose PESEL check digit holds. + + DG13 is issuer-defined, so the checksum is what separates a PESEL from + eleven digits that happen to sit under the same tag. + """ + if len(value) != 11 or not value.isdigit(): + return False + total = sum(int(digit) * weight for digit, weight in zip(value, _PESEL_WEIGHTS)) + return (10 - total % 10) % 10 == int(value[10]) + + +@dataclass +class OptionalDetails: + """EF_DG13 - optional details. ICAO leaves the content to the issuer.""" + + #: ``(tag, text)`` in the order they appear, with no meaning attached. + fields: list[tuple[str, str]] = field(default_factory=list) + + @dataclass class SecurityInfo: """EF_DG14 / EF_DG15 - chip security options and AA public key.""" @@ -144,6 +170,7 @@ class PassportRecord: com: ComInfo = field(default_factory=ComInfo) personal: PersonalDetails = field(default_factory=PersonalDetails) document: DocumentDetails = field(default_factory=DocumentDetails) + optional: OptionalDetails = field(default_factory=OptionalDetails) security: SecurityInfo = field(default_factory=SecurityInfo) sod: SodInfo = field(default_factory=SodInfo) files: list[DumpFile] = field(default_factory=list) @@ -208,8 +235,24 @@ class PassportRecord: optional = self.mrz.optional_data.value.strip() if optional: return optional + if self.dg13_personal_number: + return self.dg13_personal_number return self._optional("", 11) + @property + def dg13_personal_number(self) -> str: + """The PESEL, for the Polish documents that carry it and no DG11. + + Only for POL: DG13 means whatever the issuing state decided, so the + same tag elsewhere is not a personal number and is left unnamed. + """ + if self.mrz is None or self.mrz.nationality.strip() != "POL": + return "" + for tag, value in self.optional.fields: + if tag == PESEL_TAG and is_pesel(value): + return value + return "" + @property def personal_number_source(self) -> str: """Where :attr:`personal_number` came from, for an honest caption.""" @@ -217,6 +260,8 @@ class PassportRecord: return "DG11" if self.mrz is not None and self.mrz.optional_data.value.strip(): return "MRZ" + if self.dg13_personal_number: + return "DG13" return "" def is_missing(self, value: str) -> bool: diff --git a/tools/ePassport/epassport/ui/passport.kv b/tools/ePassport/epassport/ui/passport.kv index 58c3ea4e7..2ed186861 100644 --- a/tools/ePassport/epassport/ui/passport.kv +++ b/tools/ePassport/epassport/ui/passport.kv @@ -169,7 +169,7 @@ label_font_size: root.height * 0.0185 DataField: size_hint_x: 0.44 - label_text: ("PERSONAL No. (FROM MRZ)" if (root.record and root.record.personal_number_source == "MRZ") else "PERSONAL No. / No. PERSONNEL") + label_text: ("PERSONAL No. (FROM " + root.record.personal_number_source + ")") if (root.record and root.record.personal_number_source in ("MRZ", "DG13")) else "PERSONAL No. / No. PERSONNEL" value_text: (root.record.personal_number or "-") if root.record else "" dim: not (root.record and root.record.personal_number) or root.record.is_missing(root.record.personal_number) value_font_size: root.height * 0.031 diff --git a/tools/ePassport/epassport/ui/tabs/pages.py b/tools/ePassport/epassport/ui/tabs/pages.py index 145090a41..a11f90f7c 100644 --- a/tools/ePassport/epassport/ui/tabs/pages.py +++ b/tools/ePassport/epassport/ui/tabs/pages.py @@ -50,6 +50,14 @@ class DetailPage(TabPage): box.add_widget(KeyValueRow(key=key, value=value, palette=self.palette)) +def _personal_number_label(record) -> str: + """Name the source when it is not DG11, so the row is not misread.""" + source = record.personal_number_source + if source in ("MRZ", "DG13"): + return f"Personal number (from {source})" + return "Personal number" + + class PersonalPage(DetailPage): """EF_DG11 - additional personal details.""" @@ -63,7 +71,16 @@ class PersonalPage(DetailPage): [ ("Full name", p.full_name), ("Other names", ", ".join(p.other_names)), - ("Personal number", p.personal_number), + ( + _personal_number_label(record), + # The rest of this tab drops absent fields rather than + # labelling them, and the placeholder would stand out. + ( + "" + if record.is_missing(record.personal_number) + else record.personal_number + ), + ), ( "Full date of birth", record.full_date_of_birth if p.full_date_of_birth else "", diff --git a/tools/ePassport/tests/test_dg13.py b/tools/ePassport/tests/test_dg13.py new file mode 100644 index 000000000..d7da9c83a --- /dev/null +++ b/tools/ePassport/tests/test_dg13.py @@ -0,0 +1,110 @@ +"""EF_DG13 - optional details, whatever the issuing state chose to put there. + +ICAO assigns DG13 no meaning, so it is read as tagged values and nothing is +inferred from a tag on its own. Poland is the exception worth naming: it +carries the PESEL, its national identity number, in tag 5F70, and a Polish +passport that ships no DG11 has no other place to put it. +""" + +from __future__ import annotations + +from epassport.emrtd import dg, tlv + + +def _dg13(*elements: bytes) -> bytes: + return tlv.encode(0x6D, b"".join(elements)) + + +def _pesel(digits: str) -> bytes: + """A DG13 shaped like Poland's: a 5C tag list then the number.""" + return _dg13(tlv.encode(0x5C, b"\x5f\x70"), tlv.encode(0x5F70, digits.encode())) + + +#: Checksum-valid, born 2010-03-09, male. Not anybody's: built for the test. +VALID = "10030912345" + + +def test_the_tag_list_is_not_content() -> None: + """5C enumerates the tags that follow; showing it as a field is noise.""" + details = dg.parse_dg13(_pesel(VALID)) + assert [tag for tag, _ in details.fields] == ["5F70"] + + +def test_fields_come_back_as_tag_and_text() -> None: + details = dg.parse_dg13(_pesel(VALID)) + assert details.fields == [("5F70", VALID)] + + +def test_an_unknown_tag_is_carried_without_being_named() -> None: + details = dg.parse_dg13(_dg13(tlv.encode(0x5F71, b"whatever"))) + assert details.fields == [("5F71", "whatever")] + + +def test_rubbish_yields_no_fields_rather_than_raising() -> None: + assert dg.parse_dg13(b"").fields == [] + assert dg.parse_dg13(b"\xff\xff\xff").fields == [] + + +# ------------------------------------------------------ the PESEL in tag 5F70 +def test_a_valid_pesel_is_recognised() -> None: + assert dg.is_pesel(VALID) + + +def test_the_checksum_has_to_hold() -> None: + wrong = VALID[:10] + str((int(VALID[10]) + 1) % 10) + assert not dg.is_pesel(wrong) + + +def test_length_and_digits_are_required() -> None: + assert not dg.is_pesel("1003091234") + assert not dg.is_pesel("1003091234A") + assert not dg.is_pesel("") + + +# ------------------------------------------- what the PERSONAL tab ends up with +from epassport.emrtd.model import PassportRecord # noqa: E402 +from epassport.emrtd.mrz import Checked, Mrz # noqa: E402 + + +def _record(nationality: str, dg13: bytes, optional_data: str = "") -> PassportRecord: + """Use the real Mrz dataclass: a hand-rolled stub can disagree with it.""" + record = PassportRecord() + blank = Checked("") + record.mrz = Mrz( + kind="TD3", + lines=[], + document_number=blank, + date_of_birth=blank, + date_of_expiry=blank, + composite=blank, + nationality=nationality, + optional_data=Checked(optional_data), + ) + record.optional = dg.parse_dg13(dg13) + return record + + +def test_a_polish_document_with_no_dg11_still_has_a_personal_number() -> None: + """DG11 absent and the MRZ optional field empty - DG13 is all there is.""" + record = _record("POL", _pesel(VALID)) + assert record.personal_number == VALID + assert record.personal_number_source == "DG13" + + +def test_the_same_tag_elsewhere_is_not_claimed_as_a_personal_number() -> None: + """DG13 means whatever the issuer decided, so 5F70 is Polish only.""" + record = _record("DEU", _pesel(VALID)) + assert record.personal_number != VALID + assert record.personal_number_source != "DG13" + + +def test_a_number_that_fails_the_checksum_is_not_used() -> None: + wrong = VALID[:10] + str((int(VALID[10]) + 1) % 10) + assert _record("POL", _pesel(wrong)).personal_number != wrong + + +def test_the_mrz_optional_field_still_wins_over_dg13() -> None: + """Sweden and others put it there; that is the more direct source.""" + record = _record("POL", _pesel(VALID), optional_data="654321") + assert record.personal_number == "654321" + assert record.personal_number_source == "MRZ"