diff --git a/CHANGELOG.md b/CHANGELOG.md index 26edf36b1..a531fb455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +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) +- Fixed `tools/ePassport` - the personal number read from the MRZ optional-data field showed a filler character where a space belonged, and a document number longer than nine characters left its overflow there (@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/model.py b/tools/ePassport/epassport/emrtd/model.py index e98973ae2..7be271b15 100644 --- a/tools/ePassport/epassport/emrtd/model.py +++ b/tools/ePassport/epassport/emrtd/model.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path -from .mrz import Mrz +from .mrz import FILLER, Mrz class FileState: @@ -81,6 +81,17 @@ class DocumentDetails: return not any(v for k, v in vars(self).items() if not k.startswith("image_")) +def _mrz_personal_number(field: str) -> str: + """The MRZ optional-data field read as a personal number. + + 9303 replaces every space and special character in the number with ``<`` + and then pads the field with the same character, so the trailing run is + padding while a filler inside the number stood for something we cannot + recover. A space is the closest we can put back. + """ + return field.rstrip(FILLER).replace(FILLER, " ").strip() + + #: Poland carries the PESEL, its national identity number, in this DG13 tag. PESEL_TAG = "5F70" @@ -232,7 +243,7 @@ class PassportRecord: if self.personal.personal_number: return self.personal.personal_number if self.mrz is not None: - optional = self.mrz.optional_data.value.strip() + optional = _mrz_personal_number(self.mrz.optional_data.value) if optional: return optional if self.dg13_personal_number: @@ -258,7 +269,7 @@ class PassportRecord: """Where :attr:`personal_number` came from, for an honest caption.""" if self.personal.personal_number: return "DG11" - if self.mrz is not None and self.mrz.optional_data.value.strip(): + if self.mrz is not None and _mrz_personal_number(self.mrz.optional_data.value): return "MRZ" if self.dg13_personal_number: return "DG13" diff --git a/tools/ePassport/tests/test_personal_number.py b/tools/ePassport/tests/test_personal_number.py new file mode 100644 index 000000000..370416b5e --- /dev/null +++ b/tools/ePassport/tests/test_personal_number.py @@ -0,0 +1,53 @@ +"""The personal number taken from the MRZ optional-data field. + +ICAO 9303 Part 4, positions 29 to 42: "Any special characters, including +spaces, in the personal identification number ... shall be replaced by the +filler character (<). The number shall be followed by the filler character +(<) repeated up to position 42." + +So the trailing run is padding, and a filler inside the number stands for a +space or a special character that was substituted on the way in. Printing it +raw put a '<' in the middle of the number. +""" + +from __future__ import annotations + +from epassport.emrtd.model import PassportRecord +from epassport.emrtd.mrz import Checked, Mrz + + +def _record(optional: str) -> PassportRecord: + record = PassportRecord() + blank = Checked("") + record.mrz = Mrz( + kind="TD3", + lines=[], + document_number=blank, + date_of_birth=blank, + date_of_expiry=blank, + composite=blank, + nationality="USA", + optional_data=Checked(optional), + ) + return record + + +def test_a_filler_inside_the_number_stands_for_a_space() -> None: + assert _record("123456789<0123").personal_number == "123456789 0123" + + +def test_trailing_padding_never_reaches_the_value() -> None: + assert _record("ZE184226B").personal_number == "ZE184226B" + assert _record("ZE184226B<<<<<").personal_number == "ZE184226B" + + +def test_a_plain_number_is_untouched() -> None: + assert _record("6512168").personal_number == "6512168" + + +def test_an_empty_field_yields_no_number() -> None: + assert _record("").personal_number_source == "" + + +def test_the_source_is_still_reported_as_the_mrz() -> None: + assert _record("123456789<0123").personal_number_source == "MRZ"