Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,69 @@ assert mdoci.dumps()
# >> mdoci.dumps() returns mdoc bytes
````

### Issue an MDOC CBOR with X.509 certificate chain

To embed a full `x5chain` (COSE header label 33) in the MSO unprotected header,
pass `x509_chain` to `MdocCborIssuer.new()` or `MsoIssuer`. The Document Signer
(DS) certificate must be first; intermediate certificates follow. The trusted
root (IACA) is usually omitted.

Each element of `x509_chain` is an `X509ChainSource` with one of these types:

| Type | Meaning | Example |
|------|---------|---------|
| `str` | **File path** to a PEM or DER certificate (not PEM text inline) | `"certs/ds.pem"` |
| `bytes` | PEM or DER content; PEM bundles with multiple certificates are expanded in order | `open("chain.pem", "rb").read()` |
| `cryptography.x509.Certificate` | Certificate object already loaded in memory | `ds_cert` |

A `str` value is always read as a filesystem path. To pass PEM text, encode it as
`bytes` (for example `pem_text.encode("utf-8")`).

````python
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from pymdoccbor.mdoc.issuer import MdocCborIssuer

with open("certs/ds.pem", "rb") as f:
ds_cert = x509.load_pem_x509_certificate(f.read(), default_backend())

mdoci = MdocCborIssuer(
private_key=PKEY,
alg="ES256",
)

mdoc = mdoci.new(
doctype="eu.europa.ec.eudiw.pid.1",
data=PID_DATA,
devicekeyinfo=PKEY,
validity={"issuance_date": "2025-01-17", "expiry_date": "2030-01-17"},
x509_chain=[
ds_cert, # Certificate object
"certs/intermediate.pem", # file path
open("certs/backup-intermediate.der", "rb").read(), # DER/PEM bytes
],
)
````

For a single DS certificate, `cert_path` remains supported and is equivalent to
`x509_chain` with one entry. The two parameters are mutually exclusive.

When issuing an MSO directly with `MsoIssuer`, use the same `x509_chain` parameter:

````python
from pymdoccbor.mso.issuer import MsoIssuer

msoi = MsoIssuer(
data=PID_DATA,
private_key=PKEY,
alg="ES256",
validity={"issuance_date": "2025-01-17", "expiry_date": "2030-01-17"},
x509_chain=[ds_cert, intermediate_cert],
)

mso = msoi.sign(doctype="eu.europa.ec.eudiw.pid.1", device_key=DEVICE_KEY)
````

### Issue an MSO alone

MsoIssuer is a class that handles private keys, data processing, digests and signature operations.
Expand Down
34 changes: 34 additions & 0 deletions docs/CERTIFICATE-CHAIN-VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,40 @@ cert = x509.load_pem_x509_certificate(pem_data.encode(), default_backend())

The DS certificate is automatically extracted from the mDOC's Mobile Security Object (MSO). It is embedded in the COSE_Sign1 structure's unprotected header (label 33).

### Issuing an mDOC with an X.509 chain

Since version 1.3.0, `MsoIssuer` and `MdocCborIssuer.new()` accept an `x509_chain`
parameter to populate the COSE `x5chain` header (label 33) during MSO issuance.

```python
from pymdoccbor.mso.issuer import MsoIssuer

msoi = MsoIssuer(
data=data,
private_key=ds_private_key,
alg="ES256",
validity={"issuance_date": "2025-01-17", "expiry_date": "2030-01-17"},
x509_chain=[
"certs/ds.pem", # Document Signer (first)
"certs/intermediate.pem", # optional intermediate(s)
],
)

mso = msoi.sign(doctype="org.iso.18013.5.1.mDL", device_key=device_key)
```

Each chain entry may be:

- a file path (PEM or DER; PEM bundles with multiple certificates are expanded in order),
- raw PEM/DER bytes, or
- a `cryptography.x509.Certificate` object.

With a single certificate, the header value is encoded as DER `bytes`. With two or
more certificates, it is encoded as a `list` of DER `bytes`, with the DS certificate
first. The trusted root (IACA) is typically not included in the chain.

`cert_path` (single certificate) and `x509_chain` are mutually exclusive.

## Certificate Chain Structure

```
Expand Down
15 changes: 13 additions & 2 deletions docs/MSO.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,19 @@ protected header. Other elements should not be present in the protected header.


The DS certificate shall be included as a ‘x5chain’ element as described
in “draft-ietf-cose-x509-04”. It shall be included as an
unprotected header element.
in RFC 9360. It shall be included as an unprotected header element (COSE label 33).

At issuance time, pass `x509_chain` to `MsoIssuer` or `MdocCborIssuer.new()`:

````
msoi = MsoIssuer(
data=data,
private_key=ds_private_key,
alg="ES256",
validity={"issuance_date": "2025-01-17", "expiry_date": "2030-01-17"},
x509_chain=["ds.pem", "intermediate.pem"],
)
````


The input for the digest function is the binary data of the IssuerSignedItem.
Expand Down
2 changes: 1 addition & 1 deletion pymdoccbor/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.2.0"
__version__ = "1.3.0"
7 changes: 6 additions & 1 deletion pymdoccbor/mdoc/issuer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from pymdoccbor.mdoc.exceptions import InvalidStatusDescriptor
from pymdoccbor.mso.issuer import MsoIssuer
from pymdoccbor.x509 import X509ChainSource

logger = logging.getLogger("pymdoccbor")

Expand Down Expand Up @@ -80,6 +81,7 @@ def new(
validity: dict | None = None,
devicekeyinfo: dict | CoseKey | str | None = None,
cert_path: str | None = None,
x509_chain: list[X509ChainSource] | None = None,
revocation: dict | None = None,
status: dict | None = None
) -> dict:
Expand All @@ -90,7 +92,8 @@ def new(
: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 cert_path: str: path to a single certificate (PEM or DER)
:param x509_chain: list: X.509 chain for the MSO x5chain header (label 33)
:param revocation: dict: revocation status dict it may include status_list and identifier_list keys
:param status: dict: status dict with uri and idx per draft-ietf-oauth-status-list
:return: dict: signed mdoc
Expand Down Expand Up @@ -180,6 +183,7 @@ def new(
msoi = MsoIssuer(
data=data,
cert_path=cert_path,
x509_chain=x509_chain,
hsm=self.hsm,
key_label=self.key_label,
user_pin=self.user_pin,
Expand All @@ -198,6 +202,7 @@ def new(
private_key=self.private_key,
alg=self.alg,
cert_path=cert_path,
x509_chain=x509_chain,
validity=validity,
revocation=revocation,
cert_info=self.cert_info
Expand Down
65 changes: 23 additions & 42 deletions pymdoccbor/mso/issuer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,17 @@
import logging
import secrets
import uuid
from typing import Union

import cbor2
from cbor_diag import cbor2diag
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509 import Certificate
from pycose.headers import Algorithm
from pycose.keys import CoseKey
from pycose.messages import Sign1Message

from pymdoccbor import settings
from pymdoccbor.exceptions import MsoPrivateKeyRequired
from pymdoccbor.tools import shuffle_dict
from pymdoccbor.x509 import selfsigned_x509cert
from pymdoccbor.x509 import X509ChainSource, encode_x5chain, selfsigned_x509cert

logger = logging.getLogger("pymdoccbor")

Expand All @@ -33,6 +29,7 @@ def __init__(
data: dict,
validity: dict,
cert_path: str | None = None,
x509_chain: list[X509ChainSource] | None = None,
key_label: str | None = None,
user_pin: str | None = None,
lib_path: str | None = None,
Expand All @@ -50,7 +47,11 @@ def __init__(

:param data: dict: the data to sign
:param validity: validity: the validity info of the mso
:param cert_path: str: the path to the certificate
:param cert_path: str: the path to a single certificate (PEM or DER)
:param x509_chain: list: X.509 chain for COSE header 33 (x5chain); each
item may be a file path, PEM/DER bytes, or a Certificate object.
The Document Signer certificate must be first. Mutually exclusive
with cert_path.
:param key_label: str: key label
:param user_pin: str: user pin
:param lib_path: str: path to the library cryptographic library
Expand Down Expand Up @@ -97,11 +98,18 @@ def __init__(
self.revocation = revocation

self.cert_path = cert_path
self.x509_chain = x509_chain
self.cert_info = cert_info

if not self.cert_path and (not self.cert_info or not self.private_key):
if self.cert_path and self.x509_chain:
raise ValueError("cert_path and x509_chain are mutually exclusive")

if not self.cert_path and not self.x509_chain and (
not self.cert_info or not self.private_key
):
raise ValueError(
"cert_path or cert_info with a private key must be provided to properly insert a certificate"
"cert_path, x509_chain, or cert_info with a private key must be "
"provided to properly insert a certificate"
)

alg_map = {"ES256": "sha256", "ES384": "sha384", "ES512": "sha512"}
Expand Down Expand Up @@ -216,35 +224,15 @@ def sign(
if self.revocation is not None:
payload.update({"status": self.revocation})

if self.cert_path:
# Try to load the certificate file
with open(self.cert_path, "rb") as file:
certificate = file.read()
_parsed_cert: Union[Certificate, None] = None
try:
_parsed_cert = x509.load_pem_x509_certificate(certificate)
except Exception:
logger.error(
f"Certificate at {self.cert_path} could not be loaded as PEM, trying DER"
)

if not _parsed_cert:
try:
_parsed_cert = x509.load_der_x509_certificate(certificate)
except Exception:
_err_msg = (
f"Certificate at {self.cert_path} could not be loaded as DER"
)
logger.error(_err_msg)

if _parsed_cert:
cert = _parsed_cert
else:
raise Exception(f"Certificate at {self.cert_path} failed parse")
_cert = cert.public_bytes(getattr(serialization.Encoding, "DER"))
if self.x509_chain:
_cert = encode_x5chain(self.x509_chain)
elif self.cert_path:
_cert = encode_x5chain([self.cert_path])
else:
if not self.cert_info:
raise ValueError("cert_info must be provided if cert_path is not set")
raise ValueError(
"cert_info must be provided if cert_path and x509_chain are not set"
)

logger.warning(
"A self-signed certificate will be created using the provided "
Expand All @@ -261,9 +249,6 @@ def sign(
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)),
Expand All @@ -281,11 +266,7 @@ def sign(
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)),
Expand Down
Loading
Loading