mirror of
https://github.com/RfidResearchGroup/proxmark3.git
synced 2026-09-16 14:52:59 +00:00
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:
co-authored by
Claude Opus 5
parent
0d8b99b2a1
commit
30b57591c7
@@ -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)
|
||||
|
||||
@@ -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""),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user