ePassport: stop a long document number leaking into optional data

ICAO spills a document number longer than nine characters into the
optional-data field: positions 1 to 9 hold the first nine, position 10 a
filler in place of the check digit, and the optional field opens with the
rest of the number and the check digit for the whole of it.

_extended_document_number() already puts that back together and returns
the optional field with the overflow removed.  _parse_td3() bound that
fourth value and then ignored it, re-slicing l2[28:42] raw, so the tail of
the document number stayed in the optional field.  From there it was
reported as the holder's personal number, which falls back to the MRZ
optional-data field when DG11 carries none - a 12-character number showed
its last three digits and a check digit as a personal number.

Use the value the function already returns.  The check digit still answers
for the field as transmitted, overflow included, since that is what the
MRZ actually carries.

_parse_td1() has always used the return value, which is where the intended
shape comes from.  _parse_td2() does not call the helper at all, so TD2
carries no support for long numbers; that is a gap rather than this bug and
is left alone.

Found while checking the decoder against ICAO 9303 Part 4.  Nothing else
came out of that: the composite check digit covers lower-line positions
1-10, 14-20 and 22-43 as the spec requires, verified by mutating all 43 and
confirming that nationality and sex are ignored and every other position is
caught; the 7-3-1 digits agree with an independent implementation across
four real TD3 documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Kilar
2026-09-01 14:35:26 -04:00
co-authored by Claude Opus 5
parent 450e3dfaaa
commit aadcda0cb8
3 changed files with 70 additions and 1 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]
- Fixed `tools/ePassport` - a document number longer than nine characters left its overflow in the MRZ optional-data field, where it was reported as the holder's personal number (@pkilar)
- 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 `tools/ePassport` - a disabled button drew Kivy's banded disabled texture, which left its label unreadable (@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)
+5 -1
View File
@@ -483,8 +483,12 @@ def _parse_td3(lines: list[str], today: _dt.date | None) -> Mrz:
date_of_birth=Checked(l2[13:19], l2[19], verify(l2[13:19], l2[19])),
sex=l2[20],
date_of_expiry=Checked(l2[21:27], l2[27], verify(l2[21:27], l2[27])),
# The value comes from what _extended_document_number left behind: a
# long document number spills into this field, and that spill belongs
# to the number, not to the State's optional data. The check digit
# still answers for the field as transmitted, overflow included.
optional_data=Checked(
l2[28:42].rstrip(FILLER), l2[42], verify(l2[28:42], l2[42])
optional.rstrip(FILLER), l2[42], verify(l2[28:42], l2[42])
),
composite=Checked(
"",
+64
View File
@@ -193,3 +193,67 @@ def test_split_names_handles_single_and_missing_given_names() -> None:
assert mrz.split_names("ERIKSSON<<ANNA<MARIA<<<<") == ("ERIKSSON", "ANNA MARIA")
assert mrz.split_names("ERIKSSON<<<<<<") == ("ERIKSSON", "")
assert mrz.split_names("VAN<DER<BERG<<JAN<<<") == ("VAN DER BERG", "JAN")
# --------------------------------- document numbers longer than nine characters
def _td3_with_long_number(number: str = "AB1234567890") -> list[str]:
"""ICAO puts the overflow in the optional-data field.
Positions 1-9 hold the first nine characters, position 10 a filler in
place of the check digit, and the optional-data field opens with the rest
of the number followed by the check digit for the whole of it.
"""
head, rest = number[:9], number[9:]
dob, expiry = "740812", "120415"
optional = (rest + mrz.check_digit(number)).ljust(14, "<")
body = (
head
+ "<"
+ "UTO"
+ dob
+ mrz.check_digit(dob)
+ "F"
+ expiry
+ mrz.check_digit(expiry)
+ optional
+ mrz.check_digit(optional)
)
line2 = body + mrz.check_digit(body[0:10] + body[13:20] + body[21:43])
return ["P<UTOERIKSSON<<ANNA<MARIA".ljust(44, "<"), line2]
def test_a_long_document_number_is_put_back_together() -> None:
parsed = mrz.parse(_td3_with_long_number())
assert parsed.document_number.value == "AB1234567890"
assert parsed.document_number.ok is True
def test_the_overflow_does_not_linger_in_the_optional_field() -> None:
"""It is part of the document number, not data the State chose to add.
Left there it was reported as the holder's personal number, since that
falls back to the MRZ optional-data field when DG11 carries none.
"""
parsed = mrz.parse(_td3_with_long_number())
assert parsed.optional_data.value == ""
def test_a_nine_character_number_leaves_the_optional_field_alone() -> None:
dob, expiry = "740812", "120415"
optional = "ZE184226B".ljust(14, "<")
body = (
"L898902C"
+ "<"
+ mrz.check_digit("L898902C<")
+ "UTO"
+ dob
+ mrz.check_digit(dob)
+ "F"
+ expiry
+ mrz.check_digit(expiry)
+ optional
+ mrz.check_digit(optional)
)
line2 = body + mrz.check_digit(body[0:10] + body[13:20] + body[21:43])
parsed = mrz.parse(["P<UTOERIKSSON<<ANNA".ljust(44, "<"), line2])
assert parsed.optional_data.value == "ZE184226B"