From 213f45e12ebaa0d35224bd0d125f1d94bc4c30c6 Mon Sep 17 00:00:00 2001 From: arosasg Date: Sat, 18 May 2024 09:56:11 +0200 Subject: [PATCH 1/6] add dg2 --- src/pymrtd/ef/__init__.py | 34 +++---- src/pymrtd/ef/dg.py | 150 ++++++++++++++------------- src/pymrtd/ef/dg2.py | 206 ++++++++++++++++++++++++++++++++++++++ src/pymrtd/ef/errors.py | 12 +++ 4 files changed, 314 insertions(+), 88 deletions(-) create mode 100644 src/pymrtd/ef/dg2.py create mode 100644 src/pymrtd/ef/errors.py diff --git a/src/pymrtd/ef/__init__.py b/src/pymrtd/ef/__init__.py index 479a726..c41b658 100644 --- a/src/pymrtd/ef/__init__.py +++ b/src/pymrtd/ef/__init__.py @@ -1,36 +1,28 @@ -from .base import ( - ElementaryFile, - ElementaryFileError, - LDSVersionInfo -) +from .base import ElementaryFile, ElementaryFileError, LDSVersionInfo -from .dg import ( - DataGroup, - DataGroupNumber, - DG1, - DG14, - DG15 -) +from .dg import DataGroup, DataGroupNumber, DG1, DG2, DG14, DG15 -from .mrz import ( - MachineReadableZone -) +from .mrz import MachineReadableZone -from .sod import ( - SOD, - SODError -) +from .dg2 import DataGroup2 + +from .sod import SOD, SODError + +from .errors import NFCPassportReaderError __all__ = [ "DataGroup", "DataGroupNumber", "DG1", + "DG2", "DG14", "DG15", "ElementaryFile", "ElementaryFileError", "LDSVersionInfo", "MachineReadableZone", + "DataGroup2", "SOD", - "SODError" -] \ No newline at end of file + "SODError", + "NFCPassportReaderError", +] diff --git a/src/pymrtd/ef/dg.py b/src/pymrtd/ef/dg.py index 502a025..dbb5a2b 100644 --- a/src/pymrtd/ef/dg.py +++ b/src/pymrtd/ef/dg.py @@ -2,85 +2,84 @@ from asn1crypto.util import int_from_bytes from asn1crypto.keys import PublicKeyInfo from pymrtd.pki import keys, oids -from typing import Union #pylint: disable=wrong-import-order +from typing import Union # pylint: disable=wrong-import-order from .base import ElementaryFile from .mrz import MachineReadableZone +from .dg2 import DataGroup2 + class ActiveAuthenticationInfoId(asn1.ObjectIdentifier): _map = { - oids.id_icao_mrtd_security_aaProtocolObject: 'aa_info', + oids.id_icao_mrtd_security_aaProtocolObject: "aa_info", } class ActiveAuthenticationInfo(asn1.Sequence): _fields = [ - ('protocol', ActiveAuthenticationInfoId), - ('version', asn1.Integer), - ('signature_algorithm', keys.SignatureAlgorithmId) + ("protocol", ActiveAuthenticationInfoId), + ("version", asn1.Integer), + ("signature_algorithm", keys.SignatureAlgorithmId), ] class ChipAuthenticationInfoId(asn1.ObjectIdentifier): _map = { - oids.id_CA_DH_3DES_CBC_CBC : 'ca_dh_3des_cbc_cbc', - oids.id_CA_DH_AES_CBC_CMAC_128 : 'ca_dh_aes_cbc_cmac_128', - oids.id_CA_DH_AES_CBC_CMAC_192 : 'ca_dh_aes_cbc_cmac_192', - oids.id_CA_DH_AES_CBC_CMAC_256 : 'ca_dh_aes_cbc_cmac_256', - oids.id_CA_ECDH_3DES_CBC_CBC : 'ca_ecdh_3des_cbc_cbc', - oids.id_CA_ECDH_AES_CBC_CMAC_128 : 'ca_ecdh_aes_cbc_cmac_128', - oids.id_CA_ECDH_AES_CBC_CMAC_192 : 'ca_ecdh_aes_cbc_cmac_192', - oids.id_CA_ECDH_AES_CBC_CMAC_256 : 'ca_ecdh_aes_cbc_cmac_256' + oids.id_CA_DH_3DES_CBC_CBC: "ca_dh_3des_cbc_cbc", + oids.id_CA_DH_AES_CBC_CMAC_128: "ca_dh_aes_cbc_cmac_128", + oids.id_CA_DH_AES_CBC_CMAC_192: "ca_dh_aes_cbc_cmac_192", + oids.id_CA_DH_AES_CBC_CMAC_256: "ca_dh_aes_cbc_cmac_256", + oids.id_CA_ECDH_3DES_CBC_CBC: "ca_ecdh_3des_cbc_cbc", + oids.id_CA_ECDH_AES_CBC_CMAC_128: "ca_ecdh_aes_cbc_cmac_128", + oids.id_CA_ECDH_AES_CBC_CMAC_192: "ca_ecdh_aes_cbc_cmac_192", + oids.id_CA_ECDH_AES_CBC_CMAC_256: "ca_ecdh_aes_cbc_cmac_256", } class ChipAuthenticationInfo(asn1.Sequence): _fields = [ - ('protocol', ChipAuthenticationInfoId), - ('version', asn1.Integer), - ('key_id', asn1.Integer, {'optional': True}) + ("protocol", ChipAuthenticationInfoId), + ("version", asn1.Integer), + ("key_id", asn1.Integer, {"optional": True}), ] class ChipAuthenticationPublicKeyInfoId(asn1.ObjectIdentifier): - _map = { - oids.id_PK_DH : 'pk_dh', - oids.id_PK_ECDH : 'pk_ecdh' - } + _map = {oids.id_PK_DH: "pk_dh", oids.id_PK_ECDH: "pk_ecdh"} class ChipAuthenticationPublicKeyInfo(asn1.Sequence): _fields = [ - ('protocol', ChipAuthenticationPublicKeyInfoId), - ('chip_auth_public_key', PublicKeyInfo), - ('key_id', asn1.Integer, {'optional': True}) + ("protocol", ChipAuthenticationPublicKeyInfoId), + ("chip_auth_public_key", PublicKeyInfo), + ("key_id", asn1.Integer, {"optional": True}), ] class DefaultSecurityInfo(asn1.Sequence): _fields = [ - ('protocol', asn1.ObjectIdentifier), - ('required_data', asn1.Any), - ('optional', asn1.Any, {'optional': True}) + ("protocol", asn1.ObjectIdentifier), + ("required_data", asn1.Any), + ("optional", asn1.Any, {"optional": True}), ] class SecurityInfo(asn1.Choice): _alternatives = [ - ('security_info', DefaultSecurityInfo), - ('aa_info', ActiveAuthenticationInfo), - ('chip_auth_info', ChipAuthenticationInfo), - ('chip_auth_pub_key_info', ChipAuthenticationPublicKeyInfo) - #Note: Missing PACEDomainParameterInfo and PACEInfo + ("security_info", DefaultSecurityInfo), + ("aa_info", ActiveAuthenticationInfo), + ("chip_auth_info", ChipAuthenticationInfo), + ("chip_auth_pub_key_info", ChipAuthenticationPublicKeyInfo), + # Note: Missing PACEDomainParameterInfo and PACEInfo ] def validate(self, class_, tag, contents): - """ this function select proper SecurityInfo choice index based on OID """ + """this function select proper SecurityInfo choice index based on OID""" oid = asn1.ObjectIdentifier.load(contents).dotted self._choice = 0 for index, info in enumerate(self._alternatives): - toidm = info[1]._fields[0][1]._map #pylint: disable=protected-access + toidm = info[1]._fields[0][1]._map # pylint: disable=protected-access if toidm is not None and oid in toidm: self._choice = index return @@ -88,10 +87,13 @@ def validate(self, class_, tag, contents): def parse(self): if self._parsed is None: super().parse() - if self.name == 'aa_info' or self.name == 'chip_auth_info': - if self._parsed['version'].native != 1: - from asn1crypto._types import type_name #pylint: disable=import-outside-toplevel - raise ValueError(f'{type_name(self._parsed)} version != 1') + if self.name == "aa_info" or self.name == "chip_auth_info": + if self._parsed["version"].native != 1: + from asn1crypto._types import ( + type_name, + ) # pylint: disable=import-outside-toplevel + + raise ValueError(f"{type_name(self._parsed)} version != 1") return self._parsed @@ -100,26 +102,26 @@ class SecurityInfos(asn1.SetOf): class DataGroupNumber(asn1.Integer): - min = 1 # DG min value - max = 16 # DG max value + min = 1 # DG min value + max = 16 # DG max value _map = { - 1: 'EF.DG1', - 2: 'EF.DG2', - 3: 'EF.DG3', - 4: 'EF.DG4', - 5: 'EF.DG5', - 6: 'EF.DG6', - 7: 'EF.DG7', - 8: 'EF.DG8', - 9: 'EF.DG9', - 10: 'EF.DG10', - 11: 'EF.DG11', - 12: 'EF.DG12', - 13: 'EF.DG13', - 14: 'EF.DG14', - 15: 'EF.DG15', - 16: 'EF.DG16' + 1: "EF.DG1", + 2: "EF.DG2", + 3: "EF.DG3", + 4: "EF.DG4", + 5: "EF.DG5", + 6: "EF.DG6", + 7: "EF.DG7", + 8: "EF.DG8", + 9: "EF.DG9", + 10: "EF.DG10", + 11: "EF.DG11", + 12: "EF.DG12", + 13: "EF.DG13", + 14: "EF.DG14", + 15: "EF.DG15", + 16: "EF.DG16", } @property @@ -138,12 +140,12 @@ def __ne__(self, other) -> bool: def set(self, value): if isinstance(value, int): - if value == 21: # DG2 tag + if value == 21: # DG2 tag value = 2 - elif value == 22: # DG4 tag + elif value == 22: # DG4 tag value = 4 elif value not in DataGroupNumber._map: - raise ValueError('Invalid data group number') + raise ValueError("Invalid data group number") super().set(value) def __hash__(self) -> int: @@ -159,8 +161,9 @@ def __str__(self): Returns string representation of self i.e. EF.DG(fp=XXXXXXXXXXXXXXXX) """ if self._str_rep is None: - self._str_rep = super().__str__()\ - .replace("EF(", f'{self.number.native}(', 1) + self._str_rep = ( + super().__str__().replace("EF(", f"{self.number.native}(", 1) + ) return self._str_rep @property @@ -178,7 +181,20 @@ def mrz(self) -> MachineReadableZone: @property def native(self): - return { 'mrz': self.mrz.native } + return {"mrz": self.mrz.native} + + +class DG2(DataGroup): + tag = 21 + _content_spec = DataGroup2 + + @property + def portrait(self) -> DataGroup2: + return self.content + + @property + def native(self): + return {"portrait": self.portrait} class DG14(DataGroup): @@ -187,7 +203,7 @@ class DG14(DataGroup): @property def aaInfo(self) -> Union[ActiveAuthenticationInfo, None]: - ''' Returns ActiveAuthenticationInfo if in list otherwise None. ''' + """Returns ActiveAuthenticationInfo if in list otherwise None.""" # Loop over list of SecurityInfo objects and try to find ActiveAuthentication object # Should contain only one ActiveAuthenticationInfo @@ -198,14 +214,14 @@ def aaInfo(self) -> Union[ActiveAuthenticationInfo, None]: @property def aaSignatureAlgo(self) -> keys.SignatureAlgorithm: - ''' Returns SignatureAlgorithm object or None if DG doesn't contain one. ''' + """Returns SignatureAlgorithm object or None if DG doesn't contain one.""" aai = self.aaInfo if aai is None: return None # Get signature algorithm - return keys.SignatureAlgorithm({ 'algorithm' : aai.native['signature_algorithm'] }) + return keys.SignatureAlgorithm({"algorithm": aai.native["signature_algorithm"]}) class DG15(DataGroup): @@ -215,12 +231,12 @@ class DG15(DataGroup): @property def aaPublicKeyInfo(self) -> PublicKeyInfo: - ''' Returns active authentication public key info ''' + """Returns active authentication public key info""" return self.content @property def aaPublicKey(self) -> keys.AAPublicKey: - ''' Returns active authentication public key ''' - if not hasattr(self, '_aakey'): + """Returns active authentication public key""" + if not hasattr(self, "_aakey"): self._aakey = keys.AAPublicKey.load(self.aaPublicKeyInfo.dump()) return self._aakey diff --git a/src/pymrtd/ef/dg2.py b/src/pymrtd/ef/dg2.py new file mode 100644 index 0000000..a1b5895 --- /dev/null +++ b/src/pymrtd/ef/dg2.py @@ -0,0 +1,206 @@ +import asn1crypto.core as asn1 +from .errors import NFCPassportReaderError + + +class DataGroup2(asn1.OctetString): + class_ = 2 + tag = 21 + + def __init__(self, contents=None, **kwargs): + self.nr_images = 0 + self.version_number = 0 + self.length_of_record = 0 + self.number_of_facial_images = 0 + self.facial_record_data_length = 0 + self.nr_feature_points = 0 + self.gender = 0 + self.eye_color = 0 + self.hair_color = 0 + self.feature_mask = 0 + self.expression = 0 + self.pose_angle = 0 + self.pose_angle_uncertainty = 0 + self.face_image_type = 0 + self.image_data_type = 0 + self.image_width = 0 + self.image_height = 0 + self.image_color_space = 0 + self.source_type = 0 + self.device_type = 0 + self.quality = 0 + self.image_data = [] + + self.data = contents + self.pos = 0 + self.body = self.data[self.pos :] + + super().__init__(contents=contents, **kwargs) + + @property + def datagroup_type(self): + return "DG2" + + @classmethod + def load(cls, contents: bytes, strict=True): + instance = cls(contents=contents) + instance.parse() + return instance + + def parse(self): + tag = self.get_next_tag() + self.verify_tag(tag, 0x7F61) + self.get_next_length() + + # Tag should be 0x02 + tag = self.get_next_tag() + self.verify_tag(tag, 0x02) + value = self.get_next_value() + self.nr_images = int(value[0]) + + # Next tag is 0x7F60 + tag = self.get_next_tag() + self.verify_tag(tag, 0x7F60) + self.get_next_length() + + # Next tag is 0xA1 (Biometric Header Template) - don't care about this + tag = self.get_next_tag() + self.verify_tag(tag, 0xA1) + self.get_next_value() + + # Now we get to the good stuff - next tag is either 5F2E or 7F2E + tag = self.get_next_tag() + self.verify_tag(tag, [0x5F2E, 0x7F2E]) + value = self.get_next_value() + self.parse_iso19794_5(value) + + def parse_iso19794_5(self, data: bytes): + if not ( + data[0] == 0x46 and data[1] == 0x41 and data[2] == 0x43 and data[3] == 0x00 + ): + raise NFCPassportReaderError( + "InvalidResponse", + datagroup_id=self.datagroup_type, + expected_tag=0x46, + actual_tag=int(data[0]), + ) + + offset = 4 + self.version_number = self.bin_to_int(data, offset, 4) + offset += 4 + self.length_of_record = self.bin_to_int(data, offset, 4) + offset += 4 + self.number_of_facial_images = self.bin_to_int(data, offset, 2) + offset += 2 + + self.facial_record_data_length = self.bin_to_int(data, offset, 4) + offset += 4 + self.nr_feature_points = self.bin_to_int(data, offset, 2) + offset += 2 + self.gender = self.bin_to_int(data, offset, 1) + offset += 1 + self.eye_color = self.bin_to_int(data, offset, 1) + offset += 1 + self.hair_color = self.bin_to_int(data, offset, 1) + offset += 1 + self.feature_mask = self.bin_to_int(data, offset, 3) + offset += 3 + self.expression = self.bin_to_int(data, offset, 2) + offset += 2 + self.pose_angle = self.bin_to_int(data, offset, 3) + offset += 3 + self.pose_angle_uncertainty = self.bin_to_int(data, offset, 3) + offset += 3 + + # Skip the feature points, 8 bytes per point + offset += self.nr_feature_points * 8 + + self.face_image_type = self.bin_to_int(data, offset, 1) + offset += 1 + self.image_data_type = self.bin_to_int(data, offset, 1) + offset += 1 + self.image_width = self.bin_to_int(data, offset, 2) + offset += 2 + self.image_height = self.bin_to_int(data, offset, 2) + offset += 2 + self.image_color_space = self.bin_to_int(data, offset, 1) + offset += 1 + self.source_type = self.bin_to_int(data, offset, 1) + offset += 1 + self.device_type = self.bin_to_int(data, offset, 2) + offset += 2 + self.quality = self.bin_to_int(data, offset, 2) + offset += 2 + + jpeg_header = bytes( + [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46] + ) + jpeg2000_bitmap_header = bytes( + [0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20, 0x0D, 0x0A] + ) + jpeg2000_codestream_bitmap_header = bytes([0xFF, 0x4F, 0xFF, 0x51]) + + if len(data) < offset + len(jpeg2000_codestream_bitmap_header): + raise NFCPassportReaderError(NFCPassportReaderError.UNKNOWN_IMAGE_FORMAT) + + if not ( + data[offset : offset + len(jpeg_header)] == jpeg_header + or data[offset : offset + len(jpeg2000_bitmap_header)] + == jpeg2000_bitmap_header + or data[offset : offset + len(jpeg2000_codestream_bitmap_header)] + == jpeg2000_codestream_bitmap_header + ): + raise NFCPassportReaderError(NFCPassportReaderError.UNKNOWN_IMAGE_FORMAT) + + self.image_data = list(data[offset:]) + + def get_next_tag(self) -> int: + tag = 0 + + # Fix for some passports that may have invalid data - ensure that we do have data! + if len(self.data) <= self.pos: + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_DATA) + + if self.bin_to_hex(self.data[self.pos : self.pos + 1]) & 0x0F == 0x0F: + tag = self.bin_to_hex(self.data[self.pos : self.pos + 2]) + self.pos += 2 + else: + tag = self.data[self.pos] + self.pos += 1 + + return tag + + def verify_tag(self, tag, valid_values): + if isinstance(valid_values, list): + if tag not in valid_values: + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_TAG) + else: + if tag != valid_values: + raise NFCPassportReaderError("InvalidTag") + + def asn1_length(self, data: bytes) -> tuple: + if data[0] < 0x80: + return int(data[0]), 1 + if data[0] == 0x81: + return int(data[1]), 2 + if data[0] == 0x82: + val = int.from_bytes(data[1:3], byteorder="big") + return val, 3 + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_LENGTH) + + def get_next_length(self) -> int: + end = self.pos + 4 if self.pos + 4 < len(self.data) else len(self.data) + length, len_offset = self.asn1_length(self.data[self.pos : end]) + self.pos += len_offset + return length + + def get_next_value(self) -> bytes: + length = self.get_next_length() + value = self.data[self.pos : self.pos + length] + self.pos += length + return value + + def bin_to_int(self, data: bytes, offset: int, length: int) -> int: + return int.from_bytes(data[offset : offset + length], byteorder="big") + + def bin_to_hex(self, data: bytes) -> int: + return int.from_bytes(data, byteorder="big") diff --git a/src/pymrtd/ef/errors.py b/src/pymrtd/ef/errors.py new file mode 100644 index 0000000..05a40d9 --- /dev/null +++ b/src/pymrtd/ef/errors.py @@ -0,0 +1,12 @@ +class NFCPassportReaderError(Exception): + INVALID_DATA = "InvalidData" + INVALID_TAG = "InvalidTag" + INVALID_LENGTH = "InvalidLength" + UNKNOWN_IMAGE_FORMAT = "UnknownImageFormat" + + def __init__(self, message=""): + self.message = message + super().__init__(self.message) + + def __str__(self): + return self.message From a4c8a22d0beccef745411a441b8ffb241da1c499 Mon Sep 17 00:00:00 2001 From: arosasg Date: Sat, 18 May 2024 09:56:20 +0200 Subject: [PATCH 2/6] add unit tests --- tests/ef/dg2_test.py | 50 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/ef/dg2_test.py diff --git a/tests/ef/dg2_test.py b/tests/ef/dg2_test.py new file mode 100644 index 0000000..934a9da --- /dev/null +++ b/tests/ef/dg2_test.py @@ -0,0 +1,50 @@ +import pytest +from pymrtd import ef +from pymrtd.ef.dg import * + + +@pytest.mark.depends( + on=[ + "tests/ef/ef_base_test.py::test_ef_base", + "tests/ef/dg_base_test.py::test_dg_base", + ] +) +def test_dg2(): + assert issubclass(ef.DG2, ef.DataGroup) + + # Test vector taken from German BSI TR-03105-5 ReferenceDataSet + # https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03105/BSI_TR-03105-5_ReferenceDataSet_zip.html + # Datagroup2.bin + tv_dg2 = bytes.fromhex( + "75823ae77f61823ae20201017f60823adaa10e81010282010087020101880200085f2e823ac5464143003031300000003ac5000100003ab7000000000000000000000000000000000101015101c10000000000000000000c6a5020200d0a870a00000014667479706a703220000000006a703220000000476a7032680000001669686472000002130000019d0003070700000000000f636f6c72010000000000100000001a726573200000001272657363012c00fe012c00fe0404000000006a703263ff4fff51002f00000000019d0000021300000000000000000000019d0000021300000000000000000003070101070101070101ff5c002342771876ea76ea76bc6f006f006ee2674c674c676448c948c9491240ad40ad406eff52000c00000001010504040000ff64000f00014c57465f4a50325f323033ff90000a0000000039af0001ff93c7e37780230f1b88c8046a3f69d0d57fbbb996ed609909a080bd6b9ea92d5c7e090f8f9699bce9a80f3aa520c1f0d35a717a4cfb62438d34475c85f720532fc2e1cace89f6415de1fd0d05ef86c6e1b6695240e865befa18833f1b1975aecb8f9d0b873e6536111880b97ed1fe73b6569db5aca54878e2ba70297735114a1584c30c9d4ae9e1555469f81b39c754372ba3033b2dd282d723da59a3de4f70fed22cf38ca593a7e531a39e48adf2b6d51bf604c8bfe469e20a07f2d07b62891d3e7b39f326d3442730a38ec20c21fef304f4c304eebec904a6db75f0e11885968a85c9d6e9ee1f56c7512fb324629ede647cd6cec3f1b10076dc6bc882ea96c7173744e84cbd36481000e0a5058d751571265e11b38bc3c000f06ec1593a89372ce655e1fe85f696de61232404b453e2438ea063a6750d4dd517c4e7f9e523d1f94d8c9083c2ed7aa8c68c05173011485b64574567c3accdafce7a2af48bd8601c511aa8978055edaddaa15cf5352ddb9b122eb1d2b85728f81a925441a4edcfcf4c64560a5410fb516f3085ae546ed25d52d32fecb68fa6a5a575fb8a36eebf67eba8a52cdb666220482cd28db1790c2dc55a9f271e84050ecfa7b2c3eead802936496ad8aa580fe5de15602d5f7f0bea2f83d8872be4be6db87770f671c38c0f30f531549c6216979335f9c44bb7724c37bedb7b876d7c958fd039b891a23c62831ff763acab78679c91a95cf24c64df6917ad811f4bb1ecf2634fd871a9462486640ded0cdf2ec1c1e833971fa604b37efcbbb398c7fe585d74c53c5ea238feb60ffb460ae2c3473cac8f19214edc7971bd2e3b9af6ef3c541b37fdc337590bb8f4c99119e55a2f62964ca8433fdb4fdbd141ed96c3eeb061f75530fbaa307083adcffcd502fedefdea8dd7b26e06bd96d99ed5e19a1760738c96bcf847b46d5e4d06e678de99e559be55625a0e9f160390ac3bd573a337c091026b80340361487aeb33f2765c25ca422c4ab87b355478fd146cb5f94b59ae3c54b56f984bef2634989c90d0fab9efb61b94bd9a9a5bfc6d315a2b7f711e961c2be8c2a83349f22b8507eb6b7574d3ef4e8006b48eebc59d61de2262cb53dbdcaa6d0b08a396e9be204ca41bd5180fc317194c1a734b91ca3c90def028bb28525cb45358e63dab7046724929e65ef21682b64d03a22ee2de91e5a8b3723e1882cc7166c939cc5124c88b04d51fdea7dd5936ab64f47abcc21ab78cba17ee7ec53b01c3e70ed7d7b7b0db386a3bfd0fb9cdf2ec35b4bacff1390afe0513e6542bc3a1390ede3b65b8f33f073fae87d643d720f584df53d9952b1358b16e1d98e23d178f4acbfbd2569b9c179f908154368a3b29f19359817d72f7d550e378d18e3e72fbab5e9e1bb25d0146dc9282c5aa1ef3971b3d5659d21c51f098a1df6d6055e4e55d8298a6660416ad8496cc5b9b21a8ace07103e02ea67b20e2a83f99e9496deb7dac22e17066ec51f2b39590805dac53e5091640a5ba256831d1b60fa5c1d6fe66f1e016b797d5574baf0c19ad4d0cb13ef096b8d0361e932dff4bf7ae018d4fd56b3728eb04c1a73274a71eff2b47591150dcee004fa0e81edaacefb0a12dca5d4e96dfb59d9a9bd08099a907056ac0faa8a81f538c03e2ae0096128103264200e4f2e96fa7c02da58c0c17d7f269d2057cdcaf28590cde9f5de6ce053db73d7281b733217d4e338afe97b5e15261acf6e5d3b7723a36f33dee3eeb0b68435463b1236dfd625ebc18e4b07b7bbb8e97396bbe826674dc65769186cb7c804f19163027f8f0d6853f24105c3af80897bb1ac1440b7a3ce41774896b12974ee4ec6f1d5ebfaed7627816bf42e4ef43abd2dcc895f1a0ae8e916c5380362bcb609f327885e82e59bd382dc5ea10354915375e7e3f0f38c4f2676aadac85781fdddcbb1dc1969aeeee54c7e40d7ddbf0fa6a05acecce5df12569eeaa1d71c2eee4d9235d1b50aafa876c25479d0f6155ef5f364bb4ea641b3dc01f144c81d6aa610bf316ae6a95707bd60dff7dab4f5e61863acdca1d293dc13e6e78b31631a191dcaa5d0bc0332ed8b393d0fad36a264632631e929fb9a198e12b59426899ac213c7772d12b044643e921b94daef788be5626c0fa34440fa2eb01f2da4061612e6cc9cbb343e7f8d11697c0bb84acf200302dbd8e9a7f93b3ec2d392ce7dfd92bb1254b684ca255f0040c0f29b7a57bb293b043ce7b335ca70dbe4d61dbdacd51de53060ab472229977ea6efd9d0b9a5d6edbabd0f0ff11fc5ad30136b1c3653fb346a04651028fdd295e336e0c66abf2a83465028462ddf2e9896499b46bb0accb8e908b986bdcba614050ca2145a4817a7c5ea42538693547880d508abd6904bd09f7f0e175f51675ad5c97169e5631d86945abc0651659a411e191d13805d04f164cf205e56fbc18be9e649803aae149a4c7a9d7dea4b97fdd2f63148fdf19fe203122c1e7defb025b5b4ce85a79b585cd6a9de1cfa54f7acc2ddb31dbb86ff5a1dcc0201dd5661642cb83d68cda96d8bc6656f3fee9ff567a462a8830d515beade05731d287df73dbcb3e6d6c4a5d2f411d249664ee8c1702cf46341ad3dbdee83e18ae55a01399c0919027b9583faf5b49a1174b0fb196d213543df8b9329c2257c3ebe95a1f5ee9c1f476e88a9217bc700ff4317d8b67898bd9506b00d0c418a2a3315e0d7ba56a95da373502e8987154f3eb59d7d3e1a8bcfdec14d07f95ab11e93efc3579cd0d70851b2eb4525c0ba8749d57dcb071dfd80626b4d3506b43f7465e78363e6af06c156c04bcb17ea1da47813c803ed597bb40e4897fe37a67c8da1650fee8a96b5ed555cf535e81aad710c7edeaa51aa0c70637f412b5d8a7cd596e689e954065fb2f4032ea5e29b7e676a0072dae1e7210334895d7c33451163f4303367d14070159215c5f53a6f864a359da164eaaa14f34e4f4d7b1315b95295f5761c2780e9d9fc5b87253c8ee51151cf640307b0715c2726bcad020dda7fe8578c079e383af80ae97db4a1e00c3a9c7a40da15ae2db83bfa107028286ab307e76b0f0459af01e962e53a5b3d8fbd656beb4a5143c9e97e468194a6eec9cace481af8ca5444d994882878158e4a77e807e63a47212c561f7b673396bb8a797f3e2bb237dac4be42ddce7dc3893bfc317b9f3e3906ea486d852fd94a85bc117529fbc9270ceef207c152e8d9f513db398c9d8ed1aa43fc0ea42e9097a228dbc3784810ec1a211c9f6643b3a1d0279a7b25fa4f7c8c2c48866b26eb8ff79f29a429459a0b7588c768a7fe45034a50d9d7d642bf9646aabef74a514882458f65170eb6ecb820b3895cdaccff5eda3856b7924a1ee6ab24f9fd185eb06db7fa1166237e26c1c543122ccab89f41136979cfe1294d631ceaca31ad316f849f2c49324dab4054fce53cb15c356c563635d0d94b05676e2e82e88c71fe375a1228f41786cc188ea856b09b83b335395b110f47977761ef67ca33eb89aee72214e756140779da353577fd7229a2a51f87ebe1d564092d5a12d2459bb74868880c51e6b8cb477884b2cc004a24d6a61b502f101b6eb766a0e585d2e6d83937dd9a0f5ea2c3f29e8dffb9c19fa759d1a732d98c56faeb025109f2b496d45c89ccf3ffafe0fc7f2d8a9afe96736ded4fea4218e397f362220f8275347e03ce3fb19a2d3b1f0f275aa5a85cd3a84a5e5e22b8c1b9dc8395b006c80d7e84e653bae0c60a55f49679efb78729ca0835fe1a3cde2b30735c1faf4dcd8d158293658cd5f56ff01e73636a8e9a34119bacdbbd8d0b22c17224457bab0220d34417e8a2310db896981d6aee3f824639ca3ff5a183900672666db80b7074ee55d2b5f279541efdb78d76586c3f8897458d9a6715236ff0d3e9f6e946c67d692780ae49bc1adc81c28dc339a6aff287ee4b0ee5ce19b2936478f98fa3b02c0e1a548b30fe1a89298866653f2c0ada64b72dcad94adf828adaa0aa48c671c88d40f5d1d31d0ddc1067d50c319e97a95241efc2405f2a7a3a096d3182e3076d947da2850a1391f4f850f1636f408d723573dfa5d0d1bc8722a92b2d73059da04b40a0894bf06a27f7c5556b1d6e4cb21ac3dcb808dc61b94174d09d5bdc67288605a5fcc44c2eecdfeac4fb5f5de0dc51c3b0bd8c1cdea6e7b618a4cf733860db50194ca0b4bac722da670fd22e1546bc1e3f987be8894c4ac5ac53cfe297505808cedc673440268fe8c07a1383ad11f38a81491ca3e25117f4a744516a45af240960da997f5c6e37c84130a1f56f49dba98c4877d0df37001c8c74607c7ea2747c11384b475bfb1914d0cb1fbf38de63a4dca4ff5d5b109be7097de08bf74e39dd41819d748e0258eefd88661e4de5fd0cbd1f13779a1b86685e476d12be1b40d592953c6c1335cfe539b10b607ae40f5c758162bd8da0c183e9ee6843a77ef4fb45b9c9c5b25932df4e9998e420f12c5706dfc4141eb87b0c51901bf06c7ff0e008fa5e0e69e09d6f2f282603ada927448deeea32e0824a4b793a529347bf409e4849e54b5e53df686070411d4cdf7ba593c105777fab613cc7cc466d875d1ebb8ab6e78e57ee7be07d9c5cdb370e3b113c607f561d14b72f7f7ba2d450cb4ec539b4e4ed34f6a57b468120c83dece74a2cda8479e8aeb06454de2c90f11a3087cae8b015d72f77fbb1f3c617359aeff1c02a87b9273ebb6d8db382ecf442481e738b68f38db22d1dbd2474b7831916ce2caff6419ddcaf70088ea61d3f9a4b1da19df0fe5e1e007b11bc9e5bd9aec478f694ca8ce51c07c9d5181f3ee180f9352002f2231df912964d3d6953888633a344589823e6823d379313a27a660a10a351feb39999f7e0289820da8f16d9f95e2a8396dc84601b58e5e92d6d766ec04f1bbbd47ce5f971d2da65893b36366bf1d7426fb4b20dead29ec3f5441ed11816c46a26b29305776290b152b3ee5e046e3019c18f010544199b45ef5c22f427eed18917e461d489c07ff87118e958ecdbed8b0330ac159f6135c842576348d5b2cce9e1e9f244c271fff5334231f7c83998b259c2093c6fa0503a09614b543493d457fdaa1b96e5ea0692f0090faac51573be9a4dd9601ed0356cc9b2d9ea4a64f1d14afb020281aaafbe818b85f7ecdfdbe9eaf2895fa1b00f75d0f7cd6f764a845d28712e5e3f59ba619b3c6cce1d09c13d67b246aafc516df50a91d9255a31a55a23e04be24220d7aef64e803fa3ead8f2a9fc9af1a1ee88a39e1cabf67a614e20f71f0bac46de78685e26d08ae5fb3a32ec1baf463cc907e670da1384b468c20694c8e925fa21cecbbddb966db8867b07551e3be9ea826058ac345c8e96914c50a95213ad49d0c2dacbc69485b051129d07e37b0b08a66ea1940c5b75f82c05a426a17274b8ce4dd8bfad6b2d285fb72e121f7741094a18ac012baf9fe58861eca8f2467198df28902858f1df3f12eb7a96e81a96563ba6b9a5f4794e0b03af193ad62a9b3b7f0957e2b422bef204809d22c29f0d3dc28df3d2568527ab0a3e85b16b8cb29b5e2d895e6267788af586369bf703ff1076481cf7caafee1c963e7772a893cbda37cc005bead30175cefe02fa7c9485eb2e036552fab38a991eaf078927d135ca54a60d0157095d5d5c15d021fa42956e7d6b07d6ad5b2dffdb9b79aa74efc894668be4281399f0f2d954b1d8a79b3907df5737f69786c5dc78aec776934431d961d2984f43d1d7c9462d89794228f01df0c556166870a72f528be976ba42a64a015bed20ea5e7295956c111899202752eff6e4604ab6f3415b1397b4eb1e9c949b2485cbf92960b7f7dd32344038cd415c1f475b207cbd0580f8b5f80c6fb285a1d0100174add61e01ead09fe07eca26a17e35c5bce463fee1cd4ae945de87453545d29bc439eb34972e38515873ce13228f6c1e8c0c055884dc7b1b90bd1d7a00dc9e4af2f94c0e03487b189e2446afca6699bed2037b32a7ba5be3cb88cca173335c483ca8369f3dca3d3ddd9264ba2f4c32af9cf944c5e1407c7bf14cbc08dabb5caa9afb72d52477ea2e3e18dab0333bf31d8eeb517e0aa9ad42354068384231612a60770d5f629aceb143b6fb25bf8267ad6e9906692aef49d6211275740580fae21fa475272663d4baf3ae488e6a04bdaf17ac384c2fbc8f95f6941e4f7581b48f7d1aca17eaeccf8ec23344c773a72c43aee9b8d0eb96c9f7be23ffcaaf03969cb52cb75a2b584604cebe7c40c9bbbfc19c6c600c8fc46c784ad72fbd91222f2dea820078ee894718599a6cb82aa69b129810737a5eb07db77fc90355a76bcce2f700b80b8f700313bec46174d8b9819278d6457340b5d7ea737dd2108353bf36ceec704f7c17f6a14b7787cadd20e2bfe37eb5bc2f91a2e8dc0ee29468eed24e58ea092d78e98cfc5495f93d5b56e185966a9f1460940c2431cc9dd1ace79c9161bff3a62068c36c0cf82cb0c454245c5a04882e1c6604736a25023b76fd2c3013a4ad5e7a975c68ddceceba72766d1e146eb6f99f7711b73c16534cd11f261d8f6e88a0fca2d0c9a374cb0033002f38625aae850847e84387290ad30120cb646ace66e9110cfe25338498ae78638e439c28bcb2d871caceae12d4985247e7dfb4a4b62f2b47a09d2582a87c2a394f33d417aba1d2f313824964acba4915983e3a9fe11e4a5fe092d3d9d560fae2d912bfe88bbff0bbce5d2f5e6d11a07c5999516ec894cd34de68ca23822be1d22deb13fe4ce5c3f6d4fef26df1f5d132682dbe0136cb2ee5d64a66cf219d946a041c6c70acb835c4c5fd6fa47d40ebcde1d86fe6c6aa3a36643458b8f54cac6171380696132850d93c9e21e1bcc4c4a3a71d046e1de0c990f9bcfddd8e9c4f870887f07b6a672dbc022a3b213052c943f555662c54afdc7b2c7e13f960f5acd8d6de0c93e4a80f2b4dfa5a01151dadba477062306dc05e5d7cc98f3d7ff851f08e460c840d5dab40e12c70b97a7b991ba89145ca018821175e770e3f5fb115f52e61fa3e927fa1fe0fcbf400fcebceb98e8afcc8578184d78511592f7f63691ac0462dbf095fd7f3b2f2af67bb24964ceccb52bcb54befa3dae6beea34e9f7de1505b79ff40cafc8439838105622bf1a827ae758df6f0278e67e83c69d83ca9dc0d50ad50e93ea98d3b85b9390ceac654256bf3602d6ecad76f36f0201c3772fd77ed5f0aa583d910a60f00ed5ff3e363d1873ae3f7f1f90dd63440f8100bff2597fe2e2e6e524664cf548580dfcb510b0036efada22ccd2c9cf5048075e653fe41e96ec29ba82f2aa78e9da62b4c070365f47ec2393b2d8ad2ad72c5829e1f766c0ef09bd89cbcc3fb23a71ed41e4cc98c512c69d0c536e0b097467a0bd52390f5dd41dad3c1532faa1d4e754612954ca65b2d70115cd2711f4e383bef1b223a402d5ffd36cb0b9025273b75dc79cae675ece2b21134af7c3a26e73d4ea77a8c57887b433156ecdb4c7f26e12424da0d4f7b10782a6ec045358b7858aeb20f4af7e049b3557f5546c2947dbc70f193d6a70cef5db21a7588b86add2fc31ef5f5d11e6e54fa068d0b38356a9904b312510f5f4ad00f03e81b45f00ab03e5f1d302a5eece9b1c6dc66940049e0df69913976c97f74596ad8780f09515c38b424b0a0b6311a2b5b1a935e52305207971bb29503d3d5cd949234d833972c75ea65ad0764d010e4be3715afb0a453606aa3fcc2477adf54d03bd52bbba5e65de5624dc449e4f2971e386c50c9c63edccebbaa05a7a84328f05794c92c6ccc3bc778587cde72512dfa260820f9fd2ba28f1c906f761a9be2e7e82bb581cf6daf2e83031d52b69f39071f5a1f7028b226d90c5ad67c0d38d53cbcc803b20ecb67d657263f617c735be194e06ce2b29702fd17e415a2e41968d5557658ca05bffa544fcea4fc66aa724a93871454145344b57a21f34eea7977bd4017afab70a5f137da1698b11c78fd1a830fce9e53fdec34c719a55b3dc82478caf5c7a73f5151851b03e1820f4258a1633bc896993e18ca623d5e3e699631667f698643a4f6759b6d51e470772663c982cbd38fb9d0e0b6f60203c47f855631037f316139047338a607e7ec6a2eab2610fb95df1c2584ba5c97694b88f4f8f10319fe0e0b861178c73b97c5aba0f42149a9373767f2c0a581768c1a44dcbc3da7f729816025fa3e89d964c78c281d554919f0f0d18b164e3e47523694698e4779f9602cd07391b37960e3a5df73f9d599e3a2cf31ad337a1e65491d7f03be8e6709b10ceb128448fbf3200eb9836229e14008b633ee49e62cd549b0bfa65593c0b4b7ca182b45d3881e85b483e9615b92b01bb3943792f9c27b52209ba81b5fd40d7427361f8b3f1f4641de35cfd6019eb859b05919922ecd82d70fe637a1adc81ccb13501c4f8b4b9c5ead50af47a273baf50a8bd0607e8efaf9bb40413b10091abba295d774bbbfa04b28aa3341a49547302dc0928ca6fc3d2fcfa50ff7f8aed58c160e6ba8cc67ac752aa41b84e7eb11b5e01ec9419c3f5b990b4efc50967b077dc7666e72d1416253d6257607bcc7894c92aafb2e1046a37851ca32142709d371231dee6c0e4b3a7c9c170ed248913369ea05ed1969a2a7c54814d2fe5dedf4950faf424880e5c9c01413367964b43ccae94d52fc77f8594fa26de563bb1231c8c5d8a7938fcf5231ff08fdf32f4d2c796954a13a8a80ee13e1d6f4521dde33d953e67ef50c47dd572ae1445ad1e6fd1cb0bc4878176db5d7f2358393eb9dfba16dad4e994a92d0eda9be4d2379773512e28b46ab9159810c79d34f2bbd894c4f47add9abb86c638e2685ad1fa5213bd1605a901a879b5c346bc1b39293bda9ee860f72c84e62ae6313f21a02d913d2bfb6ff7b53346a889dce00b8e6061b2c3b680159061626b823df92cf8b8cf4b7c3a49aca98a5b5ace55667cfb94ce16a5f9770380f8c9cf17f0feaa4deaf396ba0c913c18a1aa162f60f8a8386012b9d6cd7a5034473a19e3be95c11972cc0ba9d9c79ab9c0d105b73d8fb69b2e54bb58250bfc9100538c936b8835e9082be8ca98bd2891cd6aebea50f2801d51d0593270de5e655e1337cf6f8e734865211239e20e1f767948010509bc9876d27b4d0df5497ed95c64fc9b4c165f210b3b956def04e2b463add5f1a37a8bc085927528ff6d58fa4d1281a488e8259f8c02763b732ba504186882a4c7ee4a5e2dc8f4022ce1dd55bc1476edf2d7eb5603dab4c73503871e38cd5f3b4d24bdf86374e02787558fb6083d3c5cbb0d0419a576669587450d62eb07e9eada411ee8d68b5947f24c7566c95c3b79f62664f35b5b246580aa9a08ab91950f113a3bb146000af7d7f05251c522f2992267139273af02384c14cef6ea58eebc5adea0dfd07d1956bead4269b24edf30b59b50662d8689cef25b0cb3bb704f01035c7d98f69d26057607e5772683705ab0320de7b0fc0b7e8341a92c56decae50df70185521b7772e6bd7c91cdca31cbf7ec6293800b82af354ba28f8429dec3e498564b51b14903746dcf134f06f9bff20f25584b56561d7c2310b18729e0e92af6d8c9b54f0ccb0c83aa96051011537339d4b96ad4c61ebb8fd89092313676b190a47706f00aae00068518dc28024ec69a320d85cfb29f66ec4fdfac1279fcf4e8dfc9c57e0711adff02afe724f82e1e4098ce629bd08469dcac8967388402b0649d23235f99c3c99647f3531a52b3a7c2a41434232208c8755ae0d8741de7596187da6579281cc24af8f406bb1acd5a4bff9dcc15e940308eb60cb8964540a67931415a5c9d743374fe22d559c1659886d1d246a635e57863e0279c03c1bfef9091e2c3f7256a9c48f1c5c90816489fd198cc6fcbbd0bf5467e711074714b03dd9708deba8445792c2477f245bb2bf5c241198ef873d6bcfd646e4b4054a2b6620d94189f72e0095523fcf4c15f8230d82712744ae7b3250cf02f1105dc2e0b98094865a7a2be794a17b7638f4012d5ee8532736a7e2e51b3bda4281cebdd9bd6be89be9ede3e956c18f8b93065f70d5022aa9aaf4b7f1808a326e785b1f3033e109aa9533e746e7d6022768b6f2e72417a43d89766c8e89a74611483c3a4ed9a9655184cebf2da95d9dd63835e8a392841dfaaa0a2bd97810581c2d9135dd4f1b2638c9f4f5794b4edbd5c9fe0ee38164edf15018214727f310f88ff6b1b6ddd7847f24bdfcd30dc8ee94844636cb680a802d3ec1ebcbcc1dcdf2fb92e63826ce4be403a4d07c8bb11e5c88c12b600e9e0fe1810d371a9402b990f62a330c0e64c1e10f289fbf70e9b58169181c4d393f2731d929d38e806125d622c4c646bc8e7a7d74327b44d0c688e927843cf6b9a11f592f8789725379e0704155763e6cf55125535ff67c76cd0cc75e20fbe8bc2463ce9906d9fc37a1b3202524800dbb3c71c2edea404ed00e8365b2c5c2a96fc74c2a49d1565735e762fd6dae009bcfa3b11089b5ef8b6a6064db1fa1ca4065059e22ff2a23ea9995872f4b23aa32e2cab7d7d8a21608de39bf17c4eff07de1a47e307091af89de7eac3660ae8ad94c4e8ea514cc542732601ead0b27645f303a83f47374ce663a0f3a3441452246c2bb93b129713dd5e493c34c4443b2d3e53dcea742c08b4bb2452779adb9a112f6b333f44733dc9e04bf80b6392ddd956288e7dfc4983f43c7c80b914e0d347a059f93ce3b4ae34948249f76a6ae603611196cf815f55f3d35f02336805cc6cd21c8fa80ab450fba8bf63b87b189dfda40e8b9b011f656a49b702b8d33f66c1594535fed507de447c45bd43ef62264f1531cd2a917f8f08b1628ac88045238828f8ff28eef5e18080cc2ac54b4ae7e19f2fd16bb4a0585f51de7d052dcfc5e5068c9f65c5503bf4b29901188629e5e8c3075d6aaa6e0fd58a82c695df1f7f0369f7d28128d98f64e04ad227811fbce719d72a2b0698516f14b07316c82e8158ea5665821b6f8212bf19268eff19de4c0e50f5a9a06d799c341ccc8364b33135a93fc0e963359035dbce15936fcb849da530f3c5f0b8c578bc1c24147171489f103e0a6dde1f6acb0f18879b39a33907c1a9df82eaf010a289466d534ccede307209c9cf3db4b2aa93247343855caeb8bd22964bc4426bf3cb9a72f1fe2c9a94985fceb9840ba4e0f18b0ddd00e432fee8101a2371255b4e7b429c8826c7b8a8bdb9cacd98ebc4e1ba2bd79bf2f883f70ddbb74129deb66bcfcda3181a04fa145ae0c1f09231982b6cff5131b37f194cdc4cfe915aa12e3454bdc991ca22f2f78cb536aeea64604a4bddf0bf7e784ef977510d03bd9ddc6314bb062369c9d02cf2cf30067c33e36c284a08339cb125d26163d279104c6c7d07152ebd828ab456b7aa320731886c2cf3371913c74fe48e2d13f43b31019d547167ee3feea341e59a22f871eb5f09457f2d2ae2a326be7a4ca22771242f7578c95d665d4745e0bb66738175b883baafbf7a9a94f76c38d5ffa193b5ea7da48f26c6ed17a8a94158774644d083c70fca6d85bcb407ab719b0a3c46e6d183fe33c5fc143d8582334a2391b49e9e686134e2f4f2277544e9791aca8a19b73bcddcb22fd96632137ac1de9a4ae39758a72fdb275f5fb70b7fe878c5bb6008f503b5b11bbd7a5c980e5be14104e95b9d2b6fe4b284ad438a8bccd16bf287cf33e422e27c3a44042d6408c8f60962559f48459231c864fcef7cd1960dfb17a6a84c3cbc77b2e9e9e7cbd2f4b88cf21d7e01235e6cb647b1f9eab217d55e8c7332eaf724767defc412001b7d65b884db97191cae4293020c3a203ff158ac160eb240a3c347eb6ce1de62ce79b86dc006dcd448c14cf1e0c990d75529ac342e36a63a5d5140566a9925cd91c5bff3cd9e9d92c6241c0c5c9ff467fc6dcad330c70e4c536d4fc8c052f18a8c263dc585150dc2aa990e3fc51990dff7a7b638092cf247aba8ef5fd266a478757ec5868066d3c62c34b7051a9f5acdc6695d49f5ce6702b2a2cdca41b2135cf3bf176db0b274ac35a07ff32083c57d84c176a2f90ecd7484939c6e7d01e17015780e636b52eabeb3b89edbedad8be2aecd4bd459bfb7a2c29c8029181c0e5aa5767b2e6184443e0e3b073e72b77fee474d822f606d0158f6c91cf99f27676297905ab7628a58e61392ce36cdf82793507f24a927f5ba55c4f849367566ca7655a5bf903b7c980a38f633eb74c4d983d9eed2672a41d24802448ea921c65a977e5bae9de47486e2a043b4f1906795cf3276de88751a34a48771ef5cf29807628d0331a4c9a11051eb3ae105b8d2dd77ac42c334434a252fc39551bc2d7b03ab72cd950da6751db35bc4fa0dc6a6224b702a9c27711a6db5cabd252c7484c752457cfaa952190bd202463c0141659821fe4ab747c7eded729d35ea1b69d575e8d64ff66aa7133640fb44532228453e42bdf768bd4444d3f50d90298a8491094be745ce363457694288c5b76a92ef75c5770f43f9057e358fc1e715a7bdb3659884db29404a3599384f3d3d5abe0ef08cb6bae06e4dfd58c9aff446d27b39d73ff0ad5a6043f08982c0fc1bee2f07b4b646f681aef5050312ff3adb388f73fff6bbbd86c7320307188149bb60e37b3a41d8f0b05242880953fca16d07ce7e07e3d9b38ec07e0d73e5300e380e12d1e39e62ced7de00e03e41d0b7953a1e4e94a6cc735f820c0a82de7a81fb8903fe75da88b9af825abd5dc3559ebdb52d7204f7e7c1a20f32b4bf631eb9369814428a1636e0b20a8cb126677411995aae993a69d119457e04237e66973bf777a92d875cd7037009ea4b45729823c60b5ab7bcb36d0c972676da96152f9a5156ab1fcbd8f1a90bbc476b2f85821ee3c75120f326a203f639a58a984b8beed8bc1498a4adbcfd9b95cfe8358e6b9be27ca7b5f3327e7ebfc7375972c10d3af91d96b98d9aa74c76bb58623b7f608c4447e7b3cf857a0ea89eae9e5e84393ddff6f1d24a5b9ce0f8903dbb5702058502483491aeec3d9f25d490fb17417937aa34a1d2322478722894717f570d40288db8619e838fe90b877a3ebdb71394f91ff74b0eead6d299ecdf5977ccedc3c8c63d68719e1ea838d10879a2884390105b79543b56387bf3646475db9eb7a1d7d5669a1e07e2ec1df0d0607e3d37f890e01e687c380e11c1bcef37a21f7597e8621ab95019b8bcd107afbe7f26f976df58c2b54a4fcdd75be4a92c9c4165a67d9d57c9644f25308fb1261449fc6273d3ee52ca6ec62ab2d532e352a211ba8148782a44f79f7efc3cde2e1950004443f96f44a656398b5622cb242db8ca72c2e79af9947300ba0296001a1e6a7f1e84f3a8536415d0d1c18c018e7264d0ba5385886f232841bc621c9f0f30f5ffcae6e1eae63023bcf436e84bd3bf9719d0e839ae09cba144893ff5123aaba58713d284d01d498f618ed2316e5d10b82ca6abddab9d36442b70fb13c4463674a857ae69f11c4e61552bde817edba7082fe46b002208a6ddfb9bc7577d3c8b0b88841b78f08a54b2fe955671049f0ac966378e39054fe05ae28f433451c17c0ea7061c7b94ebd9e135383489f0a1cf52e298a9369ec18150b54ad94f50f5e7df078a661df74f31dc55263d40298e9be39f1598486933461d336b0966e3f661052c79dbd2be6a85854604c2f8a7bb4c9c5e89b4d7024af6bd9580b652a864a5873cdd091179938f56e6f58af63016931bc5aa0834d12daa68585af0aebecd774a276ed0d1195bb64b3feda7146f0d577c848b25b243f0702c60898edc992913a7305d8faf0af2a0290aaf9304f5fa7d60b791af41be11a5da0a2b25be1d54a2934a8f314a9bbd68ae7c08ffa354dfde2769baff4bfe0d337a1f8b9d61801aa87162e290915bb3684a85cca8c38d074560b7ff8b4abd8b155060569581141ebfe3dc9f891aa0afbad60e404ecfe12ed1f15a2375e1242ae10df605bb6c0c60f8590eb29ce9ef329133d21eb83539c48f56058c3212f9c64504e4f981e63ab12c2fe8ec235cbce9ce339d636d2a0e2a219cd7c8243de0076ba41a1a900a1b8740a49e7e927ec90df79cef78cef7d65ad9710f1fcff21d7e7f5c77c7e8b2f8fb7d9f82f7f0daf17e3e937e4ebff71ec4be0d43fcd5dfcf4ce0fe0d96e6854fbc4fa70f828a5afd54e35005f23ce8d9fe66d322a4aa644afea7f1a81812245d0dd42fc0e5e04a3afec1317d05ad00ab1479cb128dd1cce8d0e516060569696ef9100d616ba7094a6b955ebe79860cdc1925c740165adfa1c0a3311fc501e33598a307ab83c370b843029e68abca9cdca813373f1ef6bf09fc28b4738d6bde791bd1e7124b6023eb151e4f1b668c27188a50f49563d337653fef14f531afcc78fcce381de54aaaac1b22e707c12430a30e59e7bb39d8db499d21b771e0ae060cf7d1db8077a15761232e9ed2cef984d92cd9d29e954087fa8df100280dd41c03d8872ce3ad3e37421f4b21aa85240c0bd77ab4cc54a4759377fde792bde845df9987eb04d3aedae278575b1a1411f40d1d10479bd779105fd370572fe533c540d6c326134c45e1da63bfd506d074fd47a0ac009f5afba964884af0893aabc23993d7a3c9a787e94072b5c4aa347c178e9ecb71b032e00cefd06ed4efe005909ff715a2b0fb46cdd2a1b68185703bcfcf8f3d34ce69da1ecfd76454078790ccfa3f3a5fb53769e073e250953927712a901dc0c92329edb17132067aed3df53c64e7b56c2e791c8480b6f4771f8fdf717f48b077570e806cf78ac7aed66e8531364ea658c7faf9dcf889443937fd29afe4594892ace4ff746e61423d224e13a6a5cae15d2f327ed1f37328e6ba934ff7e0a6864daa39558be2c4f3c97fa5f75d75528e55f88b5a52de9cdc1e5ed398078c6a1077181062a6aad4c94806d3e23ee40f739c270578001c71996cf524b3c038c5d890feae13c79fd16dd90ef065b2d9fd8177381eb4ef37c83946fdbf1ddd7cfecc10fea18e71b2232f3e01a83344f5be6191d1de038b697203fabfbb2f78b5e6667cad3f889c2f947f27976f17f30ec5680312838dbe4ad3d6544346c5f7dbb947cc32e05e10612307e59723832faf5ae6eeb6bf5c0f5f047066a9993157a2708957a6ee50c3b95aec4f0b8f50a2e25bf00a620a4fabdf56338827b696ed80523a1b9e1fd6bcc62a88c7c1e68ff5215674a58f016c639885c7738e930dd2e848d4ef16b8ff8f67acb4e8ce384b41abce1fcc19c693271c8e84c3b64dd000d7dd5af0fcdec7223f5bf379beaa978fb2d0e24634585a650b693b30f9e1ff0b38c7ce1153cf6371afc5f9d36ae87388431e3a573c51c6d5c8dd938a7a26a5390e455bc50876b7e6374878456b83df13ce0d4d86c9de3ebd7b903659b966bbe7bfe16abc7ed39ba167525d940c8151295adc371afb754a6eb56d0d144840d87101c60e54f9e96c6f66d108c4679545f3d8ed902cf280b5c9da76927655b4dce997b30a70faa9cee26e0dab991e784cab9591b41259743b302be0dfec43e738c1b76cbaf4c424f8862a81a3bc823affb31adf30524e315db7375aae5dcde40f0847103981ded8b86c20e73e54ad90e29fae5405a7bdcf3fdf35dc707ac61c7db9e6c2f3e2ecf8011a8f7ba78cb2830529cfdbb4b544456bfdf194b4f5a14f7aa7f4c61cf4903c41b32fe42b8e12d53f1d19bdd46399f5d8625830f8c75a739b0303713bb9c8423f806a1a7a3eb64acb47b43d4fb0ee701aa4c92c7ce5a3e1b1322cfa41801447c209d7ecf693f6410b1ba6dbdfaef593962ae2a7355e7d9f2a0db48e19fc697b0a2b258b7e9b6b33765f0bafe32d998bf90dd3fb848a3ce8c30f280b7221bf09a2860bf0d8e01e75eba48a306e66a783b92c3acfe61994b1e3751498506b6ff700086dc5db9412606fef2f00bfa209018fadce33a258e729f09247efcb98f647a78a25d4e7254657bf6723ec473ba286758d7e076af51d2588d0b4cb0813d1346b584de1b6d399afaca5606f5e0a5ea8cc4833eb2fbcb1c23613a97e7e7ddd2bc865bfb36dcaacab36dc8c066f1479458a019f48b07cae7acb2c7b42bc6f77482b77a85eedac2f734fe03be4f25e79e55e4d238b51758e70848fa7d9c670b94e990232a2e5b37786a02251392eaa079b0300b5e26d8aaf43126888d220241453a0a686de8b5580c55e7cc64a4fe393e49e42386b68cbb675f6de5b2d88bd95e2ec89b84485cb2f29a3af6cc11f4a20d7a986b5f825f4ddea58328ae34cd92dcd3257ee698bc3ff7b5ea824d88e5542a3003baede1b9b0f1bf5b2265a8309b0f36ba9f510d649766fb9348da199271890d127e5342f61c396cea19ea0903978ff26b9adc6b7168cc35c09d5e36923fbc27ac1ba429690dcf5e06547725e0aa4193945c155301af256cb2bf6c3ea47a386fbbb6a412954ff0c9ef681ebd24097f34643fd03ed57158d2eff660586026d5fc382537a39916a9ca3a009cf09de30e10a31c6e0deb39f88e87346d8e31fbc0586c52c9bedf53ccf13e33bf80bf966d0b5eb56da78f55908c145b36888282f1a0a9db6a199607af8c8ac17a2e859aeb70917252fe5cd6d924dff8ebf6c00e0e8b1cb7d3b2565addb7bd9ec57a8f76d186c5bdb5b4d060de94891f0f486387d3bf67b91cb69cff7013c298f479132b257540611d73935260ea74ac2b9a7eb5761e508ea6d7119c24c2baf61a6f1c511e65b3cef5b20e1ca828f7daaf50dea21da4063652963a8caae50aa257423adfff5996ff6e05b328d5ad0c97063ecde1a029d27071d11e8c3a13de9a5259df5d67140ee816da10797743c3f2f64bf10ca7f25cd7faf79555dd6f1be7f0d16c93c520e99f8a2e5ea8d28f2a52c065f8c946c0b8ec867ecbc4e59e19428b0cc0e4b3c445a0b8788f9394709b8e635fdbc9ac41706e4301a7ceb43179e81a2d711805c1da8dc5bf8b863e5adc16cd65f3781232a6a06b1b25c734836619d00dd182e0cf6ff6d23185127f50bbf990df05699d06ca817c920caef933fad035d2c6473830da257687cc420be01e04538410ef14bc9adc901381d13e4d04f997ceb4e471d0ae38e466c7dba857fa4d43fd2a2f496d7ab8478e4ea6af7c58f08e86214cfead2a155b72c3c9493404affc4cc5c3f3cc3a96b187f8f153f321a3770de37bb34da199f74eb61b7e38a377d34bda871ad9711db5ea7921f3488966b70e1571dde3a56d41a83ab2ca0d97908277032912720287a4caa3bebdb7d26b15eea4133faff3b84104999aecbfa2999188215d3fbb2000a096998b89d80b09adf80935a94aec86c97ea60de49f3fd573f4924b23587905bd9b4c5a305ceeab42eade4b561e9de86043d5b506c38885a26548127adac9866d8055de55847292ebd0fd8f54ad071794f1805694c8886f38e10d147fbadf6258c2f7ba232b6fad247b0fdcd4939151b872b109643a1606102fa21a4f5b3222d9c2ea34502efdfa273749a74de6afd276977b02031c772feeadbeadf65ca348c49ef454568b6c1bc2a2479bb46e632069bf6faa3bbad925d3d3a8206eea771b75eeb6bdf6906a3020a2d8e2d6ba010bf66d29b44ddc79d92b05aaa444d932080318c4242ccbfc17bdcb86fd1ccc651dd6cf3bed26019da605ccd555d524d8995e91dc23c7493d6fb02a876f3b506f478bfe4cde845e5131c379e16405f777cf7335f71fcdb8923d1bb83d27e329cb3811ddc2b8ea010aa68d502244fe8c1406997e232b5f9b57a4b7250cedd38ed1cc67d3b0e64d1f4af22722c5c9a36053a8909e1d8ba483ebb7203c12811f98c2f5d26726a0d183cef1c15909d8616d6e6466055ae10d727d7f1c2ef33a96263156bc6e12bde581c5d29aa9616aa98fd28711dc07e2973756c72f1ce27af75ece3c3d4e42a7aa1ac275aa1e432d16a81c2cd65263b51a9b9c298d3ab3cb1e35ccd1e1ea59de0bc9f5e0dfb0c8e8981b39284765b63f99b2b4ca239aa8216b0b060c72a02ed3bee486c0670f5bd230fc8c36646caca95c73bd09272cd7e0732fdc5bff218d6f92eb2a043dc02ab986ca9e369f26d48957dc178775337f7c9abd3c5e2880a52db3808a4e6bbb208682d5a33275f36b4f1102b62d27034b8b9fb26449534b3a3c060ae5442c3e38df17d0dfff4d01c42190e16e6cb5d1e383108dc582a8c0cdb21e37dc68a2756b943e75b71d22a0b068ecc23845f2f01be76a7a9a1cf9ce7d4d758efac4ae1b39d7c7449972625a259b1e190f7a7790f36d55dad8dbe9e01f9085cfa45e0641663d75abd283373f8bff72f769108747ff8375fae1acc9c93c75c4f78c46bea88669b3d1874785a04165eba203a11ed9e1202dfee5a34d238f7a34674333230451a564d564007ca5bed1fbf8dfe405cacb45fe1e095f8aa3c176861bfc53a26d6fc92b0bcb455436bb9d2ec8fdae3917692290366d2f3187b9c2e8cf31bc69b40b5fe171acc7b0de1226cdcc034d14412e91f20b69b5cbde3b140577fadf55750aa7285849166a563d29f7af95d2350f8f40a7bf57dc20c65f94ec6dd4582ba4cea1411822d8e0c11ed4fb3478e82bcad183e9e48988bd811aa5e8e4007baa26c7a80d57a1a5c8e497e955ae2666a64af256db97be6f79115e5125f3b2b00be3881d67b8a8cf69241cad1947a6e96332b56a541bb7de16dbeb0688e970772cdd036d8616a8c669f0619241683e735a989d6abfb32bdcd775b50078ce0386d0052ff21b603a7205d319449a9244f52ea6d7049aab9f5bd2e9dce6253ff2c737864d5939577eb0286bccbd8653cc35660cb6f38698b80d18462f398173cee58fc3715f2177eccf01078d3dac1cf4b42c7430c63782321ee2c623a3040af42aea620ef09e0922f3481ba8dc6d953caedc15c363276773422375d98e11ab949db3b0dfc86346c08aa25c8d6d020d7163ae478aa54037bc53f2a0fcde19f132ed708c0bf81dc6583db578d4dfbf5a710b8cd312358a6934c95e879ff356879d48f53d00d3fa58681626084a93fc29fc2060c25b38d88de8dcda1f1d2873958e5ff3dd2dbf4bccbee2cfa31550be28c7c5d5c1bd6f2bbe2e035116792e7b554431c4aa073b6fa60a29b65a54e7259f3b9ef462f1ee6e2f600c8e42bf7beeb24702879e0e7345ba0ba41826f2f671d45a698da4e065ec9ae1c24bbe85d2a7d8a1b2ea30b31aed22606f9c84c66bca824d40a4ece9a2761ed79f31257bdbaa4a4a25792dc5ea147967f179f4332b61e0ca6c01f32df0c65cd55d348f0ba0e5517b9dcd74e273aac87d4f59bbc8e6c36743e7e7fadc57d64cc95b13af4b84f8291a4215bf34b961051d9d5a158936102f671944e58ed49431f1c3577641f4e25b3126302b26a0c047633ad96fff379e99dde13b6b42e3956daa3eaba8b421e6cca2d12b594177b37ea99f721f7d9e302cba62ec3aaa8ba69a161f12a9d22893ccc6505b6b9ce80f8791ec2c5d51cb6c421b3070bcd6c9c851749ee6a1d5e189834cf27371eba22cac148f0d8ce07c871f1011a9a306468de243abae77c1ff021a441cc56c3f129424f269eae4d535c58e41bb380e9a296e892ef4cd23cdd997cb795b11440b60f4ee893428ac711ebada36994115b0004ee6f9b7b8f20a7c5756416a688f5f3dc6efa40e1c1951b3ad4530fbbd3e038a3ce4d06f7996c0b66aeef382ecb92bd43d9965564cde7f3172254465b50053d93fed9c1d41fe81f219a9684a2a284bd2bd99f5f7d4215bebf5455b55afa2be2d10c821da2be0a1e0086dc58bb3f57c6f55425023b51ca7585e409acc1a27bba45b8ab21a25971cdca16c9d39f9d20fe562e7ad759bcffe56f9545656c624a3aca18169bde21c090a7fad1839bc3fc6731569f893f8a0ad2e75a54c44441eaf0de67672293e249be33e7aa2f796658e09fb33813cfe2808a8f064cd13bc780d9a7999afc5c076647c264ef9f328e12aae753373af3fab90360a875ba75eadb8bea891b952a3b16b7de88f9dbee13d3e12f9a765e77611d37a3786c6842a958ef51f523cf8720ff49d9f1d3106e498c568c5c747fe1b75bc3a639fff7a65752dfb2258e7cf3949489cc0e2842500bd6f60ddf27b1c1998e274872315c5c6ec3329645706f8237ba28fcafa0dd52b9a4eac0fcb5aa09959556083cecac1c5ee744fc15e2961412ec99743b700d19f46c4b9577787e6ced6ed34ac2eea627928f03e6b83207ca5e27f687d74f22a0b533a5babacc3fb11b35332b0a2c7fbfd19bce7722d7c8498b3f01ff744a3f4d76e7108987959c09b7eec1cd82f55d076d96fa3d383ddacb52bd4d751a8f30e2ac1f5e25c7a5e5247a22f224960aa16776a69fbe20dea3cbad49a3ea0486006fcbd8213a6e7157c291cfc158edd84c10727a956e4074dff51bb6e887c24e94c4d5c79afdbf86f13e53ee5d7977cd57e0ab6f29a1751f6c5d07ed000f54abe206977a09db1f39d00b15ff4a7b326833c29203c97f6a96089f39d572e0d5c11ce5940d42e77d78285f30633e931a8b8a10d9b1cd60586d55d2023e27ca030db9159c45e9571a13b1e05ce6ec42aad6807f9ba9d96ad329656749a853d9d502d5cfbbc53c2939564c1e7a3a35d667dba01aa1a8f9c743e3c8138f14a66bda086fa1b97dc5d79529f781d568ec7d1df2634238f53e54ba0725f2d83363bd8ac3fa4ecfb763ea9dfbef0888c157fc2794a2418a17cc90f6dfdc2f53bef9f273f8f059673dd93496d8ed7aac2a1da5ea051b8a87dda67f0e167800cabe186809673a5204dad2d71774e18f83c9cda507acf1a12e3696f976db4342ce9d6284a9cdc2dfbdaa7718a6f6b98e03e46824b36fa3a4e473b787d4d6e42b7e899628080ffd9" + ) + dg2 = ef.DG2.load(tv_dg2) + assert dg2.dump() == tv_dg2 + assert dg2.fingerprint == "A9A1B09DFD598087" + assert dg2.tag == 21 + assert dg2.number == DataGroupNumber(2) + assert dg2.portrait.nr_images == 1 + assert dg2.portrait.version_number == 808529920 + assert dg2.portrait.length_of_record == 15045 + assert dg2.portrait.number_of_facial_images == 1 + assert dg2.portrait.facial_record_data_length == 15031 + assert dg2.portrait.nr_feature_points == 0 + assert dg2.portrait.gender == 0 + assert dg2.portrait.eye_color == 0 + assert dg2.portrait.hair_color == 0 + assert dg2.portrait.feature_mask == 0 + assert dg2.portrait.expression == 0 + assert dg2.portrait.pose_angle == 0 + assert dg2.portrait.pose_angle_uncertainty == 0 + assert dg2.portrait.face_image_type == 1 + assert dg2.portrait.image_data_type == 1 + assert dg2.portrait.image_width == 337 + assert dg2.portrait.image_height == 449 + assert dg2.portrait.image_color_space == 0 + assert dg2.portrait.source_type == 0 + assert dg2.portrait.device_type == 0 + assert dg2.portrait.quality == 0 + assert ( + dg2.portrait.image_data + == "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" + ) From a0f60eed02600c957434723c4a534ad3ff6ed478 Mon Sep 17 00:00:00 2001 From: arosasg Date: Tue, 21 May 2024 17:17:19 +0200 Subject: [PATCH 3/6] fix cryptography version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a10bfbe..c90f612 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ python_requires=">=3.9.11", install_requires=[ 'asn1crypto>=1.5.1', - 'cryptography>=41.0.3' + 'cryptography==41.0.3' ], extras_require={ 'tests': [ From 9884cf237b6e317296974e2672abfe2a905b4b12 Mon Sep 17 00:00:00 2001 From: arosasg Date: Tue, 21 May 2024 21:03:43 +0200 Subject: [PATCH 4/6] fix error for NFCPassportReader exception --- src/pymrtd/ef/dg2.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pymrtd/ef/dg2.py b/src/pymrtd/ef/dg2.py index a1b5895..cfdce0f 100644 --- a/src/pymrtd/ef/dg2.py +++ b/src/pymrtd/ef/dg2.py @@ -77,12 +77,7 @@ def parse_iso19794_5(self, data: bytes): if not ( data[0] == 0x46 and data[1] == 0x41 and data[2] == 0x43 and data[3] == 0x00 ): - raise NFCPassportReaderError( - "InvalidResponse", - datagroup_id=self.datagroup_type, - expected_tag=0x46, - actual_tag=int(data[0]), - ) + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_DATA) offset = 4 self.version_number = self.bin_to_int(data, offset, 4) From 0aec2d3cd19060ba29c474f9f1887df3065f1111 Mon Sep 17 00:00:00 2001 From: arosasg Date: Wed, 22 May 2024 06:44:21 +0200 Subject: [PATCH 5/6] fix unittests --- tests/ef/dg2_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ef/dg2_test.py b/tests/ef/dg2_test.py index 934a9da..fe34dc1 100644 --- a/tests/ef/dg2_test.py +++ b/tests/ef/dg2_test.py @@ -1,3 +1,5 @@ +import base64 + import pytest from pymrtd import ef from pymrtd.ef.dg import * @@ -45,6 +47,6 @@ def test_dg2(): assert dg2.portrait.device_type == 0 assert dg2.portrait.quality == 0 assert ( - dg2.portrait.image_data + base64.b64encode(bytes(dg2.portrait.image_data)).decode("utf-8") == "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" ) From eb2a1dfc10d80fd5b09212e49b2584b798a2b3bb Mon Sep 17 00:00:00 2001 From: arosasg Date: Wed, 22 May 2024 17:47:55 +0200 Subject: [PATCH 6/6] added dg7 and dg11 --- src/pymrtd/ef/__init__.py | 33 +- src/pymrtd/ef/base.py | 106 ++- src/pymrtd/ef/dg.py | 219 ++---- src/pymrtd/ef/dg1.py | 268 ++++++++ src/pymrtd/ef/dg11.py | 83 +++ src/pymrtd/ef/dg14.py | 127 ++++ src/pymrtd/ef/dg15.py | 23 + src/pymrtd/ef/dg2.py | 63 +- src/pymrtd/ef/dg7.py | 50 ++ src/pymrtd/ef/mrz.py | 228 ------- src/pymrtd/ef/sod.py | 156 +++-- tests/ef/dg11_test.py | 21 + tests/ef/dg14_test.py | 100 ++- tests/ef/mrz_test.py | 474 +++++++------ tests/ef/sod_test.py | 1332 +++++++++++++++++++++++++------------ 15 files changed, 2045 insertions(+), 1238 deletions(-) create mode 100644 src/pymrtd/ef/dg1.py create mode 100644 src/pymrtd/ef/dg11.py create mode 100644 src/pymrtd/ef/dg14.py create mode 100644 src/pymrtd/ef/dg15.py create mode 100644 src/pymrtd/ef/dg7.py delete mode 100644 src/pymrtd/ef/mrz.py create mode 100644 tests/ef/dg11_test.py diff --git a/src/pymrtd/ef/__init__.py b/src/pymrtd/ef/__init__.py index c41b658..d5e7d3b 100644 --- a/src/pymrtd/ef/__init__.py +++ b/src/pymrtd/ef/__init__.py @@ -1,28 +1,31 @@ from .base import ElementaryFile, ElementaryFileError, LDSVersionInfo - -from .dg import DataGroup, DataGroupNumber, DG1, DG2, DG14, DG15 - -from .mrz import MachineReadableZone - -from .dg2 import DataGroup2 - -from .sod import SOD, SODError - +from .dg import DataGroup, DataGroupNumber +from .dg1 import DG1, DataGroup1 +from .dg2 import DG2, DataGroup2 +from .dg7 import DG7, DataGroup7 +from .dg11 import DG11, DataGroup11 +from .dg14 import DG14 +from .dg15 import DG15 from .errors import NFCPassportReaderError +from .sod import SOD, SODError __all__ = [ + "ElementaryFile", + "ElementaryFileError", + "LDSVersionInfo", "DataGroup", "DataGroupNumber", + "DataGroup1", "DG1", + "DataGroup2", "DG2", + "DataGroup7", + "DG7", + "DataGroup11", + "DG11", "DG14", "DG15", - "ElementaryFile", - "ElementaryFileError", - "LDSVersionInfo", - "MachineReadableZone", - "DataGroup2", + "NFCPassportReaderError", "SOD", "SODError", - "NFCPassportReaderError", ] diff --git a/src/pymrtd/ef/base.py b/src/pymrtd/ef/base.py index 6a29266..da10c15 100644 --- a/src/pymrtd/ef/base.py +++ b/src/pymrtd/ef/base.py @@ -1,41 +1,68 @@ import hashlib + import asn1crypto.core as asn1 import asn1crypto.parser as asn1Parser class LDSVersionInfo(asn1.Sequence): _fields = [ - ('ldsVersion', asn1.PrintableString), - ('unicodeVersion', asn1.PrintableString), + ("ldsVersion", asn1.PrintableString), + ("unicodeVersion", asn1.PrintableString), ] + class ElementaryFileError(ValueError): pass + class ElementaryFile(asn1.Asn1Value): _content_spec = None _str_rep = None - def __init__(self, explicit=None, implicit=None, no_explicit=False, tag_type=None, class_=None, tag=None, - optional=None, default=None, contents=None, method=None, spec=None): + def __init__( + self, + explicit=None, + implicit=None, + no_explicit=False, + tag_type=None, + class_=None, + tag=None, + optional=None, + default=None, + contents=None, + method=None, + spec=None, + ): if spec: self._content_spec = spec - super().__init__(explicit=explicit, implicit=implicit, no_explicit=no_explicit, tag_type=tag_type, class_=class_, tag=tag, - optional=optional, default=default, contents=contents, method=method) + super().__init__( + explicit=explicit, + implicit=implicit, + no_explicit=no_explicit, + tag_type=tag_type, + class_=class_, + tag=tag, + optional=optional, + default=default, + contents=contents, + method=method, + ) self._content = None - self._fp = None + self._fp = None def __str__(self): """ Returns string representation of self i.e. EF(fp=XXXXXXXXXXXXXXXX) """ if self._str_rep is None: - self._str_rep = f'EF(fp={self.fingerprint})' + self._str_rep = f"EF(fp={self.fingerprint})" return self._str_rep @classmethod - def load(cls, encoded_data: bytes, strict=False): #pylint: disable=arguments-differ - ''' + def load( + cls, encoded_data: bytes, strict=False + ): # pylint: disable=arguments-differ + """ Loads a BER/DER-encoded byte string using the current class as the spec :param encoded_data: A byte string of BER or DER encoded data @@ -44,29 +71,35 @@ def load(cls, encoded_data: bytes, strict=False): #pylint: disable=arguments-dif ValueError will be raised when trailing data exists :return: A instance of the current class - ''' + """ - class_, method, tag, header, contents, trailer = asn1Parser.parse(encoded_data, strict=strict) #pylint: disable=unused-variable + class_, method, tag, header, contents, trailer = asn1Parser.parse( + encoded_data, strict=strict + ) # pylint: disable=unused-variable value = cls(class_=class_, tag=tag, method=method, contents=contents) if cls.class_ is not None and value.class_ != cls.class_: - raise ElementaryFileError("Invalid elementary file class, expected class '{}' got '{}'" - .format( + raise ElementaryFileError( + "Invalid elementary file class, expected class '{}' got '{}'".format( asn1.CLASS_NUM_TO_NAME_MAP.get(cls.class_, cls.class_), - asn1.CLASS_NUM_TO_NAME_MAP.get(value.class_, value.class_) - )) + asn1.CLASS_NUM_TO_NAME_MAP.get(value.class_, value.class_), + ) + ) if cls.method is not None and value.method != cls.method: - raise ElementaryFileError("Invalid elementary file method , expected method '{}' got '{}'" - .format( + raise ElementaryFileError( + "Invalid elementary file method , expected method '{}' got '{}'".format( asn1.METHOD_NUM_TO_NAME_MAP.get(cls.method, cls.method), - asn1.METHOD_NUM_TO_NAME_MAP.get(value.method, value.method) - )) + asn1.METHOD_NUM_TO_NAME_MAP.get(value.method, value.method), + ) + ) if cls.tag is not None and value.tag != cls.tag: - raise ElementaryFileError(f"Invalid elementary file tag, expected tag '{cls.tag}' got '{value.tag}'") + raise ElementaryFileError( + f"Invalid elementary file tag, expected tag '{cls.tag}' got '{value.tag}'" + ) # Force parsing of content. This is done in order for any invalid content to raise an exception - value.content #pylint: disable=pointless-statement + value.content # pylint: disable=pointless-statement return value @property @@ -76,23 +109,23 @@ def fingerprint(self) -> str: """ if self._fp is None: d = hashlib.sha256(self.dump()).digest() - self._fp = d[0:8].hex().upper().rjust(16, '0') + self._fp = d[0:8].hex().upper().rjust(16, "0") return self._fp @property def content(self): - ''' Returns content object of a type content_type ''' + """Returns content object of a type content_type""" if self._content is None: self._parse_content() return self._content @property def native(self): - ''' + """ The native Python data type representation of this value :return: A native representation of content object or None. - ''' + """ if self.contents is None: return None @@ -102,12 +135,12 @@ def native(self): return self.content.native def _parse_content(self): - ''' + """ Parses the contents and generates Asn1Value content objects based on the definitions from _content_spec. :raises: ValueError - when an error occurs parsing content object - ''' + """ self._content = None if self.contents is None: @@ -115,15 +148,22 @@ def _parse_content(self): if self._content_spec is not None: if not issubclass(self._content_spec, asn1.Asn1Value): - raise ValueError(f'_content_spec must be of a Ans1Value type, not {self._content_spec!r}') + raise ValueError( + f"_content_spec must be of a Ans1Value type, not {self._content_spec!r}" + ) try: self._content = self._content_spec.load(self.contents, strict=True) if isinstance(self._content, (asn1.Sequence, asn1.SequenceOf)): - self._content._parse_children(recurse=True) #pylint: disable=protected-access + self._content._parse_children( + recurse=True + ) # pylint: disable=protected-access except (ValueError, TypeError) as e: - from asn1crypto._types import type_name #pylint: disable=import-outside-toplevel + from asn1crypto._types import ( + type_name, + ) # pylint: disable=import-outside-toplevel + self._content = None - args = e.args[1:] - e.args = (e.args[0] + f'\n while parsing {type_name(self)}',) + args + args = e.args[1:] + e.args = (e.args[0] + f"\n while parsing {type_name(self)}",) + args raise diff --git a/src/pymrtd/ef/dg.py b/src/pymrtd/ef/dg.py index dbb5a2b..bd14c72 100644 --- a/src/pymrtd/ef/dg.py +++ b/src/pymrtd/ef/dg.py @@ -1,104 +1,8 @@ import asn1crypto.core as asn1 from asn1crypto.util import int_from_bytes -from asn1crypto.keys import PublicKeyInfo -from pymrtd.pki import keys, oids -from typing import Union # pylint: disable=wrong-import-order from .base import ElementaryFile -from .mrz import MachineReadableZone -from .dg2 import DataGroup2 - - -class ActiveAuthenticationInfoId(asn1.ObjectIdentifier): - _map = { - oids.id_icao_mrtd_security_aaProtocolObject: "aa_info", - } - - -class ActiveAuthenticationInfo(asn1.Sequence): - _fields = [ - ("protocol", ActiveAuthenticationInfoId), - ("version", asn1.Integer), - ("signature_algorithm", keys.SignatureAlgorithmId), - ] - - -class ChipAuthenticationInfoId(asn1.ObjectIdentifier): - _map = { - oids.id_CA_DH_3DES_CBC_CBC: "ca_dh_3des_cbc_cbc", - oids.id_CA_DH_AES_CBC_CMAC_128: "ca_dh_aes_cbc_cmac_128", - oids.id_CA_DH_AES_CBC_CMAC_192: "ca_dh_aes_cbc_cmac_192", - oids.id_CA_DH_AES_CBC_CMAC_256: "ca_dh_aes_cbc_cmac_256", - oids.id_CA_ECDH_3DES_CBC_CBC: "ca_ecdh_3des_cbc_cbc", - oids.id_CA_ECDH_AES_CBC_CMAC_128: "ca_ecdh_aes_cbc_cmac_128", - oids.id_CA_ECDH_AES_CBC_CMAC_192: "ca_ecdh_aes_cbc_cmac_192", - oids.id_CA_ECDH_AES_CBC_CMAC_256: "ca_ecdh_aes_cbc_cmac_256", - } - - -class ChipAuthenticationInfo(asn1.Sequence): - _fields = [ - ("protocol", ChipAuthenticationInfoId), - ("version", asn1.Integer), - ("key_id", asn1.Integer, {"optional": True}), - ] - - -class ChipAuthenticationPublicKeyInfoId(asn1.ObjectIdentifier): - _map = {oids.id_PK_DH: "pk_dh", oids.id_PK_ECDH: "pk_ecdh"} - - -class ChipAuthenticationPublicKeyInfo(asn1.Sequence): - _fields = [ - ("protocol", ChipAuthenticationPublicKeyInfoId), - ("chip_auth_public_key", PublicKeyInfo), - ("key_id", asn1.Integer, {"optional": True}), - ] - - -class DefaultSecurityInfo(asn1.Sequence): - _fields = [ - ("protocol", asn1.ObjectIdentifier), - ("required_data", asn1.Any), - ("optional", asn1.Any, {"optional": True}), - ] - - -class SecurityInfo(asn1.Choice): - _alternatives = [ - ("security_info", DefaultSecurityInfo), - ("aa_info", ActiveAuthenticationInfo), - ("chip_auth_info", ChipAuthenticationInfo), - ("chip_auth_pub_key_info", ChipAuthenticationPublicKeyInfo), - # Note: Missing PACEDomainParameterInfo and PACEInfo - ] - - def validate(self, class_, tag, contents): - """this function select proper SecurityInfo choice index based on OID""" - oid = asn1.ObjectIdentifier.load(contents).dotted - - self._choice = 0 - for index, info in enumerate(self._alternatives): - toidm = info[1]._fields[0][1]._map # pylint: disable=protected-access - if toidm is not None and oid in toidm: - self._choice = index - return - - def parse(self): - if self._parsed is None: - super().parse() - if self.name == "aa_info" or self.name == "chip_auth_info": - if self._parsed["version"].native != 1: - from asn1crypto._types import ( - type_name, - ) # pylint: disable=import-outside-toplevel - - raise ValueError(f"{type_name(self._parsed)} version != 1") - return self._parsed - - -class SecurityInfos(asn1.SetOf): - _child_spec = SecurityInfo +from .errors import NFCPassportReaderError class DataGroupNumber(asn1.Integer): @@ -170,73 +74,54 @@ def __str__(self): def number(self) -> DataGroupNumber: return DataGroupNumber(self.tag) - -class DG1(DataGroup): - tag = 1 - _content_spec = MachineReadableZone - - @property - def mrz(self) -> MachineReadableZone: - return self.content - - @property - def native(self): - return {"mrz": self.mrz.native} - - -class DG2(DataGroup): - tag = 21 - _content_spec = DataGroup2 - - @property - def portrait(self) -> DataGroup2: - return self.content - - @property - def native(self): - return {"portrait": self.portrait} - - -class DG14(DataGroup): - tag = 14 - _content_spec = SecurityInfos - - @property - def aaInfo(self) -> Union[ActiveAuthenticationInfo, None]: - """Returns ActiveAuthenticationInfo if in list otherwise None.""" - - # Loop over list of SecurityInfo objects and try to find ActiveAuthentication object - # Should contain only one ActiveAuthenticationInfo - for si in self.content: - if isinstance(si.chosen, ActiveAuthenticationInfo): - return si - return None - - @property - def aaSignatureAlgo(self) -> keys.SignatureAlgorithm: - """Returns SignatureAlgorithm object or None if DG doesn't contain one.""" - - aai = self.aaInfo - if aai is None: - return None - - # Get signature algorithm - return keys.SignatureAlgorithm({"algorithm": aai.native["signature_algorithm"]}) - - -class DG15(DataGroup): - tag = 15 - _content_spec = PublicKeyInfo - _aakey: keys.AAPublicKey - - @property - def aaPublicKeyInfo(self) -> PublicKeyInfo: - """Returns active authentication public key info""" - return self.content - - @property - def aaPublicKey(self) -> keys.AAPublicKey: - """Returns active authentication public key""" - if not hasattr(self, "_aakey"): - self._aakey = keys.AAPublicKey.load(self.aaPublicKeyInfo.dump()) - return self._aakey + def get_next_tag(self) -> int: + tag = 0 + + # Fix for some passports that may have invalid data - ensure that we do have data! + if len(self.data) <= self.pos: + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_DATA) + + if self.bin_to_hex(self.data[self.pos : self.pos + 1]) & 0x0F == 0x0F: + tag = self.bin_to_hex(self.data[self.pos : self.pos + 2]) + self.pos += 2 + else: + tag = self.data[self.pos] + self.pos += 1 + + return tag + + def verify_tag(self, tag, valid_values): + if isinstance(valid_values, list): + if tag not in valid_values: + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_TAG) + else: + if tag != valid_values: + raise NFCPassportReaderError("InvalidTag") + + def asn1_length(self, data: bytes) -> tuple: + if data[0] < 0x80: + return int(data[0]), 1 + if data[0] == 0x81: + return int(data[1]), 2 + if data[0] == 0x82: + val = int.from_bytes(data[1:3], byteorder="big") + return val, 3 + raise NFCPassportReaderError(NFCPassportReaderError.INVALID_LENGTH) + + def get_next_length(self) -> int: + end = self.pos + 4 if self.pos + 4 < len(self.data) else len(self.data) + length, len_offset = self.asn1_length(self.data[self.pos : end]) + self.pos += len_offset + return length + + def get_next_value(self) -> bytes: + length = self.get_next_length() + value = self.data[self.pos : self.pos + length] + self.pos += length + return value + + def bin_to_int(self, data: bytes, offset: int, length: int) -> int: + return int.from_bytes(data[offset : offset + length], byteorder="big") + + def bin_to_hex(self, data: bytes) -> int: + return int.from_bytes(data, byteorder="big") diff --git a/src/pymrtd/ef/dg1.py b/src/pymrtd/ef/dg1.py new file mode 100644 index 0000000..ce32f20 --- /dev/null +++ b/src/pymrtd/ef/dg1.py @@ -0,0 +1,268 @@ +from datetime import date, datetime, timedelta +from enum import Enum +from typing import Optional + +import asn1crypto.core as asn1 + +from .dg import DataGroup + + +class DocumentType(Enum): + """ + Enumeration of possible mayjor document types. + """ + + Passport = "P" # DOC ICAO 9303-p4 4.2.2 specifies a capital letter 'P' as document code to define machine readable passport (MRP). + # One additional capital letter can follow 'P' at the discretion of the issuing State or organization, to designate + # other types of passports such as MRP issued to diplomatic staff, an MRP issued for travel on government business, or a passport issued for a special purpose. + # + # Additional note: From doc ICAO 9303-p4 4.2.2.1 notes 'm': + # "In documents other than passports, e.g. United Nations laissez-passer, seafarer’s identity document + # or refugee travel document, the official title of the document shall be indicated instead of “Passport”. + # However, the first character of the document code shall be P" + + +class DataGroup1(asn1.OctetString): + class_ = 1 + tag = 31 + _parsed = None + _type = None + + @classmethod + def load(cls, encoded_data: bytes, strict=False, **kwargs): + v: DataGroup1 = super().load(encoded_data, strict, **kwargs) + clen = len(v.contents) + # pylint: disable=protected-access + if clen == 90: + v._type = "td1" + elif clen == 72: + v._type = "td2" + elif clen == 88: + v._type = "td3" + else: + raise ValueError("Unknown MRZ type") + return v + + def __getitem__(self, key): + return self.native[key] + + @property + def country(self) -> str: # Issuing country + return self["country"] + + @property + def dateOfBirth(self) -> Optional[date]: # Could be None if date is not known + return self["date_of_birth"] + + @property + def dateOfExpiry(self) -> date: + return self["date_of_expiry"] + + @property + def documentCode(self) -> str: + return self["document_code"] + + @property + def documentNumber(self) -> str: + return self["document_number"] + + @property + def gender(self) -> str: + return self["gender"] + + @property + def name(self) -> str: + ni = self["name_identifiers"] + if len(ni) > 1: + return ni[-1] + return "" + + @property + def nationality(self) -> str: + return self["nationality"] + + @property + def native(self): + if self._parsed is None: + self.parse() + return self._parsed + + @property + def additionalData(self) -> str: + if self.type == "td1": + return ( + self["optional_data_1"] + if len(self["optional_data_1"]) + else self["optional_data_2"] + ) + return self["optional_data"] + + @property + def surname(self) -> str: + ni = self["name_identifiers"] + if len(ni) > 0: + return ni[0] + return "" + + @property + def type(self) -> str: + return self._type + + def toJson(self) -> dict: + return { + "type": self.type, + "doc_code": self.documentCode, + "doc_number": self.documentNumber, + "date_of_expiry": self.dateOfExpiry, + "surname": self.surname, + "name": self.name, + "date_of_birth": self.dateOfBirth, + "gender": self.gender, + "country": self.country, + "nationality": self.nationality, + "additional_data": self.additionalData, + } + + def parse(self): + self._parsed = {} + if self.type == "td1": + self._parse_td1() + elif self.type == "td2": + self._parse_td2() + elif self.type == "td3": + self._parse_td3() + else: + raise ValueError("Cannot parse unknown MRZ type") + + def _parse_td1(self): + self._parsed["document_code"] = self._read(0, 2) + self._parsed["country"] = self._read(2, 3) + self._parsed["document_number"] = self._read(5, 9) + self._parsed["document_number_cd"] = self._read_with_filter( + 14, 1 + ) # document number check digit, could be char '<' + self._parsed["optional_data_1"] = self._read(15, 15) + self._parsed["date_of_birth"] = self._read_date_of_birth(30, 6) + self._parsed["date_of_birth_cd"] = self._read_cd(36) # document dob check digit + self._parsed["gender"] = self._read(37, 1) + self._parsed["date_of_expiry"] = self._read_date_of_expiry(38, 6) + self._parsed["date_of_expiry_cd"] = self._read_cd( + 44 + ) # document doe check digit + self._parsed["nationality"] = self._read(45, 3) + self._parsed["optional_data_2"] = self._read(48, 11) + self._parsed["composite_cd"] = self._read_cd(59) + self._parsed["name_identifiers"] = self._read_name_identifiers(60, 30) + self._parseExtendedDocumentNumber() + + def _parse_td2(self): + self._parsed["document_code"] = self._read(0, 2) + self._parsed["country"] = self._read(2, 3) + self._parsed["name_identifiers"] = self._read_name_identifiers(5, 31) + self._parsed["document_number"] = self._read(36, 9) + self._parsed["document_number_cd"] = self._read_with_filter( + 45, 1 + ) # document number check digit + self._parsed["nationality"] = self._read(46, 3) + self._parsed["date_of_birth"] = self._read_date_of_birth(49, 6) + self._parsed["date_of_birth_cd"] = self._read_cd(55) # document dob check digit + self._parsed["gender"] = self._read(56, 1) + self._parsed["date_of_expiry"] = self._read_date_of_expiry(57, 6) + self._parsed["date_of_expiry_cd"] = self._read_cd( + 63 + ) # document doe check digit + self._parsed["optional_data"] = self._read(64, 7) + self._parsed["composite_cd"] = self._read_cd(71) + self._parseExtendedDocumentNumber() + + def _parse_td3(self): + self._parsed["document_code"] = self._read(0, 2) + self._parsed["country"] = self._read(2, 3) + self._parsed["name_identifiers"] = self._read_name_identifiers(5, 39) + self._parsed["document_number"] = self._read(44, 9) + self._parsed["document_number_cd"] = self._read_cd( + 53 + ) # document number check digit + self._parsed["nationality"] = self._read(54, 3) + self._parsed["date_of_birth"] = self._read_date_of_birth(57, 6) + self._parsed["date_of_birth_cd"] = self._read_cd(63) # document dob check digit + self._parsed["gender"] = self._read(64, 1) + self._parsed["date_of_expiry"] = self._read_date_of_expiry(65, 6) + self._parsed["date_of_expiry_cd"] = self._read_cd( + 71 + ) # document doe check digit + self._parsed["optional_data"] = self._read(72, 14) + self._parsed["optional_data_cd"] = self._read_cd(86) + self._parsed["composite_cd"] = self._read_cd(87) + + def _parseExtendedDocumentNumber(self): + # doc 9303 p10 page 30 + fn_opt_data = "optional_data_1" if self.type == "td1" else "optional_data" + if ( + self._parsed["document_number_cd"] == "<" + and len(self._parsed[fn_opt_data]) > 0 + ): + self._parsed["document_number"] += self._parsed[fn_opt_data][:-1] + self._parsed["document_number_cd"] = self._parsed[fn_opt_data][-1] + self._parsed[fn_opt_data] = "" + + def _read_with_filter(self, idx, len): + return self.contents[idx : idx + len].decode("ascii") + + def _read(self, idx, len): + return self._read_with_filter(idx, len).rstrip("<") + + def _read_cd(self, idx) -> int: + scd = self._read_with_filter(idx, 1) + if scd == "<": + return 0 + try: + return int(scd) + except ValueError: + raise ValueError( + f"Invalid check digit character '{scd}' in MRZ at position {idx}" + ) + + def _read_date(self, idx, len): + date = self._read_with_filter(idx, len) + if "<" in date: # In case of unknown date of birth + return None + try: + return datetime.strptime(date.rstrip("<"), "%y%m%d").date() + except ValueError: + raise ValueError(f"Invalid date format '{date}' in MRZ at position {idx}") + + def _read_date_of_birth(self, idx, len): + date = self._read_date(idx, len) + if ( + date is not None and date > datetime.today().date() + ): # reduce date for 100 years if greater then current date + days_per_year = 365.25 + date -= timedelta(days=(100 * days_per_year)) + return date + + def _read_date_of_expiry(self, idx, len): + date = self._read_date(idx, len) + if date is None: + raise ValueError("Invalid date of expiry in MRZ") + return date + + def _read_name_identifiers(self, idx, size): + name_field = self._read(idx, size) + ids = name_field.split("<<") + for i in range(0, len(ids)): + ids[i] = ids[i].replace("<", " ") + return tuple(ids) + + +class DG1(DataGroup): + tag = 1 + _content_spec = DataGroup1 + + @property + def mrz(self) -> DataGroup1: + return self.content + + @property + def native(self): + return {"mrz": self.mrz.native} diff --git a/src/pymrtd/ef/dg11.py b/src/pymrtd/ef/dg11.py new file mode 100644 index 0000000..1593274 --- /dev/null +++ b/src/pymrtd/ef/dg11.py @@ -0,0 +1,83 @@ +import asn1crypto.core as asn1 + +from .dg import DataGroup + + +class DataGroup11(asn1.OctetString, DataGroup): + class_ = 11 + tag = 11 + + def __init__(self, contents=None, **kwargs): + self.full_name = "" + self.personal_number = "" + self.date_of_birth = "" + self.place_of_birth = "" + self.address = "" + self.telephone = "" + self.profession = "" + self.title = "" + self.personal_summary = "" + self.proof_of_citizenship = "" + self.td_numbers = "" + self.custody_info = "" + + self.data = contents + self.pos = 0 + self.body = self.data[self.pos :] + + super().__init__(contents=contents, **kwargs) + + @property + def datagroup_type(self): + return "DG11" + + @classmethod + def load(cls, contents: bytes, strict=True): + instance = cls(contents=contents) + instance.parse() + return instance + + def parse(self): + tag = self.get_next_tag() + self.verify_tag(tag, 0x5C) + _ = self.get_next_value() + + while self.pos < len(self.data): + tag = self.get_next_tag() + value = self.get_next_value() + + if tag == 0x5F0E: + self.full_name = value.decode("utf-8") + elif tag == 0x5F10: + self.personal_number = value.decode("utf-8") + elif tag == 0x5F11: + self.place_of_birth = value.decode("utf-8") + elif tag == 0x5F42: + self.address = value.decode("utf-8") + elif tag == 0x5F12: + self.telephone = value.decode("utf-8") + elif tag == 0x5F13: + self.profession = value.decode("utf-8") + elif tag == 0x5F14: + self.title = value.decode("utf-8") + elif tag == 0x5F15: + self.personal_summary = value.decode("utf-8") + elif tag == 0x5F16: + self.proof_of_citizenship = value.decode("utf-8") + elif tag == 0x5F17: + self.td_numbers = value.decode("utf-8") + elif tag == 0x5F18: + self.custody_info = value.decode("utf-8") + + +class DG11(DataGroup): + tag = 11 + _content_spec = DataGroup11 + + @property + def personal_info(self) -> DataGroup11: + return self.content + + @property + def native(self): + return {"personal_info": self.personal_info} diff --git a/src/pymrtd/ef/dg14.py b/src/pymrtd/ef/dg14.py new file mode 100644 index 0000000..574b307 --- /dev/null +++ b/src/pymrtd/ef/dg14.py @@ -0,0 +1,127 @@ +from typing import Union + +import asn1crypto.core as asn1 +from asn1crypto.keys import PublicKeyInfo + +from pymrtd.pki import keys, oids + +from .dg import DataGroup + + +class ActiveAuthenticationInfoId(asn1.ObjectIdentifier): + _map = { + oids.id_icao_mrtd_security_aaProtocolObject: "aa_info", + } + + +class ActiveAuthenticationInfo(asn1.Sequence): + _fields = [ + ("protocol", ActiveAuthenticationInfoId), + ("version", asn1.Integer), + ("signature_algorithm", keys.SignatureAlgorithmId), + ] + + +class ChipAuthenticationInfoId(asn1.ObjectIdentifier): + _map = { + oids.id_CA_DH_3DES_CBC_CBC: "ca_dh_3des_cbc_cbc", + oids.id_CA_DH_AES_CBC_CMAC_128: "ca_dh_aes_cbc_cmac_128", + oids.id_CA_DH_AES_CBC_CMAC_192: "ca_dh_aes_cbc_cmac_192", + oids.id_CA_DH_AES_CBC_CMAC_256: "ca_dh_aes_cbc_cmac_256", + oids.id_CA_ECDH_3DES_CBC_CBC: "ca_ecdh_3des_cbc_cbc", + oids.id_CA_ECDH_AES_CBC_CMAC_128: "ca_ecdh_aes_cbc_cmac_128", + oids.id_CA_ECDH_AES_CBC_CMAC_192: "ca_ecdh_aes_cbc_cmac_192", + oids.id_CA_ECDH_AES_CBC_CMAC_256: "ca_ecdh_aes_cbc_cmac_256", + } + + +class ChipAuthenticationInfo(asn1.Sequence): + _fields = [ + ("protocol", ChipAuthenticationInfoId), + ("version", asn1.Integer), + ("key_id", asn1.Integer, {"optional": True}), + ] + + +class ChipAuthenticationPublicKeyInfoId(asn1.ObjectIdentifier): + _map = {oids.id_PK_DH: "pk_dh", oids.id_PK_ECDH: "pk_ecdh"} + + +class ChipAuthenticationPublicKeyInfo(asn1.Sequence): + _fields = [ + ("protocol", ChipAuthenticationPublicKeyInfoId), + ("chip_auth_public_key", PublicKeyInfo), + ("key_id", asn1.Integer, {"optional": True}), + ] + + +class DefaultSecurityInfo(asn1.Sequence): + _fields = [ + ("protocol", asn1.ObjectIdentifier), + ("required_data", asn1.Any), + ("optional", asn1.Any, {"optional": True}), + ] + + +class SecurityInfo(asn1.Choice): + _alternatives = [ + ("security_info", DefaultSecurityInfo), + ("aa_info", ActiveAuthenticationInfo), + ("chip_auth_info", ChipAuthenticationInfo), + ("chip_auth_pub_key_info", ChipAuthenticationPublicKeyInfo), + # Note: Missing PACEDomainParameterInfo and PACEInfo + ] + + def validate(self, class_, tag, contents): + """this function select proper SecurityInfo choice index based on OID""" + oid = asn1.ObjectIdentifier.load(contents).dotted + + self._choice = 0 + for index, info in enumerate(self._alternatives): + toidm = info[1]._fields[0][1]._map # pylint: disable=protected-access + if toidm is not None and oid in toidm: + self._choice = index + return + + def parse(self): + if self._parsed is None: + super().parse() + if self.name == "aa_info" or self.name == "chip_auth_info": + if self._parsed["version"].native != 1: + from asn1crypto._types import ( + type_name, + ) # pylint: disable=import-outside-toplevel + + raise ValueError(f"{type_name(self._parsed)} version != 1") + return self._parsed + + +class SecurityInfos(asn1.SetOf): + _child_spec = SecurityInfo + + +class DG14(DataGroup): + tag = 14 + _content_spec = SecurityInfos + + @property + def aaInfo(self) -> Union[ActiveAuthenticationInfo, None]: + """Returns ActiveAuthenticationInfo if in list otherwise None.""" + + # Loop over list of SecurityInfo objects and try to find ActiveAuthentication object + # Should contain only one ActiveAuthenticationInfo + for si in self.content: + if isinstance(si.chosen, ActiveAuthenticationInfo): + return si + return None + + @property + def aaSignatureAlgo(self) -> keys.SignatureAlgorithm: + """Returns SignatureAlgorithm object or None if DG doesn't contain one.""" + + aai = self.aaInfo + if aai is None: + return None + + # Get signature algorithm + return keys.SignatureAlgorithm({"algorithm": aai.native["signature_algorithm"]}) diff --git a/src/pymrtd/ef/dg15.py b/src/pymrtd/ef/dg15.py new file mode 100644 index 0000000..285f4de --- /dev/null +++ b/src/pymrtd/ef/dg15.py @@ -0,0 +1,23 @@ +from asn1crypto.keys import PublicKeyInfo + +from pymrtd.pki import keys + +from .dg import DataGroup + + +class DG15(DataGroup): + tag = 15 + _content_spec = PublicKeyInfo + _aakey: keys.AAPublicKey + + @property + def aaPublicKeyInfo(self) -> PublicKeyInfo: + """Returns active authentication public key info""" + return self.content + + @property + def aaPublicKey(self) -> keys.AAPublicKey: + """Returns active authentication public key""" + if not hasattr(self, "_aakey"): + self._aakey = keys.AAPublicKey.load(self.aaPublicKeyInfo.dump()) + return self._aakey diff --git a/src/pymrtd/ef/dg2.py b/src/pymrtd/ef/dg2.py index cfdce0f..1e27d70 100644 --- a/src/pymrtd/ef/dg2.py +++ b/src/pymrtd/ef/dg2.py @@ -1,8 +1,10 @@ import asn1crypto.core as asn1 + +from .dg import DataGroup from .errors import NFCPassportReaderError -class DataGroup2(asn1.OctetString): +class DataGroup2(asn1.OctetString, DataGroup): class_ = 2 tag = 21 @@ -148,54 +150,15 @@ def parse_iso19794_5(self, data: bytes): self.image_data = list(data[offset:]) - def get_next_tag(self) -> int: - tag = 0 - # Fix for some passports that may have invalid data - ensure that we do have data! - if len(self.data) <= self.pos: - raise NFCPassportReaderError(NFCPassportReaderError.INVALID_DATA) +class DG2(DataGroup): + tag = 21 + _content_spec = DataGroup2 - if self.bin_to_hex(self.data[self.pos : self.pos + 1]) & 0x0F == 0x0F: - tag = self.bin_to_hex(self.data[self.pos : self.pos + 2]) - self.pos += 2 - else: - tag = self.data[self.pos] - self.pos += 1 - - return tag - - def verify_tag(self, tag, valid_values): - if isinstance(valid_values, list): - if tag not in valid_values: - raise NFCPassportReaderError(NFCPassportReaderError.INVALID_TAG) - else: - if tag != valid_values: - raise NFCPassportReaderError("InvalidTag") - - def asn1_length(self, data: bytes) -> tuple: - if data[0] < 0x80: - return int(data[0]), 1 - if data[0] == 0x81: - return int(data[1]), 2 - if data[0] == 0x82: - val = int.from_bytes(data[1:3], byteorder="big") - return val, 3 - raise NFCPassportReaderError(NFCPassportReaderError.INVALID_LENGTH) - - def get_next_length(self) -> int: - end = self.pos + 4 if self.pos + 4 < len(self.data) else len(self.data) - length, len_offset = self.asn1_length(self.data[self.pos : end]) - self.pos += len_offset - return length - - def get_next_value(self) -> bytes: - length = self.get_next_length() - value = self.data[self.pos : self.pos + length] - self.pos += length - return value - - def bin_to_int(self, data: bytes, offset: int, length: int) -> int: - return int.from_bytes(data[offset : offset + length], byteorder="big") - - def bin_to_hex(self, data: bytes) -> int: - return int.from_bytes(data, byteorder="big") + @property + def portrait(self) -> DataGroup2: + return self.content + + @property + def native(self): + return {"portrait": self.portrait} diff --git a/src/pymrtd/ef/dg7.py b/src/pymrtd/ef/dg7.py new file mode 100644 index 0000000..10b8e71 --- /dev/null +++ b/src/pymrtd/ef/dg7.py @@ -0,0 +1,50 @@ +import asn1crypto.core as asn1 + +from .dg import DataGroup + + +class DataGroup7(asn1.OctetString, DataGroup): + class_ = 7 + tag = 7 + + def __init__(self, contents=None, **kwargs): + self.image_data = [] + + self.data = contents + self.pos = 0 + self.body = self.data[self.pos :] + + super().__init__(contents=contents, **kwargs) + + @property + def datagroup_type(self): + return "DG7" + + @classmethod + def load(cls, contents: bytes, strict=True): + instance = cls(contents=contents) + instance.parse() + return instance + + def parse(self): + tag = self.get_next_tag() + self.verify_tag(tag, 0x02) + _ = self.get_next_value() + + tag = self.get_next_tag() + self.verify_tag(tag, 0x5F43) + + self.image_data = self.get_next_value() + + +class DG7(DataGroup): + tag = 7 + _content_spec = DataGroup7 + + @property + def signature(self) -> DataGroup7: + return self.content + + @property + def native(self): + return {"signature": self.signature} diff --git a/src/pymrtd/ef/mrz.py b/src/pymrtd/ef/mrz.py deleted file mode 100644 index 0479828..0000000 --- a/src/pymrtd/ef/mrz.py +++ /dev/null @@ -1,228 +0,0 @@ -from enum import Enum -import asn1crypto.core as asn1 -from datetime import datetime, date, timedelta -from typing import Optional - -class DocumentType(Enum): - """ - Enumeration of possible mayjor document types. - """ - Passport = 'P' # DOC ICAO 9303-p4 4.2.2 specifies a capital letter 'P' as document code to define machine readable passport (MRP). - # One additional capital letter can follow 'P' at the discretion of the issuing State or organization, to designate - # other types of passports such as MRP issued to diplomatic staff, an MRP issued for travel on government business, or a passport issued for a special purpose. - # - # Additional note: From doc ICAO 9303-p4 4.2.2.1 notes 'm': - # "In documents other than passports, e.g. United Nations laissez-passer, seafarer’s identity document - # or refugee travel document, the official title of the document shall be indicated instead of “Passport”. - # However, the first character of the document code shall be P" - -class MachineReadableZone(asn1.OctetString): - class_ = 1 - tag = 31 - _parsed = None - _type = None - - @classmethod - def load(cls, encoded_data: bytes, strict=False, **kwargs): - v:MachineReadableZone = super().load(encoded_data, strict, **kwargs) - clen = len(v.contents) - # pylint: disable=protected-access - if clen == 90: - v._type = 'td1' - elif clen == 72: - v._type = 'td2' - elif clen == 88: - v._type = 'td3' - else: - raise ValueError("Unknown MRZ type") - return v - - def __getitem__(self, key): - return self.native[key] - - @property - def country(self) -> str: # Issuing country - return self['country'] - - @property - def dateOfBirth(self) -> Optional[date]: # Could be None if date is not known - return self['date_of_birth'] - - @property - def dateOfExpiry(self) -> date: - return self['date_of_expiry'] - - @property - def documentCode(self) -> str: - return self['document_code'] - - @property - def documentNumber(self) -> str: - return self['document_number'] - - @property - def gender(self) -> str: - return self['gender'] - - @property - def name(self) -> str: - ni = self['name_identifiers'] - if len(ni) > 1: - return ni[-1] - return "" - - @property - def nationality(self) -> str: - return self['nationality'] - - @property - def native(self): - if self._parsed is None: - self.parse() - return self._parsed - - @property - def additionalData(self) -> str: - if self.type == 'td1': - return self['optional_data_1'] \ - if len(self['optional_data_1']) \ - else self['optional_data_2'] - return self['optional_data'] - - @property - def surname(self) -> str: - ni = self['name_identifiers'] - if len(ni) > 0: - return ni[0] - return "" - - @property - def type(self) -> str: - return self._type - - def toJson(self) -> dict: - return { - 'type' : self.type, - 'doc_code' : self.documentCode, - 'doc_number' : self.documentNumber, - 'date_of_expiry' : self.dateOfExpiry, - 'surname' : self.surname, - 'name' : self.name, - 'date_of_birth' : self.dateOfBirth, - 'gender' : self.gender, - 'country' : self.country, - 'nationality' : self.nationality, - 'additional_data' : self.additionalData - } - - def parse(self): - self._parsed = {} - if self.type == 'td1': - self._parse_td1() - elif self.type == 'td2': - self._parse_td2() - elif self.type == 'td3': - self._parse_td3() - else: - raise ValueError("Cannot parse unknown MRZ type") - - def _parse_td1(self): - self._parsed['document_code'] = self._read(0, 2) - self._parsed['country'] = self._read(2, 3) - self._parsed['document_number'] = self._read(5, 9) - self._parsed['document_number_cd'] = self._read_with_filter(14, 1) # document number check digit, could be char '<' - self._parsed['optional_data_1'] = self._read(15, 15) - self._parsed['date_of_birth'] = self._read_date_of_birth(30, 6) - self._parsed['date_of_birth_cd'] = self._read_cd(36) # document dob check digit - self._parsed['gender'] = self._read(37, 1) - self._parsed['date_of_expiry'] = self._read_date_of_expiry(38, 6) - self._parsed['date_of_expiry_cd'] = self._read_cd(44) # document doe check digit - self._parsed['nationality'] = self._read(45, 3) - self._parsed['optional_data_2'] = self._read(48, 11) - self._parsed['composite_cd'] = self._read_cd(59) - self._parsed['name_identifiers'] = self._read_name_identifiers(60, 30) - self._parseExtendedDocumentNumber() - - def _parse_td2(self): - self._parsed['document_code'] = self._read(0, 2) - self._parsed['country'] = self._read(2, 3) - self._parsed['name_identifiers'] = self._read_name_identifiers(5, 31) - self._parsed['document_number'] = self._read(36, 9) - self._parsed['document_number_cd'] = self._read_with_filter(45, 1) # document number check digit - self._parsed['nationality'] = self._read(46, 3) - self._parsed['date_of_birth'] = self._read_date_of_birth(49, 6) - self._parsed['date_of_birth_cd'] = self._read_cd(55) # document dob check digit - self._parsed['gender'] = self._read(56, 1) - self._parsed['date_of_expiry'] = self._read_date_of_expiry(57, 6) - self._parsed['date_of_expiry_cd'] = self._read_cd(63) # document doe check digit - self._parsed['optional_data'] = self._read(64, 7) - self._parsed['composite_cd'] = self._read_cd(71) - self._parseExtendedDocumentNumber() - - def _parse_td3(self): - self._parsed['document_code'] = self._read(0, 2) - self._parsed['country'] = self._read(2, 3) - self._parsed['name_identifiers'] = self._read_name_identifiers(5, 39) - self._parsed['document_number'] = self._read(44, 9) - self._parsed['document_number_cd'] = self._read_cd(53) # document number check digit - self._parsed['nationality'] = self._read(54, 3) - self._parsed['date_of_birth'] = self._read_date_of_birth(57, 6) - self._parsed['date_of_birth_cd'] = self._read_cd(63) # document dob check digit - self._parsed['gender'] = self._read(64, 1) - self._parsed['date_of_expiry'] = self._read_date_of_expiry(65, 6) - self._parsed['date_of_expiry_cd'] = self._read_cd(71) # document doe check digit - self._parsed['optional_data'] = self._read(72, 14) - self._parsed['optional_data_cd'] = self._read_cd(86) - self._parsed['composite_cd'] = self._read_cd(87) - - def _parseExtendedDocumentNumber(self): - # doc 9303 p10 page 30 - fn_opt_data = 'optional_data_1' if self.type == 'td1' else 'optional_data' - if self._parsed['document_number_cd'] == '<' and len(self._parsed[fn_opt_data]) > 0: - self._parsed['document_number'] += self._parsed[fn_opt_data][:-1] - self._parsed['document_number_cd'] = self._parsed[fn_opt_data][-1] - self._parsed[fn_opt_data] = "" - - def _read_with_filter(self, idx, len): - return self.contents[idx: idx + len].decode('ascii') - - def _read(self, idx, len): - return self._read_with_filter(idx, len).rstrip('<') - - def _read_cd(self, idx) -> int: - scd = self._read_with_filter(idx, 1) - if scd == '<': - return 0 - try: - return int(scd) - except: - raise ValueError(f"Invalid check digit character '{scd}' in MRZ at position {idx}") - - def _read_date(self, idx, len): - date = self._read_with_filter(idx, len) - if '<' in date: # In case of unknown date of birth - return None - try: - return datetime.strptime(date.rstrip('<'), '%y%m%d').date() - except: - raise ValueError(f"Invalid date format '{date}' in MRZ at position {idx}") - - def _read_date_of_birth(self, idx, len): - date = self._read_date(idx, len) - if date is not None and date > datetime.today().date(): # reduce date for 100 years if greater then current date - days_per_year = 365.25 - date -= timedelta(days=(100 * days_per_year)) - return date - - def _read_date_of_expiry(self, idx, len): - date = self._read_date(idx, len) - if date is None: - raise ValueError('Invalid date of expiry in MRZ') - return date - - def _read_name_identifiers(self, idx, size): - name_field = self._read(idx, size) - ids = name_field.split('<<') - for i in range(0, len(ids)): - ids[i] = ids[i].replace('<', ' ') - return tuple(ids) diff --git a/src/pymrtd/ef/sod.py b/src/pymrtd/ef/sod.py index ce5a146..16d3b79 100644 --- a/src/pymrtd/ef/sod.py +++ b/src/pymrtd/ef/sod.py @@ -1,42 +1,42 @@ +from typing import List, Optional, Union, cast + import asn1crypto.core as asn1 from asn1crypto.algos import DigestAlgorithm from asn1crypto.util import int_from_bytes - from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from pymrtd.pki import algo_utils, cms, oids, x509 -from typing import cast, List, Optional, Union from .base import ElementaryFile, LDSVersionInfo from .dg import DataGroup, DataGroupNumber + class SODError(Exception): pass + class LDSSecurityObjectVersion(asn1.Integer): - _map = { - 0: 'v0', - 1: 'v1' - } + _map = {0: "v0", 1: "v1"} @property def value(self): return int_from_bytes(self.contents, signed=True) + class DataGroupHash(asn1.Sequence): _fields = [ - ('dataGroupNumber', DataGroupNumber), - ('dataGroupHashValue', asn1.OctetString), + ("dataGroupNumber", DataGroupNumber), + ("dataGroupHashValue", asn1.OctetString), ] @property def number(self) -> DataGroupNumber: - return self['dataGroupNumber'] + return self["dataGroupNumber"] @property def hash(self) -> bytes: - return self['dataGroupHashValue'].native + return self["dataGroupHashValue"].native class DataGroupHashValues(asn1.SequenceOf): @@ -59,51 +59,51 @@ def find(self, dgNumber: DataGroupNumber) -> Union[DataGroupHash, None]: class LDSSecurityObject(asn1.Sequence): _fields = [ - ('version', LDSSecurityObjectVersion), - ('hashAlgorithm', DigestAlgorithm), - ('dataGroupHashValues', DataGroupHashValues), - ('ldsVersionInfo', LDSVersionInfo, {'optional': True}) + ("version", LDSSecurityObjectVersion), + ("hashAlgorithm", DigestAlgorithm), + ("dataGroupHashValues", DataGroupHashValues), + ("ldsVersionInfo", LDSVersionInfo, {"optional": True}), ] @property def version(self) -> LDSSecurityObjectVersion: - return self['version'] + return self["version"] @property def dgHashAlgo(self) -> DigestAlgorithm: - ''' Returns the hash algorithm that the hash values of data groups were produced with. ''' - return self['hashAlgorithm'] + """Returns the hash algorithm that the hash values of data groups were produced with.""" + return self["hashAlgorithm"] @property def dgHashes(self) -> DataGroupHashValues: - ''' Returns hash values of data groups. ''' - return self['dataGroupHashValues'] + """Returns hash values of data groups.""" + return self["dataGroupHashValues"] @property def ldsVersion(self) -> Union[LDSVersionInfo, None]: - ''' Returns the version of LDS. It can return None if version of this object is 0 ''' - return self['ldsVersionInfo'] + """Returns the version of LDS. It can return None if version of this object is 0""" + return self["ldsVersionInfo"] def getDgHasher(self) -> hashes.Hash: - ''' Returns hashes.Hash object of dgHashAlgo ''' - h = algo_utils.get_hash_algo_by_name(self.dgHashAlgo['algorithm'].native) + """Returns hashes.Hash object of dgHashAlgo""" + h = algo_utils.get_hash_algo_by_name(self.dgHashAlgo["algorithm"].native) return hashes.Hash(h, backend=default_backend()) def find(self, dgNumber: DataGroupNumber) -> Union[DataGroupHash, None]: - '''' + """' Returns DataGroupHash if DataGroupHashValues contains specific data group number, else None :param dgNumber: Data group number to find DataGroupHash object - ''' + """ assert isinstance(dgNumber, DataGroupNumber) return self.dgHashes.find(dgNumber) def contains(self, dg: DataGroup) -> bool: - '''' + """' Returns True if DataGroupHashValues has matching hash of data group, else False :param dg: Data group to find and compare hash value of - ''' + """ assert isinstance(dg, DataGroup) dgh = self.find(dg.number) if dgh is None: @@ -114,54 +114,50 @@ def contains(self, dg: DataGroup) -> bool: return h.finalize() == dgh.hash -class _DataChoice(asn1.Choice): # For OID '1.2.840.113549.1.7.1' +class _DataChoice(asn1.Choice): # For OID '1.2.840.113549.1.7.1' _alternatives = [ - ('data', asn1.OctetString), - ('ldsSecurityObject', LDSSecurityObject), + ("data", asn1.OctetString), + ("ldsSecurityObject", LDSSecurityObject), ] + class SODSignedData(cms.MrtdSignedData): _certificate_spec = x509.DocumentSignerCertificate cms.cms_register_encap_content_info_type( - 'ldsSecurityObject', - oids.id_mrtd_ldsSecurityObject, - LDSSecurityObject - ) - cms.cms_register_encap_content_info_type( - 'data', - oids.id_data, - _DataChoice + "ldsSecurityObject", oids.id_mrtd_ldsSecurityObject, LDSSecurityObject ) + cms.cms_register_encap_content_info_type("data", oids.id_data, _DataChoice) @property def content(self) -> LDSSecurityObject: - ''' overloads MrtdSignedData.content ''' + """overloads MrtdSignedData.content""" lso = super().content - if isinstance(lso, _DataChoice): # In case of OID '1.2.840.113549.1.7.1' + if isinstance(lso, _DataChoice): # In case of OID '1.2.840.113549.1.7.1' lso = lso.chosen if not isinstance(lso, LDSSecurityObject): - raise SODError('SignedData content is not not LDSSecurityObject') + raise SODError("SignedData content is not not LDSSecurityObject") return lso class SODContentInfo(cms.MrtdContentInfo): _signed_data_spec = SODSignedData + class SOD(ElementaryFile): class_ = 1 method = 1 - tag = 23 + tag = 23 _content_spec = SODContentInfo _allowedSodContentTypes = { - oids.id_data, # Some Chinese passports has this OID instead of id_mrtd_ldsSecurityObject + oids.id_data, # Some Chinese passports has this OID instead of id_mrtd_ldsSecurityObject } @classmethod def load(cls, encoded_data: bytes, strict=False) -> "SOD": - ''' + """ Loads EF.SOD from BER/DER-encoded byte string :param encoded_data: A byte string of BER or DER encoded data @@ -172,32 +168,46 @@ def load(cls, encoded_data: bytes, strict=False) -> "SOD": A instance of the `SOD` :raises: SODError - when an error occurs while parsing `encoded_data`. - ''' + """ try: # Parse parent type s = cast(cls, super(SOD, cls).load(encoded_data, strict=strict)) assert isinstance(s, SOD) ci = s.content - ctype = ci['content_type'].native - if ctype != 'signed_data': # ICAO 9303-10-p21 - raise SODError(f"Invalid content type: '{ctype}', expected 'signed_data'") + ctype = ci["content_type"].native + if ctype != "signed_data": # ICAO 9303-10-p21 + raise SODError( + f"Invalid content type: '{ctype}', expected 'signed_data'" + ) sdver = s.signedData.version.native - if sdver != 'v3': # ICAO 9303 part 10 - 4.6.2.2 - raise SODError(f'Invalid SignedData version: {sdver}') - - if not cls._valid_content_type(s.signedData.contentType, strict): #s.signedData.contentType.dotted != oids.id_mrtd_ldsSecurityObject: - raise SODError(f'Invalid encapContentInfo type: {s.signedData.contentType.dotted}, expected {oids.id_mrtd_ldsSecurityObject}') - - if not(0 <= s.ldsSecurityObject.version.value <= 1): - raise SODError(f'Unsupported LDSSecurityObject version: {s.ldsSecurityObject.version.value}, expected 0 or 1') - - assert isinstance(s.signedData.certificates[0], x509.DocumentSignerCertificate) if len(s.signedData.certificates) else True + if sdver != "v3": # ICAO 9303 part 10 - 4.6.2.2 + raise SODError(f"Invalid SignedData version: {sdver}") + + if not cls._valid_content_type( + s.signedData.contentType, strict + ): # s.signedData.contentType.dotted != oids.id_mrtd_ldsSecurityObject: + raise SODError( + f"Invalid encapContentInfo type: {s.signedData.contentType.dotted}, expected {oids.id_mrtd_ldsSecurityObject}" + ) + + if not (0 <= s.ldsSecurityObject.version.value <= 1): + raise SODError( + f"Unsupported LDSSecurityObject version: {s.ldsSecurityObject.version.value}, expected 0 or 1" + ) + + assert ( + isinstance(s.signedData.certificates[0], x509.DocumentSignerCertificate) + if len(s.signedData.certificates) + else True + ) assert isinstance(s.signedData.content, LDSSecurityObject) return s - except SODError: raise - except AssertionError: raise + except SODError: + raise + except AssertionError: + raise except Exception as e: raise SODError(e) @@ -218,21 +228,21 @@ def dump(self, force=False): @classmethod def _valid_content_type(cls, ct, strict): oid = ct.dotted - return oid == oids.id_mrtd_ldsSecurityObject or \ - (strict == False and oid in cls._allowedSodContentTypes) + return oid == oids.id_mrtd_ldsSecurityObject or ( + strict is False and oid in cls._allowedSodContentTypes + ) def __str__(self): """ Returns string representation of self i.e. EF.SOD(fp=XXXXXXXXXXXXXXXX) """ if self._str_rep is None: - self._str_rep = super().__str__()\ - .replace("EF(", "EF.SOD(", 1) + self._str_rep = super().__str__().replace("EF(", "EF.SOD(", 1) return self._str_rep @property def signedData(self) -> SODSignedData: - return self.content['content'] + return self.content["content"] @property def ldsSecurityObject(self) -> LDSSecurityObject: @@ -240,16 +250,18 @@ def ldsSecurityObject(self) -> LDSSecurityObject: @property def dscCertificates(self) -> Optional[List[x509.DocumentSignerCertificate]]: - ''' Returns list of document signer certificates if present, otherwise None. ''' + """Returns list of document signer certificates if present, otherwise None.""" return self.signedData.certificates - def getDscCertificate(self, si: cms.SignerInfo) -> Optional[x509.DocumentSignerCertificate]: - ''' + def getDscCertificate( + self, si: cms.SignerInfo + ) -> Optional[x509.DocumentSignerCertificate]: + """ Returns document signer certificates from the list of `dscCertificates` which signed `si` object. :param si: Signer object for which to return DSC certificate. :return: x509.DocumentSignerCertificate object or None if DSC is not found. :raises SODError: If `si` object is not version v1 or v3 - ''' + """ try: return self.signedData.getCertificate(si) except Exception as e: @@ -257,16 +269,16 @@ def getDscCertificate(self, si: cms.SignerInfo) -> Optional[x509.DocumentSignerC @property def signers(self) -> cms.SignerInfos: - ''' Returns list of SignerInfo which signed this file. ''' + """Returns list of SignerInfo which signed this file.""" return self.signedData.signers def verify(self, si: cms.SignerInfo, dsc: x509.DocumentSignerCertificate) -> None: - ''' + """ Verifies LdsSecurityObject was signed by `dsc`. :param si: The signer info object of `dsc` certificate. :param dsc: The DSC certificate which issued this EF.SOD. :raises: SODError - if verification fails or other some error occurs. - ''' + """ try: self.signedData.verify(si, dsc) except cms.MrtdSignedDataError as e: diff --git a/tests/ef/dg11_test.py b/tests/ef/dg11_test.py new file mode 100644 index 0000000..8b794b8 --- /dev/null +++ b/tests/ef/dg11_test.py @@ -0,0 +1,21 @@ +import pytest + +from pymrtd import ef + + +@pytest.mark.depends( + on=[ + "tests/ef/ef_base_test.py::test_ef_base", + "tests/ef/dg_base_test.py::test_dg_base", + ] +) +def test_dg11(): + assert issubclass(ef.DG11, ef.DataGroup) + + tv_dg11 = bytes.fromhex( + "6B305C065F0E5F2B5F115F0E0C546573743C3C5465737465725F2B0831393730313230315F110B4E6F727468616D70746F6E" + ) + dg11 = ef.DG11.load(tv_dg11) + assert dg11.dump() == tv_dg11 + assert dg11.personal_info.full_name == "Test< bytes: - td = "".join(f'{ord(c):02x}' for c in td) - td = f'5F1F{int(len(td) /2):02x}{td}' + td = "".join(f"{ord(c):02x}" for c in td) + td = f"5F1F{int(len(td) /2):02x}{td}" return bytes.fromhex(td) + def test_mrz_parse(): # tv from ICAO 9303 part 10 A.2.1 - tv = _td_as_der("I