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 <noreply@anthropic.com>
This commit is contained in:
Paul Kilar
2026-09-01 13:51:44 -04:00
co-authored by Claude Opus 5
parent 0d8b99b2a1
commit 30b57591c7
6 changed files with 199 additions and 2 deletions
+1
View File
@@ -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)
+24
View File
@@ -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""),
+45
View File
@@ -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:
+1 -1
View File
@@ -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
+18 -1
View File
@@ -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 "",
+110
View File
@@ -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"