From aadcda0cb81b6aa30d1acb0a3e30fcaf59d69fa2 Mon Sep 17 00:00:00 2001 From: Paul Kilar Date: Tue, 1 Sep 2026 14:35:26 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + tools/ePassport/epassport/emrtd/mrz.py | 6 ++- tools/ePassport/tests/test_mrz.py | 64 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dde66bee..26edf36b1 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] +- 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) diff --git a/tools/ePassport/epassport/emrtd/mrz.py b/tools/ePassport/epassport/emrtd/mrz.py index 85a5cbf05..d84420d70 100644 --- a/tools/ePassport/epassport/emrtd/mrz.py +++ b/tools/ePassport/epassport/emrtd/mrz.py @@ -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( "", diff --git a/tools/ePassport/tests/test_mrz.py b/tools/ePassport/tests/test_mrz.py index 29d26f1b1..4725812c3 100644 --- a/tools/ePassport/tests/test_mrz.py +++ b/tools/ePassport/tests/test_mrz.py @@ -193,3 +193,67 @@ def test_split_names_handles_single_and_missing_given_names() -> None: assert mrz.split_names("ERIKSSON< 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 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