diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1eb97f4..bd013ba 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -12,14 +12,15 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: python-version: - '3.10' - - '3.11' + - "3.11" + - "3.12" steps: - uses: actions/checkout@v2 @@ -30,7 +31,7 @@ jobs: - name: Install system package run: | sudo apt update - sudo apt install python3-dev libssl-dev make automake gcc g++ + sudo apt install python3-dev libssl-dev libffi-dev make automake gcc g++ - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index 6509795..429ea90 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,20 @@ Python parser and writer for EUDI Wallet MDOC CBOR for credential Type 1 and als - [ISO 18013-5 - ISO-compliant driving licence — Mobile driving licence (mDL) application](https://www.iso.org/standard/69084.html) - [RFC 8949 - Concise Binary Object Representation (CBOR)](https://datatracker.ietf.org/doc/html/rfc8949) - [RFC 9052 - CBOR Object Signing and Encryption (COSE): Structures and Process](https://www.rfc-editor.org/rfc/rfc9052.html) - - deprecates [RFC 8152 - CBOR Object Signing and Encryption (COSE)](https://datatracker.ietf.org/doc/html/rfc8152) + - deprecates [RFC 8152 - CBOR Object Signing and Encryption (COSE)](https://datatracker.ietf.org/doc/html/rfc8152) - [IANA Registry - Concise Binary Object Representation (CBOR) Tags](https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml) COSE label 33 (x5chain) in MSO: - [RFC 9360 CBOR Object Signing and Encryption (COSE) - Header Parameters for Carrying and Referencing X.509 Certificates](https://www.rfc-editor.org/rfc/rfc9360.html) -## Scope +## Scope -pyMDOC-CBOR is a -[cbor2](https://github.com/agronholm/cbor2) -and -[pycose](https://github.com/TimothyClaeys/pycose) -wrapper that parses, creates and validates MDOC CBOR encoded binaries +pyMDOC-CBOR is a +[cbor2](https://github.com/agronholm/cbor2) +and +[pycose](https://github.com/TimothyClaeys/pycose) +wrapper that parses, creates and validates MDOC CBOR encoded binaries according to ISO 18013-5. ## Setup @@ -39,14 +39,41 @@ pip install git+https://github.com/peppelinux/pyMDOC-CBOR.git ## Usage +### Issue an MDOC CBOR signed with HSM key + +```` +PID_DATA = { + "eu.europa.ec.eudiw.pid.1": { + "family_name": "Raffaello", + "given_name": "Mascetti", + "birth_date": "1922-03-13" + } + } + +mdoci = MdocCborIssuer( + alg = 'ES256', + kid = "demo-kid", + hsm=True, + key_label="p256-1", + user_pin="1234", + lib_path="/etc/utimaco/libcs2_pkcs11.so", + slot_id=3 +) + +mdoc = mdoci.new( + doctype="eu.europa.ec.eudiw.pid.1", + data=PID_DATA, + # cert_path="app/keys/IACAmDLRoot01.der" # DS certificate +) + +```` + ### Issue an MDOC CBOR `MdocCborIssuer` must be initialized with a private key. The method `.new()` gets the user attributes, devicekeyinfo and doctype. ```` -import os - from pymdoccbor.mdoc.issuer import MdocCborIssuer PKEY = { @@ -58,17 +85,14 @@ PKEY = { } PID_DATA = { - "eu.europa.ec.eudiw.pid.1": { - "family_name": "Raffaello", - "given_name": "Mascetti", - "birth_date": "1922-03-13", - "birth_place": "Rome", - "birth_country": "IT" - }, - "eu.europa.ec.eudiw.pid.it.1": { - "tax_id_code": "TINIT-XXXXXXXXXXXXXXX" + "eu.europa.ec.eudiw.pid.1": { + "family_name": "Raffaello", + "given_name": "Mascetti", + "birth_date": "1922-03-13", + "birth_place": "Rome", + "birth_country": "IT" + } } -} mdoci = MdocCborIssuer( private_key=PKEY @@ -78,16 +102,20 @@ mdoc = mdoci.new( doctype="eu.europa.ec.eudiw.pid.1", data=PID_DATA, devicekeyinfo=PKEY # TODO + # cert_path="/path/" ) mdoc >> returns a python dictionay +mdoc.dump() +>> returns mdoc MSO bytes + mdoci.dump() >> returns mdoc bytes mdoci.dumps() ->> returns AF Binary string representation +>> returns AF Binary mdoc string representation ```` ### Issue an MSO alone @@ -96,7 +124,7 @@ MsoIssuer is a class that handles private keys, data processing, digests and sig The `disclosure_map` is used in the Mdoc `nameSpaces` object for issuance and presentations, it's carried in the mdoc but outside of the MSO, even if it is produced by `MsoIssuer`. -that's why `MsoIssuer.sign()` returns a pure MSO, while `disclosure_map` +that's why `MsoIssuer.sign()` returns a pure MSO, while `disclosure_map` is an attribute of the `MsoIssuer` instance. ```` @@ -124,15 +152,16 @@ mso = msoi.sign() ```` API usage: - - `msoi.data`, user attributes to be encoded - - `msoi.private_key`, COSEKey - - `msoi.public_key`, COSEKey without `d` (for EC2Key) - - `msoi.selfsigned_x509cert`, using the private and the public keys returns a self-signed x509 certificate - - `msoi.hash_map`, digests that will be signed in the MSO - - `msoi.disclosure_map`, disclosure objects grouped by namespaces - - `msoi.sign`, signs the MSO and returns it -### Parse a binary Mdoc +- `msoi.data`, user attributes to be encoded +- `msoi.private_key`, COSEKey +- `msoi.public_key`, COSEKey without `d` (for EC2Key) +- `msoi.selfsigned_x509cert`, using the private and the public keys returns a self-signed x509 certificate +- `msoi.hash_map`, digests that will be signed in the MSO +- `msoi.disclosure_map`, disclosure objects grouped by namespaces +- `msoi.sign`, signs the MSO and returns it + +### Parse a binary Mdoc ```` from pymdoccbor.mdoc.verifier import MdocCbor @@ -166,13 +195,14 @@ msop.verify_signature() ```` API usage: - - `msop.payload_as_dict`: returns the MSO as Python dictionary. - - `msop.payload_as_raw`: returns the MSO as bytes in its original format. - - `msop.payload_as_cbor`: returns the MSO as CBOR encoded object. - - `msop.object`: returns a pycose COSE_Sign1 object. - - `msop.raw_public_keys`: returns the list of the public keys from the unprotected COSE header - - `msop.public_key`: returns `cryptography.hazmat` key. - - `msop.x509_certificates`: returns a list of `cryptography.x509` certificate objects + +- `msop.payload_as_dict`: returns the MSO as Python dictionary. +- `msop.payload_as_raw`: returns the MSO as bytes in its original format. +- `msop.payload_as_cbor`: returns the MSO as CBOR encoded object. +- `msop.object`: returns a pycose COSE_Sign1 object. +- `msop.raw_public_keys`: returns the list of the public keys from the unprotected COSE header +- `msop.public_key`: returns `cryptography.hazmat` key. +- `msop.x509_certificates`: returns a list of `cryptography.x509` certificate objects ## Tests @@ -184,6 +214,7 @@ pytest --cov-report term-missing --cov-report term:skip-covered --cov ## Other examples Quick preview in bash using AF binary + ```` # D.4.1.2 mdoc response export ISSUED_MDOC="a36776657273696f6e63312e3069646f63756d656e747381a367646f6354797065756f72672e69736f2e31383031332e352e312e6d444c6c6973737565725369676e6564a26a6e616d65537061636573a1716f72672e69736f2e31383031332e352e3186d8185863a4686469676573744944006672616e646f6d58208798645b20ea200e19ffabac92624bee6aec63aceedecfb1b80077d22bfc20e971656c656d656e744964656e7469666965726b66616d696c795f6e616d656c656c656d656e7456616c756563446f65d818586ca4686469676573744944036672616e646f6d5820b23f627e8999c706df0c0a4ed98ad74af988af619b4bb078b89058553f44615d71656c656d656e744964656e7469666965726a69737375655f646174656c656c656d656e7456616c7565d903ec6a323031392d31302d3230d818586da4686469676573744944046672616e646f6d5820c7ffa307e5de921e67ba5878094787e8807ac8e7b5b3932d2ce80f00f3e9abaf71656c656d656e744964656e7469666965726b6578706972795f646174656c656c656d656e7456616c7565d903ec6a323032342d31302d3230d818586da4686469676573744944076672616e646f6d582026052a42e5880557a806c1459af3fb7eb505d3781566329d0b604b845b5f9e6871656c656d656e744964656e7469666965726f646f63756d656e745f6e756d6265726c656c656d656e7456616c756569313233343536373839d818590471a4686469676573744944086672616e646f6d5820d094dad764a2eb9deb5210e9d899643efbd1d069cc311d3295516ca0b024412d71656c656d656e744964656e74696669657268706f7274726169746c656c656d656e7456616c7565590412ffd8ffe000104a46494600010101009000900000ffdb004300130d0e110e0c13110f11151413171d301f1d1a1a1d3a2a2c2330453d4947443d43414c566d5d4c51685241435f82606871757b7c7b4a5c869085778f6d787b76ffdb0043011415151d191d381f1f38764f434f7676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676ffc00011080018006403012200021101031101ffc4001b00000301000301000000000000000000000005060401020307ffc400321000010303030205020309000000000000010203040005110612211331141551617122410781a1163542527391b2c1f1ffc4001501010100000000000000000000000000000001ffc4001a110101010003010000000000000000000000014111213161ffda000c03010002110311003f00a5bbde22da2329c7d692bc7d0d03f52cfb0ff75e7a7ef3e7709723a1d0dae146ddfbb3c039ce07ad2bd47a7e32dbb8dd1d52d6ef4b284f64a480067dfb51f87ffb95ff00eb9ff14d215de66af089ce44b7dbde9cb6890a2838eddf18078f7add62d411ef4db9b10a65d6b95a147381ea0d495b933275fe6bba75c114104a8ba410413e983dff004f5af5d34b4b4cde632d0bf1fd1592bdd91c6411f3934c2fa6af6b54975d106dcf4a65ae56e856001ebc03c7ce29dd9eef1ef10fc447dc9da76ad2aee93537a1ba7e4f70dd8eff0057c6dffb5e1a19854a83758e54528750946ec6704850cd037bceb08b6d7d2cc76d3317fc7b5cc04fb6707269c5c6e0c5b60ae549242123b0e493f602a075559e359970d98db89525456b51c951c8afa13ea8e98e3c596836783d5c63f5a61a99fdb7290875db4be88ab384bbbbbfc7183fdeaa633e8951db7da396dc48524fb1a8bd611a5aa2a2432f30ab420a7a6d3240c718cf031fa9ef4c9ad550205aa02951df4a1d6c8421b015b769db8c9229837ea2be8b1b0d39d0eba9c51484efdb8c0efd8d258daf3c449699f2edbd4584e7af9c64e3f96b9beb28d4ac40931e6478c8e76a24a825449501d867d2b1dcdebae99b9c752ae4ecd6dde4a179c1c1e460938f9149ef655e515c03919a289cb3dca278fb7bf177f4faa829dd8ce3f2ac9a7ecde490971fafd7dce15eed9b71c018c64fa514514b24e8e4f8c5c9b75c1e82579dc1233dfec08238f6add62d391acc1c5256a79e706d52d431c7a0145140b9fd149eb3a60dc5e88cbbc2da092411e9dc71f39a7766b447b344e847dcac9dcb5abba8d145061d43a6fcf1e65cf15d0e90231d3dd9cfe62995c6dcc5ca12a2c904a15f71dd27d451453e09d1a21450961cbb3ea8a956433b781f1ce33dfed54f0e2b50a2b71d84ed6db18028a28175f74fc6bda105c529a791c25c4f3c7a11f71586268f4a66b726e33de9ea6f1b52b181c760724e47b514520a5a28a283ffd9d81858ffa4686469676573744944096672616e646f6d58204599f81beaa2b20bd0ffcc9aa03a6f985befab3f6beaffa41e6354cdb2ab2ce471656c656d656e744964656e7469666965727264726976696e675f70726976696c656765736c656c656d656e7456616c756582a37576656869636c655f63617465676f72795f636f646561416a69737375655f64617465d903ec6a323031382d30382d30396b6578706972795f64617465d903ec6a323032342d31302d3230a37576656869636c655f63617465676f72795f636f646561426a69737375655f64617465d903ec6a323031372d30322d32336b6578706972795f64617465d903ec6a323032342d31302d32306a697373756572417574688443a10126a118215901d2308201ce30820174a003020102021401ec51916031e6898e8fc7864af5e6d5f86602b6300a06082a8648ce3d04030230233114301206035504030c0b75746f7069612069616361310b3009060355040613025553301e170d3230313030313030303030305a170d3231313030313030303030305a30213112301006035504030c0975746f706961206473310b30090603550406130255533059301306072a8648ce3d020106082a8648ce3d03010703420004ace7ab7340e5d9648c5a72a9a6f56745c7aad436a03a43efea77b5fa7b88f0197d57d8983e1b37d3a539f4d588365e38cbbf5b94d68c547b5bc8731dcd2f146ba38187308184301e0603551d120417301581136578616d706c65406578616d706c652e636f6d301c0603551d1f041530133011a00fa00d820b6578616d706c652e636f6d301d0603551d0e0416041414e29017a6c35621ffc7a686b7b72db06cd12351300e0603551d0f0101ff04040302078030150603551d250101ff040b3009060728818c5d050102300a06082a8648ce3d0403020348003045022100bac6f93a8bacf0fc9aeac1c89a5c9293af2076942e9e972882a113640330702702207b7b73c0444371a4c94c9c888ddfe553ffde84ca492fd64dfbf02ad46a31cbc85903a2d81859039da66776657273696f6e63312e306f646967657374416c676f726974686d675348412d3235366c76616c756544696765737473a2716f72672e69736f2e31383031332e352e31ad00582075167333b47b6c2bfb86eccc1f438cf57af055371ac55e1e359e20f254adcebf01582067e539d6139ebd131aef441b445645dd831b2b375b390ca5ef6279b205ed45710258203394372ddb78053f36d5d869780e61eda313d44a392092ad8e0527a2fbfe55ae0358202e35ad3c4e514bb67b1a9db51ce74e4cb9b7146e41ac52dac9ce86b8613db555045820ea5c3304bb7c4a8dcb51c4c13b65264f845541341342093cca786e058fac2d59055820fae487f68b7a0e87a749774e56e9e1dc3a8ec7b77e490d21f0e1d3475661aa1d0658207d83e507ae77db815de4d803b88555d0511d894c897439f5774056416a1c7533075820f0549a145f1cf75cbeeffa881d4857dd438d627cf32174b1731c4c38e12ca936085820b68c8afcb2aaf7c581411d2877def155be2eb121a42bc9ba5b7312377e068f660958200b3587d1dd0c2a07a35bfb120d99a0abfb5df56865bb7fa15cc8b56a66df6e0c0a5820c98a170cf36e11abb724e98a75a5343dfa2b6ed3df2ecfbb8ef2ee55dd41c8810b5820b57dd036782f7b14c6a30faaaae6ccd5054ce88bdfa51a016ba75eda1edea9480c5820651f8736b18480fe252a03224ea087b5d10ca5485146c67c74ac4ec3112d4c3a746f72672e69736f2e31383031332e352e312e5553a4005820d80b83d25173c484c5640610ff1a31c949c1d934bf4cf7f18d5223b15dd4f21c0158204d80e1e2e4fb246d97895427ce7000bb59bb24c8cd003ecf94bf35bbd2917e340258208b331f3b685bca372e85351a25c9484ab7afcdf0d2233105511f778d98c2f544035820c343af1bd1690715439161aba73702c474abf992b20c9fb55c36a336ebe01a876d6465766963654b6579496e666fa1696465766963654b6579a40102200121582096313d6c63e24e3372742bfdb1a33ba2c897dcd68ab8c753e4fbd48dca6b7f9a2258201fb3269edd418857de1b39a4e4a44b92fa484caa722c228288f01d0c03a2c3d667646f6354797065756f72672e69736f2e31383031332e352e312e6d444c6c76616c6964697479496e666fa3667369676e6564c074323032302d31302d30315431333a33303a30325a6976616c696446726f6dc074323032302d31302d30315431333a33303a30325a6a76616c6964556e74696cc074323032312d31302d30315431333a33303a30325a5840cff12c17d4739aba806035a9cb2b34ae8a830cef4f329289f9a3ebd302dd6b99c584068257569397b92ba9aa5128554eb05d1273dafea313da4aff6b01a5fb3f6c6465766963655369676e6564a26a6e616d65537061636573d81841a06a64657669636541757468a1696465766963654d61638443a10105a0f65820200d73ded787c64652dc8ee743ea83a5260d5a3283fddc919b7b9cfb486addb26673746174757300" @@ -194,11 +225,13 @@ echo $ISSUED_MDOC | xxd -r -ps | python3 -m cbor2.tool --pretty ### using cbor-diag Install cbor-diag + ```` pip install cbor-diag ```` Print a cbor diagnostic representation + ```` from cbor_diag import * @@ -222,4 +255,5 @@ Other examples at [cbor official documentation](https://github.com/agronholm/cbo ## Authors and contributors -- Giuseppe De Marco +- Giuseppe De Marco +- Pasquale De Rose diff --git a/examples/it_data_model.py b/examples/it_data_model.py index 195557d..a429145 100644 --- a/examples/it_data_model.py +++ b/examples/it_data_model.py @@ -53,15 +53,19 @@ } mdoci = MdocCborIssuer( - private_key=PKEY + private_key=PKEY, + alg="ES256", ) mdoc = mdoci.new( doctype="org.iso.18013.5.1.mDL", data=PID_DATA, - devicekeyinfo=PKEY # TODO + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) mdoc mdoci.dump() -mdoci.dumps() +mdoci.dumps() \ No newline at end of file diff --git a/pymdoccbor/mdoc/exceptions.py b/pymdoccbor/mdoc/exceptions.py index 0e12122..1cb70ad 100644 --- a/pymdoccbor/mdoc/exceptions.py +++ b/pymdoccbor/mdoc/exceptions.py @@ -8,4 +8,4 @@ class NoSignedDocumentProvided(Exception): pass class MissingIssuerAuth(Exception): - pass \ No newline at end of file + pass diff --git a/pymdoccbor/mdoc/issuer.py b/pymdoccbor/mdoc/issuer.py index 94169c6..beca979 100644 --- a/pymdoccbor/mdoc/issuer.py +++ b/pymdoccbor/mdoc/issuer.py @@ -1,127 +1,199 @@ +import base64 import binascii import cbor2 import logging - -from pycose.keys import CoseKey, EC2Key +from cryptography.hazmat.primitives import serialization +from pycose.keys import CoseKey from typing import Union from pymdoccbor.mso.issuer import MsoIssuer -from pymdoccbor.mdoc.exceptions import MissingPrivateKey -logger = logging.getLogger('pymdoccbor') +from cbor_diag import * + + +logger = logging.getLogger("pymdoccbor") class MdocCborIssuer: """ MdocCborIssuer helper class to create a new mdoc """ - - def __init__(self, private_key: Union[dict, EC2Key, CoseKey]): + def __init__( + self, + key_label: str = None, + user_pin: str = None, + lib_path: str = None, + slot_id: int = None, + hsm: bool = False, + alg: str = None, + kid: str = None, + private_key: Union[dict, CoseKey] = {}, + ): """ - Create a new MdocCborIssuer instance - - :param private_key: the private key to sign the mdoc - :type private_key: dict | CoseKey - - :raises MissingPrivateKey: if no private key is provided + Initialize a new MdocCborIssuer + + :param key_label: str: key label + :param user_pin: str: user pin + :param lib_path: str: path to the library cryptographic library + :param slot_id: int: slot id + :param hsm: bool: hardware security module + :param alg: str: hashig algorithm + :param kid: str: key id + :param private_key: Union[dict, CoseKey]: private key """ - self.version: str = '1.0' + self.version: str = "1.0" self.status: int = 0 - if isinstance(private_key, dict): - self.private_key = CoseKey.from_dict(private_key) - elif isinstance(private_key, EC2Key): - ec2_encoded = private_key.encode() - ec2_decoded = CoseKey.decode(ec2_encoded) - self.private_key = ec2_decoded - elif isinstance(private_key, CoseKey): - self.private_key = private_key - else: - raise MissingPrivateKey("You must provide a private key") - - - self.signed :dict = {} + if private_key: + if isinstance(private_key, dict): + self.private_key = CoseKey.from_dict(private_key) + elif isinstance(private_key, EC2Key): + ec2_encoded = private_key.encode() + ec2_decoded = CoseKey.decode(ec2_encoded) + self.private_key = ec2_decoded + elif isinstance(private_key, CoseKey): + self.private_key = private_key + else: + raise ValueError("private_key must be a dict or CoseKey object") + + self.signed: dict = {} + self.key_label = key_label + self.user_pin = user_pin + self.lib_path = lib_path + self.slot_id = slot_id + self.hsm = hsm + self.alg = alg + self.kid = kid def new( self, - data: dict | list[dict], - devicekeyinfo: Union[dict, CoseKey], - doctype: str | None = None - ) -> dict: + data: dict, + doctype: str, + validity: dict = None, + devicekeyinfo: Union[dict, CoseKey, str] = None, + cert_path: str = None, + revocation: dict = None, + ): """ create a new mdoc with signed mso - :param data: the data to sign - Can be a dict, representing the single document, or a list of dicts containg the doctype and the data - Example: - {doctype: "org.iso.18013.5.1.mDL", data: {...}} - :type data: dict | list[dict] - :param devicekeyinfo: the device key info - :type devicekeyinfo: dict | CoseKey - :param doctype: the document type (optional if data is a list) - :type doctype: str | None - - :return: the signed mdoc - :rtype: dict + :param data: dict: data to be signed + :param doctype: str: document type + :param validity: dict: validity info + :param devicekeyinfo: Union[dict, CoseKey, str]: device key info + :param cert_path: str: path to the certificate + :param revocation: dict: revocation info + + :return: dict: signed mdoc """ if isinstance(devicekeyinfo, dict): devicekeyinfo = CoseKey.from_dict(devicekeyinfo) - else: - devicekeyinfo: CoseKey = devicekeyinfo + if isinstance(devicekeyinfo, str): + device_key_bytes = base64.urlsafe_b64decode(devicekeyinfo.encode("utf-8")) + public_key = serialization.load_pem_public_key(device_key_bytes) + curve_name = public_key.curve.name + curve_map = { + "secp256r1": 1, # NIST P-256 + "secp384r1": 2, # NIST P-384 + "secp521r1": 3, # NIST P-521 + "brainpoolP256r1": 8, # Brainpool P-256 + "brainpoolP384r1": 9, # Brainpool P-384 + "brainpoolP512r1": 10, # Brainpool P-512 + # Add more curve mappings as needed + } + curve_identifier = curve_map.get(curve_name) - if isinstance(data, dict): - data = [{"doctype": doctype, "data": data}] + # Extract the x and y coordinates from the public key + x = public_key.public_numbers().x.to_bytes( + (public_key.public_numbers().x.bit_length() + 7) + // 8, # Number of bytes needed + "big", # Byte order + ) - documents = [] + y = public_key.public_numbers().y.to_bytes( + (public_key.public_numbers().y.bit_length() + 7) + // 8, # Number of bytes needed + "big", # Byte order + ) + + devicekeyinfo = { + 1: 2, + -1: curve_identifier, + -2: x, + -3: y, + } - for doc in data: + else: + devicekeyinfo: CoseKey = devicekeyinfo + + if self.hsm: msoi = MsoIssuer( - data=doc["data"], - private_key=self.private_key + data=data, + cert_path=cert_path, + hsm=self.hsm, + key_label=self.key_label, + user_pin=self.user_pin, + lib_path=self.lib_path, + slot_id=self.slot_id, + alg=self.alg, + kid=self.kid, + validity=validity, + revocation=revocation, ) - mso = msoi.sign() + else: + msoi = MsoIssuer( + data=data, + private_key=self.private_key, + alg=self.alg, + cert_path=cert_path, + validity=validity, + revocation=revocation, + ) - document = { - 'docType': doc["doctype"], # 'org.iso.18013.5.1.mDL' - 'issuerSigned': { + mso = msoi.sign(doctype=doctype, device_key=devicekeyinfo) + + mso_cbor = mso.encode( + tag=False, + hsm=self.hsm, + key_label=self.key_label, + user_pin=self.user_pin, + lib_path=self.lib_path, + slot_id=self.slot_id, + ) + + res = { + "version": self.version, + "documents": [{ + "docType": doctype, # 'org.iso.18013.5.1.mDL' + "issuerSigned": { "nameSpaces": { - ns: [ - cbor2.CBORTag(24, value={k: v}) for k, v in dgst.items() - ] + ns: [v for k, v in dgst.items()] for ns, dgst in msoi.disclosure_map.items() }, - "issuerAuth": mso.encode() + "issuerAuth": cbor2.decoder.loads(mso_cbor), }, - # this is required during the presentation. - # 'deviceSigned': { - # # TODO - # } - } + }], + "status": self.status, + } - documents.append(document) + logger.debug(f"MSO diagnostic notation: {cbor2diag(mso_cbor)}") - self.signed = { - 'version': self.version, - 'documents': documents, - 'status': self.status - } + self.signed = res return self.signed - - def dump(self): + + def dump(self) -> bytes: """ - Returns the signed mdoc in CBOR format + Returns the CBOR representation of the signed mdoc - :return: the signed mdoc in CBOR format - :rtype: bytes + :return: bytes: CBOR representation of the signed mdoc """ - return cbor2.dumps(self.signed) + return cbor2.dumps(self.signed, canonical=True) - def dumps(self): + def dumps(self) -> bytes: """ - Returns the signed mdoc in AF binary repr + Returns the AF binary representation of the signed mdoc - :return: the signed mdoc in AF binary repr - :rtype: bytes + :return: bytes: AF binary representation of the signed mdoc """ - return binascii.hexlify(cbor2.dumps(self.signed)) + return binascii.hexlify(cbor2.dumps(self.signed, canonical=True)) diff --git a/pymdoccbor/mdoc/issuersigned.py b/pymdoccbor/mdoc/issuersigned.py index f50fd00..f8207be 100644 --- a/pymdoccbor/mdoc/issuersigned.py +++ b/pymdoccbor/mdoc/issuersigned.py @@ -2,13 +2,9 @@ from typing import Union from pymdoccbor.mso.verifier import MsoVerifier -from pymdoccbor.mdoc.exceptions import MissingIssuerAuth - class IssuerSigned: """ - IssuerSigned helper class to handle issuer signed data - nameSpaces provides the definition within which the data elements of the document are defined. A document may have multiple nameSpaces. @@ -25,31 +21,29 @@ class IssuerSigned: ] """ - def __init__(self, nameSpaces: dict, issuerAuth: Union[dict, bytes]) -> None: + def __init__(self, nameSpaces: dict, issuerAuth: Union[cbor2.CBORTag, dict, bytes]) -> None: """ - Create a new IssuerSigned instance - - :param nameSpaces: the namespaces - :type nameSpaces: dict - :param issuerAuth: the issuer auth - :type issuerAuth: dict | bytes + Initialize the IssuerSigned object - :raises MissingIssuerAuth: if no issuer auth is provided + :param nameSpaces: dict: the nameSpaces of the document + :param issuerAuth: Union[dict, bytes]: the issuerAuth info of the document """ + self.namespaces: dict = nameSpaces if not issuerAuth: - raise MissingIssuerAuth("issuerAuth must be provided") + raise ValueError("issuerAuth must be provided") + # if isinstance(ia, dict): self.issuer_auth = MsoVerifier(issuerAuth) def dump(self) -> dict: """ - Returns a dict representation of the issuer signed data + It returns the issuerSigned as a dict - :return: the issuer signed data as dict - :rtype: dict + :return: dict: the issuerSigned as a dict """ + return { 'nameSpaces': self.namespaces, 'issuerAuth': self.issuer_auth @@ -57,11 +51,11 @@ def dump(self) -> dict: def dumps(self) -> bytes: """ - Returns a CBOR representation of the issuer signed data + It returns the issuerSigned as bytes - :return: the issuer signed data as CBOR - :rtype: bytes + :return: dict: the issuerSigned as bytes """ + return cbor2.dumps( { 'nameSpaces': self.namespaces, diff --git a/pymdoccbor/mdoc/verifier.py b/pymdoccbor/mdoc/verifier.py index d9ddf51..51ae9b7 100644 --- a/pymdoccbor/mdoc/verifier.py +++ b/pymdoccbor/mdoc/verifier.py @@ -13,7 +13,7 @@ class MobileDocument: """ - MobileDocument helper class to verify a mdoc + MobileDocument class to handle the Mobile Document """ _states = { @@ -21,114 +21,113 @@ class MobileDocument: False: "failed", } - def __init__(self, docType: str, issuerSigned: dict, deviceSigned: dict = {}): + def __init__(self, docType: str, issuerSigned: dict, deviceSigned: dict = {}) -> None: """ - Create a new MobileDocument instance + Initialize the MobileDocument object - :param docType: the document type - :type docType: str - :param issuerSigned: the issuer signed data - :type issuerSigned: dict - :param deviceSigned: the device signed data - :type deviceSigned: dict - - :raises NoDocumentTypeProvided: if no document type is provided - :raises NoSignedDocumentProvided: if no signed document is provided + :param docType: str: the document type + :param issuerSigned: dict: the issuerSigned info + :param deviceSigned: dict: the deviceSigned info """ if not docType: raise NoDocumentTypeProvided("You must provide a document type") - + + self.doctype: str = docType # eg: 'org.iso.18013.5.1.mDL' + if not issuerSigned: raise NoSignedDocumentProvided("You must provide a signed document") - - self.doctype: str = docType # eg: 'org.iso.18013.5.1.mDL' - self.issuersigned: IssuerSigned = IssuerSigned(**issuerSigned) - self.is_valid = False - # TODO + self.issuersigned: List[IssuerSigned] = IssuerSigned(**issuerSigned) + self.is_valid = False self.devicesigned: dict = deviceSigned def dump(self) -> dict: """ - Returns a dict representation of the document + It returns the document as a dict - :return: the document as dict - :rtype: dict + :return: dict: the document as a dict """ - return { 'docType': self.doctype, 'issuerSigned': self.issuersigned.dump() } - def dumps(self) -> str: + def dumps(self) -> bytes: """ - Returns an AF binary repr of the document + It returns the AF binary repr as bytes - :return: the document as AF binary - :rtype: str + :return: bytes: the document as bytes """ return binascii.hexlify(self.dump()) def dump(self) -> bytes: """ - Returns a CBOR repr of the document + It returns the document as bytes - :return: the document as CBOR - :rtype: bytes + :return: dict: the document as bytes """ return cbor2.dumps( - cbor2.CBORTag(24, value={ - 'docType': self.doctype, - 'issuerSigned': self.issuersigned.dumps() - }) + cbor2.CBORTag( + 24, + value={ + 'docType': self.doctype, + 'issuerSigned': self.issuersigned.dumps() + } + ) ) def verify(self) -> bool: """ Verify the document signature - :return: True if valid, False otherwise - :rtype: bool + :return: bool: True if the signature is valid, False otherwise """ - self.is_valid = self.issuersigned.issuer_auth.verify_signature() return self.is_valid - def __repr__(self): + def __repr__(self) -> str: return f"{self.__module__}.{self.__class__.__name__} [{self._states[self.is_valid]}]" class MdocCbor: + """ + MdocCbor class to handle the Mobile Document + """ - def __init__(self): + def __init__(self) -> None: + """ + Initialize the MdocCbor object + """ self.data_as_bytes: bytes = b"" self.data_as_cbor_dict: dict = {} self.documents: List[MobileDocument] = [] self.documents_invalid: list = [] - def load(self, data: bytes): - data = binascii.hexlify(data) - return self.loads(data) - - def loads(self, data: str): + def loads(self, data: str) -> None: """ - data is a AF BINARY + Load the data from a AF Binary string + + :param data: str: the AF binary string """ + if isinstance(data, bytes): + data = binascii.hexlify(data) + self.data_as_bytes = binascii.unhexlify(data) self.data_as_cbor_dict = cbor2.loads(self.data_as_bytes) def dump(self) -> bytes: """ - returns bytes + Returns the CBOR representation of the mdoc as bytes """ return self.data_as_bytes - def dumps(self) -> str: + def dumps(self) -> bytes: """ - returns AF binary string representation + Returns the AF binary representation of the mdoc as bytes + + :return: bytes: the AF binary representation of the mdoc """ return binascii.hexlify(self.data_as_bytes) @@ -137,6 +136,12 @@ def data_as_string(self) -> str: return self.dumps().decode() def verify(self) -> bool: + """" + Verify signatures of all documents contained in the mdoc + + :return: bool: True if all signatures are valid, False otherwise + """ + cdict = self.data_as_cbor_dict for i in ('version', 'documents'): @@ -166,7 +171,7 @@ def verify(self) -> bool: return False if self.documents_invalid else True - def __repr__(self): + def __repr__(self) -> str: return ( f"{self.__module__}.{self.__class__.__name__} " f"[{len(self.documents)} valid documents]" diff --git a/pymdoccbor/mso/issuer.py b/pymdoccbor/mso/issuer.py index 0ff6e51..3d943a3 100644 --- a/pymdoccbor/mso/issuer.py +++ b/pymdoccbor/mso/issuer.py @@ -3,21 +3,32 @@ import hashlib import secrets import uuid +import logging + +logger = logging.getLogger("pymdoccbor") + +from pycose.headers import Algorithm +from pycose.keys import CoseKey from datetime import timezone -from pycose.headers import Algorithm, KID +from pycose.headers import Algorithm #, KID from pycose.keys import CoseKey, EC2Key + from pycose.messages import Sign1Message from typing import Union -from pymdoccbor.exceptions import ( - MsoPrivateKeyRequired -) + +from pymdoccbor.exceptions import MsoPrivateKeyRequired from pymdoccbor import settings from pymdoccbor.x509 import MsoX509Fabric from pymdoccbor.tools import shuffle_dict +from cryptography import x509 +from cryptography.hazmat.primitives import serialization + + +from cbor_diag import * class MsoIssuer(MsoX509Fabric): @@ -28,154 +39,229 @@ class MsoIssuer(MsoX509Fabric): def __init__( self, data: dict, - private_key: Union[dict, EC2Key, CoseKey], - digest_alg: str = settings.PYMDOC_HASHALG - ): + validity: dict, + revocation: str = None, + cert_path: str = None, + key_label: str = None, + user_pin: str = None, + lib_path: str = None, + slot_id: int = None, + kid: str = None, + alg: str = None, + hsm: bool = False, + private_key: Union[dict, CoseKey] = None, + digest_alg: str = settings.PYMDOC_HASHALG, + ) -> None: """ - Create a new MsoIssuer instance + Initialize a new MsoIssuer - :param data: the data to sign - :type data: dict - :param private_key: the private key to sign the mso - :type private_key: dict | CoseKey - :param digest_alg: the digest algorithm to use - :type digest_alg: str - - :raises MsoPrivateKeyRequired: if no private key is provided + :param data: dict: the data to sign + :param validity: validity: the validity info of the mso + :param revocation: str: the revocation status + :param cert_path: str: the path to the certificate + :param key_label: str: key label + :param user_pin: str: user pin + :param lib_path: str: path to the library cryptographic library + :param slot_id: int: slot id + :param kid: str: key id + :param alg: str: hashig algorithm + :param hsm: bool: hardware security module + :param private_key: Union[dict, CoseKey]: the signing key + :param digest_alg: str: the digest algorithm """ - if private_key and isinstance(private_key, dict): - self.private_key = CoseKey.from_dict(private_key) - if not self.private_key.kid: - self.private_key.kid = str(uuid.uuid4()) - elif private_key and isinstance(private_key, CoseKey): - self.private_key = private_key - elif private_key and isinstance(private_key, EC2Key): - ec2_encoded = private_key.encode() - ec2_decoded = CoseKey.decode(ec2_encoded) - self.private_key = ec2_decoded - else: - raise MsoPrivateKeyRequired( - "MSO Writer requires a valid private key" - ) - - self.public_key = EC2Key( - crv=self.private_key.crv, - x=self.private_key.x, - y=self.private_key.y - ) + if not hsm: + if private_key: + if isinstance(private_key, dict): + self.private_key = CoseKey.from_dict(private_key) + if not self.private_key.kid: + self.private_key.kid = str(uuid.uuid4()) + elif isinstance(private_key, CoseKey): + self.private_key = private_key + else: + raise ValueError("private_key must be a dict or CoseKey object") + else: + raise MsoPrivateKeyRequired("MSO Writer requires a valid private key") + + if not validity: + raise ValueError("validity must be present") + + if not alg: + raise ValueError("alg must be present") self.data: dict = data self.hash_map: dict = {} + self.cert_path = cert_path self.disclosure_map: dict = {} self.digest_alg: str = digest_alg + self.key_label = key_label + self.user_pin = user_pin + self.lib_path = lib_path + self.slot_id = slot_id + self.hsm = hsm + self.alg = alg + self.kid = kid + self.validity = validity + self.revocation = revocation - hashfunc = getattr( - hashlib, - settings.HASHALG_MAP[settings.PYMDOC_HASHALG] - ) + alg_map = {"ES256": "sha256", "ES384": "sha384", "ES512": "sha512"} + + hashfunc = getattr(hashlib, alg_map.get(self.alg)) digest_cnt = 0 for ns, values in data.items(): self.disclosure_map[ns] = {} self.hash_map[ns] = {} for k, v in shuffle_dict(values).items(): - _rnd_salt = secrets.token_bytes(settings.DIGEST_SALT_LENGTH) - + _value_cbortag = settings.CBORTAGS_ATTR_MAP.get(k, None) - + if _value_cbortag: v = cbor2.CBORTag(_value_cbortag, value=v) - - self.disclosure_map[ns][digest_cnt] = { - 'digestID': digest_cnt, - 'random': _rnd_salt, - 'elementIdentifier': k, - 'elementValue': v - } + + if isinstance(v, dict): + for k2, v2 in v.items(): + _value_cbortag = settings.CBORTAGS_ATTR_MAP.get(k2, None) + if _value_cbortag: + v[k2] = cbor2.CBORTag(_value_cbortag, value=v2) + + if isinstance(v, list) and k != "nationality": + for item in v: + for k2, v2 in item.items(): + _value_cbortag = settings.CBORTAGS_ATTR_MAP.get(k2, None) + if _value_cbortag: + item[k2] = cbor2.CBORTag(_value_cbortag, value=v2) + + self.disclosure_map[ns][digest_cnt] = cbor2.CBORTag( + 24, + value=cbor2.dumps( + { + "digestID": digest_cnt, + "random": _rnd_salt, + "elementIdentifier": k, + "elementValue": v, + }, + canonical=True, + ), + ) self.hash_map[ns][digest_cnt] = hashfunc( - cbor2.dumps( - cbor2.CBORTag( - 24, - value=cbor2.dumps( - self.disclosure_map[ns][digest_cnt] - ) - ) - ) + cbor2.dumps(self.disclosure_map[ns][digest_cnt], canonical=True) ).digest() digest_cnt += 1 def format_datetime_repr(self, dt: datetime.datetime) -> str: """ - Format a datetime object to a string representation - - :param dt: the datetime object - :type dt: datetime.datetime + Format a datetime object to a string - :return: the string representation - :rtype: str + :param dt: datetime.datetime: the datetime object + :return: str: the formatted string """ - return dt.isoformat().split('.')[0] + 'Z' + return dt.isoformat().split(".")[0] + "Z" def sign( self, device_key: Union[dict, None] = None, valid_from: Union[None, datetime.datetime] = None, - doctype: str | None = None + doctype: str = None, ) -> Sign1Message: """ Sign a mso and returns it - :param device_key: the device key info - :type device_key: dict | None - :param valid_from: the validity start date - :type valid_from: datetime.datetime | None - :param doctype: the document type - :type doctype: str + :param device_key: Union[dict, None]: the device key + :param valid_from: Union[None, datetime.datetime]: the valid from date + :param doctype: str: the document type - :return: the signed mso - :rtype: Sign1Message + :return: Sign1Message: the signed mso """ - utcnow = datetime.datetime.now(timezone.utc) + + utcnow = datetime.datetime.utcnow() + valid_from = datetime.datetime.strptime( + self.validity["issuance_date"], "%Y-%m-%d" + ) + if settings.PYMDOC_EXP_DELTA_HOURS: - exp = utcnow + datetime.timedelta( - hours=settings.PYMDOC_EXP_DELTA_HOURS - ) + exp = utcnow + datetime.timedelta(hours=settings.PYMDOC_EXP_DELTA_HOURS) else: # five years - exp = utcnow + datetime.timedelta(hours=(24 * 365) * 5) + exp = datetime.datetime.strptime(self.validity["expiry_date"], "%Y-%m-%d") + # exp = utcnow + datetime.timedelta(hours=(24 * 365) * 5) + + if utcnow > valid_from: + valid_from = utcnow + + alg_map = {"ES256": "SHA-256", "ES384": "SHA-384", "ES512": "SHA-512"} payload = { - 'version': '1.0', - 'digestAlgorithm': settings.HASHALG_MAP[settings.PYMDOC_HASHALG], - 'valueDigests': self.hash_map, - 'deviceKeyInfo': { - 'deviceKey': device_key + "docType": doctype or list(self.hash_map)[0], + "version": "1.0", + "validityInfo": { + "signed": cbor2.CBORTag(0, self.format_datetime_repr(utcnow)), + "validFrom": cbor2.CBORTag( + 0, self.format_datetime_repr(valid_from or utcnow) + ), + "validUntil": cbor2.CBORTag(0, self.format_datetime_repr(exp)), }, - 'docType': doctype or list(self.hash_map)[0], - 'validityInfo': { - 'signed': cbor2.dumps(cbor2.CBORTag(0, self.format_datetime_repr(utcnow))), - 'validFrom': cbor2.dumps(cbor2.CBORTag(0, self.format_datetime_repr(valid_from or utcnow))), - 'validUntil': cbor2.dumps(cbor2.CBORTag(0, self.format_datetime_repr(exp))) - } - } - - _cert = settings.X509_DER_CERT or self.selfsigned_x509cert() - - mso = Sign1Message( - phdr={ - Algorithm: self.private_key.alg, - KID: self.private_key.kid, - 33: self.selfsigned_x509cert() + "valueDigests": self.hash_map, + "deviceKeyInfo": { + "deviceKey": device_key, }, - # TODO: x509 (cbor2.CBORTag(33)) and federation trust_chain support (cbor2.CBORTag(27?)) here - # 33 means x509chain standing to rfc9360 - # in both protected and unprotected for interop purpose .. for now. - uhdr={33: _cert}, - payload=cbor2.dumps(payload) - ) - mso.key = self.private_key + "digestAlgorithm": alg_map.get(self.alg), + } + + if self.revocation is not None: + payload.update({"status": self.revocation}) + + if self.cert_path: + # Load the DER certificate file + with open(self.cert_path, "rb") as file: + certificate = file.read() + + cert = x509.load_der_x509_certificate(certificate) + + _cert = cert.public_bytes(getattr(serialization.Encoding, "DER")) + else: + _cert = self.selfsigned_x509cert() + + if self.hsm: + # print("payload diganostic notation: \n",cbor2diag(cbor2.dumps(cbor2.CBORTag(24, cbor2.dumps(payload))))) + + mso = Sign1Message( + phdr={ + Algorithm: self.alg, + # 33: _cert + }, + # TODO: x509 (cbor2.CBORTag(33)) and federation trust_chain support (cbor2.CBORTag(27?)) here + # 33 means x509chain standing to rfc9360 + # in both protected and unprotected for interop purpose .. for now. + uhdr={33: _cert}, + payload=cbor2.dumps( + cbor2.CBORTag(24, cbor2.dumps(payload, canonical=True)), + canonical=True, + ), + ) + + else: + logger.debug("payload diagnostic notation: {cbor2diag(cbor2.dumps(cbor2.CBORTag(24,cbor2.dumps(payload))))}") + + mso = Sign1Message( + phdr={ + Algorithm: self.private_key.alg, + # KID: self.private_key.kid, + # 33: _cert + }, + # TODO: x509 (cbor2.CBORTag(33)) and federation trust_chain support (cbor2.CBORTag(27?)) here + # 33 means x509chain standing to rfc9360 + # in both protected and unprotected for interop purpose .. for now. + uhdr={33: _cert}, + payload=cbor2.dumps( + cbor2.CBORTag(24, cbor2.dumps(payload, canonical=True)), + canonical=True, + ), + ) + + mso.key = self.private_key + return mso diff --git a/pymdoccbor/mso/verifier.py b/pymdoccbor/mso/verifier.py index a314491..20c4602 100644 --- a/pymdoccbor/mso/verifier.py +++ b/pymdoccbor/mso/verifier.py @@ -2,9 +2,11 @@ import cryptography import logging -from pycose.keys import EC2Key +from pycose.keys import CoseKey, EC2Key from pycose.messages import Sign1Message +from typing import Union + from pymdoccbor.exceptions import ( MsoX509ChainNotFound, UnsupportedMsoDataFormat @@ -18,8 +20,6 @@ class MsoVerifier: """ - MsoVerifier helper class to verify a mso - Parameters data: CBOR TAG 24 @@ -31,16 +31,15 @@ class MsoVerifier: structure as defined in RFC 8152. """ - def __init__(self, data: cbor2.CBORTag) -> None: + def __init__(self, data: Union[cbor2.CBORTag, bytes, list]) -> None: """ - Create a new MsoParser instance - - :param data: the data to verify - :type data: cbor2.CBORTag - - :raises UnsupportedMsoDataFormat: if the data format is not supported + Initialize the MsoParser object + + :param data: Union[cbor2.CBORTag, bytes, list]: the data to parse """ + self._data = data + # not used if isinstance(self._data, bytes): self.object: Sign1Message = bytes2CoseSign1( @@ -59,28 +58,18 @@ def __init__(self, data: cbor2.CBORTag) -> None: @property def payload_as_cbor(self) -> dict: """ - Return the decoded payload + It returns the payload as a CBOR TAG - :return: the decoded payload - :rtype: dict + :return: dict: the payload as a CBOR TAG 24 """ return cbor2.loads(self.object.payload) @property - def payload_as_raw(self) -> bytes: - """ - Return the raw payload - - :return: the raw payload - :rtype: bytes - """ + def payload_as_raw(self): return self.object.payload @property - def payload_as_dict(self) -> dict: - """ - Return the payload as dict - """ + def payload_as_dict(self): return cbor2.loads( cbor2.loads(self.object.payload).value ) @@ -88,13 +77,8 @@ def payload_as_dict(self) -> dict: @property def raw_public_keys(self) -> bytes: """ - It returns the public key extract from x509 certificates - looking to both phdr and uhdr - - :raises MsoX509ChainNotFound: if no valid x509 certificate is found - - :return: the raw public key - :rtype: bytes + it returns the public key extract from x509 certificates + looking to both phdr and uhdr """ _mixed_heads = self.object.phdr.items() | self.object.uhdr.items() for h, v in _mixed_heads: @@ -106,7 +90,7 @@ def raw_public_keys(self) -> bytes: "in this MSO." ) - def attest_public_key(self) -> None: + def attest_public_key(self): logger.warning( "TODO: in next releases. " "The certificate is to be considered as untrusted, this release " @@ -117,8 +101,9 @@ def attest_public_key(self) -> None: def load_public_key(self) -> None: """ Load the public key from the x509 certificate - """ + :return: None + """ self.attest_public_key() for i in self.raw_public_keys: @@ -130,17 +115,17 @@ def load_public_key(self) -> None: key = EC2Key( crv=settings.COSEKEY_HAZMAT_CRV_MAP[self.public_key.curve.name], - x=self.public_key.public_numbers().x.to_bytes(settings.CRV_LEN_MAP[self.public_key.curve.name], 'big'), - y=self.public_key.public_numbers().y.to_bytes(settings.CRV_LEN_MAP[self.public_key.curve.name], 'big') + x=self.public_key.public_numbers().x.to_bytes( + settings.CRV_LEN_MAP[self.public_key.curve.name], 'big' + ) ) self.object.key = key def verify_signature(self) -> bool: - """ - Verify the signature + """" + Verify the signature of the MSO - :return: True if valid, False otherwise - :rtype: bool + :return: bool: True if the signature is valid, False otherwise """ if not self.object.key: self.load_public_key() diff --git a/pymdoccbor/settings.py b/pymdoccbor/settings.py index bba7433..054e1d4 100644 --- a/pymdoccbor/settings.py +++ b/pymdoccbor/settings.py @@ -11,30 +11,28 @@ CRV_LEN_MAP = { "secp256r1": 32, - "secp384r1": 48, - "secp521r1": 66 } -PYMDOC_HASHALG: str = os.getenv('PYMDOC_HASHALG', "SHA-256") -PYMDOC_EXP_DELTA_HOURS: int = os.getenv('PYMDOC_EXP_DELTA_HOURS', 0) +PYMDOC_HASHALG: str = os.getenv("PYMDOC_HASHALG", "SHA-256") +PYMDOC_EXP_DELTA_HOURS: int = os.getenv("PYMDOC_EXP_DELTA_HOURS", 0) HASHALG_MAP = { "SHA-256": "sha256", "SHA-512": "sha512", - } DIGEST_SALT_LENGTH = 32 -X509_DER_CERT = os.getenv('X509_DER_CERT', None) + +X509_DER_CERT = os.getenv("X509_DER_CERT", None) # OR -X509_COUNTRY_NAME = os.getenv('X509_COUNTRY_NAME', u"US") -X509_STATE_OR_PROVINCE_NAME = os.getenv('X509_STATE_OR_PROVINCE_NAME', u"California") -X509_LOCALITY_NAME = os.getenv('X509_LOCALITY_NAME', u"San Francisco") -X509_ORGANIZATION_NAME = os.getenv('X509_ORGANIZATION_NAME', u"My Company") -X509_COMMON_NAME = os.getenv('X509_COMMON_NAME', u"mysite.com") +X509_COUNTRY_NAME = os.getenv('X509_COUNTRY_NAME', "US") +X509_STATE_OR_PROVINCE_NAME = os.getenv('X509_STATE_OR_PROVINCE_NAME', "California") +X509_LOCALITY_NAME = os.getenv('X509_LOCALITY_NAME', "San Francisco") +X509_ORGANIZATION_NAME = os.getenv('X509_ORGANIZATION_NAME', "My Company") +X509_COMMON_NAME = os.getenv('X509_COMMON_NAME', "mysite.com") X509_NOT_VALID_BEFORE = os.getenv('X509_NOT_VALID_BEFORE', datetime.datetime.now(timezone.utc)) X509_NOT_VALID_AFTER_DAYS = os.getenv('X509_NOT_VALID_AFTER_DAYS', 10) @@ -46,12 +44,12 @@ ) X509_SAN_URL = os.getenv( - 'X509_SAN_URL', u"https://credential-issuer.oidc-federation.online" + "X509_SAN_URL", "https://credential-issuer.example.org" ) CBORTAGS_ATTR_MAP = { "birth_date": 1004, "expiry_date": 1004, - "issue_date": 1004 + "issue_date": 1004, + "issuance_date": 1004, } - diff --git a/pymdoccbor/tests/pkey.py b/pymdoccbor/tests/pkey.py index 65a001a..97e3210 100644 --- a/pymdoccbor/tests/pkey.py +++ b/pymdoccbor/tests/pkey.py @@ -1,5 +1,9 @@ -from pycose.keys import EC2Key +import os -encoded_pkey = b'\xa6\x01\x02\x03& \x01!X \x8d%C\x91\xe8\x17A\xe1\xc2\xc1\'J\xa7\x1e\xe6J\x03\xc4\xc9\x8a\x91 hV\xcd\x10yb\x9f\xf7\xbe\x9a"X H\x8a\xc3\xd4\xc2\xea\x9bX\x9d\x9d\xf1~\x0c!\x92\xda\xfd\x02s\x0ci\xee\x190i\x88J\xddt\x14\x03\x95#X \xcd\xe1^\x92\xc8z\xd9&&\x0f\x0c\xbd\x8f4r}z\x03\x83\xe0\xf2\x8e\xcc\x04\x13M\xe1\xafXH\xcbT' - -PKEY = EC2Key.decode(encoded_pkey) \ No newline at end of file +PKEY = { + 'KTY': 'EC2', + 'CURVE': 'P_256', + 'ALG': 'ES256', + 'D': b"<\xe5\xbc;\x08\xadF\x1d\xc5\x0czR'T&\xbb\x91\xac\x84\xdc\x9ce\xbf\x0b,\x00\xcb\xdd\xbf\xec\xa2\xa5", + 'KID': b"demo-kid" +} \ No newline at end of file diff --git a/pymdoccbor/tests/test_01_mdoc_parser.py b/pymdoccbor/tests/test_01_mdoc_parser.py index 5d89b70..bb52ce8 100644 --- a/pymdoccbor/tests/test_01_mdoc_parser.py +++ b/pymdoccbor/tests/test_01_mdoc_parser.py @@ -23,7 +23,7 @@ def test_parse_mdoc_af_binary(): # testing from export re-import mdoc2 = MdocCbor() - mdoc2.load(mdoc.data_as_bytes) + mdoc2.loads(mdoc.data_as_bytes) mdoc2.verify() for i in mdoc.documents: diff --git a/pymdoccbor/tests/test_02_mdoc_issuer.py b/pymdoccbor/tests/test_02_mdoc_issuer.py index 7ac300b..7975c15 100644 --- a/pymdoccbor/tests/test_02_mdoc_issuer.py +++ b/pymdoccbor/tests/test_02_mdoc_issuer.py @@ -1,23 +1,39 @@ import cbor2 import os - from pycose.messages import Sign1Message -from pycose.keys import EC2Key + from pymdoccbor.mdoc.issuer import MdocCborIssuer from pymdoccbor.mdoc.verifier import MdocCbor from pymdoccbor.mso.issuer import MsoIssuer -from pymdoccbor.tests.pid_data import PID_DATA -from pymdoccbor.tests.pkey import PKEY +from . pid_data import PID_DATA + +PKEY = { + 'KTY': 'EC2', + 'CURVE': 'P_256', + 'ALG': 'ES256', + 'D': os.urandom(32), + 'KID': b"demo-kid" +} def test_mso_writer(): msoi = MsoIssuer( data=PID_DATA, - private_key=PKEY + private_key=PKEY, + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, + alg="ES256" ) - # TODO: assertion here about msow.hash_map and msow.disclosure_map + assert "eu.europa.ec.eudiw.pid.1" in msoi.hash_map + assert msoi.hash_map["eu.europa.ec.eudiw.pid.1"] + + assert "eu.europa.ec.eudiw.pid.1" in msoi.disclosure_map + assert msoi.disclosure_map["eu.europa.ec.eudiw.pid.1"] + assert msoi.disclosure_map["eu.europa.ec.eudiw.pid.1"].values().__len__() == PID_DATA["eu.europa.ec.eudiw.pid.1"].values().__len__() mso = msoi.sign() @@ -29,21 +45,25 @@ def test_mso_writer(): def test_mdoc_issuer(): mdoci = MdocCborIssuer( - private_key=PKEY + private_key=PKEY, + alg="ES256", ) mdoc = mdoci.new( doctype="eu.europa.ec.eudiw.pid.1", data=PID_DATA, - devicekeyinfo=PKEY # TODO + #devicekeyinfo=PKEY, TODO + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) mdocp = MdocCbor() aa = cbor2.dumps(mdoc) - mdocp.load(aa) + mdocp.loads(aa) mdocp.verify() mdoci.dump() mdoci.dumps() - diff --git a/pymdoccbor/tests/test_03_mdoc_issuer.py b/pymdoccbor/tests/test_03_mdoc_issuer.py index 77dbf73..34deb89 100644 --- a/pymdoccbor/tests/test_03_mdoc_issuer.py +++ b/pymdoccbor/tests/test_03_mdoc_issuer.py @@ -4,7 +4,10 @@ from pymdoccbor.tests.pid_data import PID_DATA from pymdoccbor.tests.pkey import PKEY -mdoc = MdocCborIssuer(PKEY) +mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256", +) def test_MdocCborIssuer_creation(): assert mdoc.version == '1.0' @@ -19,21 +22,33 @@ def test_mdoc_without_private_key_must_fail(): def test_MdocCborIssuer_new_single(): mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) assert mdoc.signed['version'] == '1.0' assert mdoc.signed['status'] == 0 assert mdoc.signed['documents'][0]['docType'] == 'org.micov.medical.1' assert mdoc.signed['documents'][0]['issuerSigned']['nameSpaces']['org.micov.medical.1'][0].tag == 24 +# TODO: restore multiple documents support +""" def test_MdocCborIssuer_new_multiple(): micov_data = {"doctype": "org.micov.medical.1", "data": MICOV_DATA} pid_data = {"doctype": "eu.europa.ec.eudiw.pid.1", "data": PID_DATA} mdoc.new( + #TODO: fix the doctype handling + doctype="org.micov.medical.1", data=[micov_data, pid_data], - devicekeyinfo=PKEY # TODO + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, + #devicekeyinfo=PKEY # TODO ) assert mdoc.signed['version'] == '1.0' assert mdoc.signed['status'] == 0 @@ -41,6 +56,7 @@ def test_MdocCborIssuer_new_multiple(): assert mdoc.signed['documents'][0]['issuerSigned']['nameSpaces']['org.micov.medical.1'][0].tag == 24 assert mdoc.signed['documents'][1]['docType'] == 'eu.europa.ec.eudiw.pid.1' assert mdoc.signed['documents'][1]['issuerSigned']['nameSpaces']['eu.europa.ec.eudiw.pid.1'][0].tag == 24 +""" def test_MdocCborIssuer_dump(): dump = mdoc.dump() diff --git a/pymdoccbor/tests/test_04_issuer_signed.py b/pymdoccbor/tests/test_04_issuer_signed.py index 51abbdf..efd6969 100644 --- a/pymdoccbor/tests/test_04_issuer_signed.py +++ b/pymdoccbor/tests/test_04_issuer_signed.py @@ -2,15 +2,21 @@ from pymdoccbor.mdoc.issuersigned import IssuerSigned from pymdoccbor.mdoc.issuer import MdocCborIssuer from pymdoccbor.tests.micov_data import MICOV_DATA -from pymdoccbor.tests.test_03_mdoc_issuer import mdoc from pymdoccbor.tests.pkey import PKEY -mdoc = MdocCborIssuer(PKEY) +mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256", +) mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) issuerAuth = mdoc.signed["documents"][0]["issuerSigned"] issuer_signed = IssuerSigned(**issuerAuth) diff --git a/pymdoccbor/tests/test_05_mdoc_verifier.py b/pymdoccbor/tests/test_05_mdoc_verifier.py index 4a7ff63..58fe29b 100644 --- a/pymdoccbor/tests/test_05_mdoc_verifier.py +++ b/pymdoccbor/tests/test_05_mdoc_verifier.py @@ -17,11 +17,18 @@ def test_verifier_must_fail_issuer_signed(): assert str(e) == "You must provide a signed document" def test_mobile_document(): - mdoc = MdocCborIssuer(PKEY) + mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256", + ) mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) @@ -32,11 +39,18 @@ def test_mobile_document(): assert doc.issuersigned def test_mobile_document_dump(): - mdoc = MdocCborIssuer(PKEY) + mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256" + ) mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) @@ -49,11 +63,18 @@ def test_mobile_document_dump(): assert len(dump) > 0 def test_mobile_document_dumps(): - mdoc = MdocCborIssuer(PKEY) + mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256" + ) mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) @@ -66,14 +87,21 @@ def test_mobile_document_dumps(): assert len(dumps) > 0 def test_mobile_document_verify(): - mdoc = MdocCborIssuer(PKEY) + mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256" + ) mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) document = mdoc.signed["documents"][0] doc = MobileDocument(**document) - assert doc.verify() \ No newline at end of file + assert doc.verify() diff --git a/pymdoccbor/tests/test_06_mso_issuer.py b/pymdoccbor/tests/test_06_mso_issuer.py index fbe2a7f..ec30cce 100644 --- a/pymdoccbor/tests/test_06_mso_issuer.py +++ b/pymdoccbor/tests/test_06_mso_issuer.py @@ -14,11 +14,15 @@ def test_mso_issuer_fail(): def test_mso_issuer_creation(): msoi = MsoIssuer( data=MICOV_DATA, - private_key=PKEY + private_key=PKEY, + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, + alg="ES256" ) assert msoi.private_key - assert msoi.public_key assert msoi.data assert msoi.hash_map assert list(msoi.hash_map.keys())[0] == 'org.micov.medical.1' @@ -27,8 +31,13 @@ def test_mso_issuer_creation(): def test_mso_issuer_sign(): msoi = MsoIssuer( data=MICOV_DATA, - private_key=PKEY + private_key=PKEY, + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, + alg="ES256" ) mso = msoi.sign() - assert isinstance(mso, CoseMessage) + assert isinstance(mso, CoseMessage) \ No newline at end of file diff --git a/pymdoccbor/tests/test_07_mso_verifier.py b/pymdoccbor/tests/test_07_mso_verifier.py index d6b1aae..a385bc3 100644 --- a/pymdoccbor/tests/test_07_mso_verifier.py +++ b/pymdoccbor/tests/test_07_mso_verifier.py @@ -1,3 +1,4 @@ + import os from pycose.keys import CoseKey, EC2Key from pymdoccbor.mso.verifier import MsoVerifier @@ -7,11 +8,19 @@ from pymdoccbor.tests.pkey import PKEY -mdoc = MdocCborIssuer(PKEY) +mdoc = MdocCborIssuer( + private_key=PKEY, + alg="ES256", +) + mdoc.new( data=MICOV_DATA, - devicekeyinfo=PKEY, # TODO - doctype="org.micov.medical.1" + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, ) def test_mso_verifier_fail(): @@ -39,11 +48,11 @@ def test_mso_verifier_payload_as_cbor(): msov = MsoVerifier(issuerAuth) - cbor = msov.payload_as_cbor + cbor = msov.payload_as_dict assert cbor assert cbor["version"] == "1.0" - assert cbor["digestAlgorithm"] == "sha256" + assert cbor["digestAlgorithm"] == "SHA-256" assert cbor["valueDigests"]["org.micov.medical.1"] def test_payload_as_raw(): diff --git a/pymdoccbor/tests/test_08_mdoc_cbor.py b/pymdoccbor/tests/test_08_mdoc_cbor.py new file mode 100644 index 0000000..eab9e88 --- /dev/null +++ b/pymdoccbor/tests/test_08_mdoc_cbor.py @@ -0,0 +1,29 @@ +import os +import cbor2 +from pymdoccbor.mdoc.issuer import MdocCborIssuer +from pymdoccbor.tests.micov_data import MICOV_DATA +from pymdoccbor.mdoc.verifier import MdocCbor +from pymdoccbor.tests.pkey import PKEY + +def test_mdoc_cbor_creation(): + mdoci = MdocCborIssuer( + private_key=PKEY, + alg="ES256", + ) + mdoc = mdoci.new( + data=MICOV_DATA, + #devicekeyinfo=PKEY, # TODO + doctype="org.micov.medical.1", + validity={ + "issuance_date": "2024-12-31", + "expiry_date": "2050-12-31" + }, + ) + + data = cbor2.dumps(mdoc) + + mdocp = MdocCbor() + mdocp.loads(data) + mdocp.verify() + + assert mdoc \ No newline at end of file diff --git a/pymdoccbor/tools.py b/pymdoccbor/tools.py index 50cbcf5..dc781c0 100644 --- a/pymdoccbor/tools.py +++ b/pymdoccbor/tools.py @@ -13,6 +13,9 @@ def bytes2CoseSign1(data: bytes) -> Sign1Message: """ Gets bytes and return a COSE_Sign1 object + + :param data: bytes: the COSE Sign1 as bytes + :return: Sign1Message: the COSE Sign1 object """ decoded = Sign1Message.decode(cbor2.loads(data).value) @@ -21,7 +24,10 @@ def bytes2CoseSign1(data: bytes) -> Sign1Message: def cborlist2CoseSign1(data: list) -> Sign1Message: """ - Gets cbor2 decoded COSE Sign1 as a list and return a COSE_Sign1 object + Gets cbor2 decoded COSE Sign1 as a list and return a COSE_Sign1 object + + :param data: list: the COSE Sign1 as a list + :return: Sign1Message: the COSE Sign1 object """ decoded = Sign1Message.decode( cbor2.dumps( @@ -32,7 +38,12 @@ def cborlist2CoseSign1(data: list) -> Sign1Message: return decoded -def pretty_print(cbor_loaded: dict): +def pretty_print(cbor_loaded: dict) -> None: + """" + Pretty print a CBOR object + + :param cbor_loaded: dict: the CBOR object + """ _obj = key_to_str(cbor_loaded) res = json.dumps( _obj, @@ -42,7 +53,13 @@ def pretty_print(cbor_loaded: dict): print(res) -def shuffle_dict(d: dict): +def shuffle_dict(d: dict) -> dict: + """ + Shuffle a dictionary + + :param d: dict: the dictionary to shuffle + :return: dict: the shuffled dictionary + """ keys = list(d.keys()) diff --git a/pymdoccbor/x509.py b/pymdoccbor/x509.py index 0c65bef..a2f8f12 100644 --- a/pymdoccbor/x509.py +++ b/pymdoccbor/x509.py @@ -2,19 +2,27 @@ import os from cwt import COSEKey +from typing import Union from cryptography import x509 from cryptography.x509.oid import NameOID +from cryptography.x509 import Certificate from cryptography.hazmat.primitives import hashes, serialization -from . import settings - +from pymdoccbor import settings class MsoX509Fabric: + """ + MsoX509Fabric helper class to create a new mso + """ - def selfsigned_x509cert(self, encoding: str = "DER"): + def selfsigned_x509cert(self, encoding: str = "DER") -> Union[Certificate, bytes]: """ - returns an X.509 certificate derived from the private key of the MSO Issuer + Returns an X.509 certificate derived from the private key of the MSO Issuer + + :param encoding: str: the encoding to use, default is DER + + :return: Union[Certificate, bytes]: the X.509 certificate """ ckey = COSEKey.from_bytes(self.private_key.encode()) diff --git a/setup.py b/setup.py index 6bf1045..7732618 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,45 @@ import re from setuptools import setup, find_packages + def readme(): - with open('README.md') as f: + with open("README.md") as f: return f.read() -_pkg_name = 'pymdoccbor' -with open(f'{_pkg_name}/__init__.py', 'r') as fd: - VERSION = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', - fd.read(), re.MULTILINE).group(1) +_pkg_name = "pymdoccbor" + +with open(f"{_pkg_name}/__init__.py", "r") as fd: + VERSION = re.search( + r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE + ).group(1) setup( name=_pkg_name, version=VERSION, description="Python parser and writer for Mobile Driving License and EUDI Wallet MDOC CBOR.", long_description=readme(), - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Topic :: Software Development :: Libraries :: Python Modules" + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", ], - url='https://github.com/peppelinux/pyMDL-MDOC', - author='Giuseppe De Marco', - author_email='demarcog83@gmail.com', - license='Apache Software License', + url="https://github.com/IdentityPython/pyMDL-MDOC", + author="Giuseppe De Marco", + author_email="demarcog83@gmail.com", + license="License :: OSI Approved :: Apache Software License", + # scripts=[f'{_pkg_name}/bin/{_pkg_name}'], packages=find_packages(include=["pymdoccbor", "pymdoccbor.*"]), include_package_data=True, install_requires=[ - 'cbor2>=5.4.0,<5.5.0', - 'cwt>=2.3.0,<2.4', - 'pycose>=1.0.1,<1.1.0' + "cbor2>=5.4.0,<5.5.0", + "cwt>=2.3.0,<2.4", + "cbor-diag>=1.1.0,<1.2", + #'pycose>=1.0.1,<1.1.0' + "pycose @ git+https://github.com/devisefutures/pycose.git@hsm", ], )