mirror of
https://github.com/RfidResearchGroup/proxmark3.git
synced 2026-10-07 18:28:20 +00:00
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>
111 lines
3.7 KiB
Python
111 lines
3.7 KiB
Python
"""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"
|