Skip to content
Merged
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
186 changes: 186 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,192 @@ 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
import os
import tempfile
from datetime import datetime, timedelta, timezone

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from pymdoccbor.mdoc.issuer import MdocCborIssuer

# Demo chain: root CA signs the Document Signer certificate
root_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
root_name = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example"),
x509.NameAttribute(NameOID.COMMON_NAME, "Example Root CA"),
])
root_cert = (
x509.CertificateBuilder()
.subject_name(root_name)
.issuer_name(root_name)
.public_key(root_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(root_key, hashes.SHA256(), default_backend())
)

ds_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
ds_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example"),
x509.NameAttribute(NameOID.COMMON_NAME, "Example DS"),
]))
.issuer_name(root_name)
.public_key(ds_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.sign(root_key, hashes.SHA256(), default_backend())
)

PKEY = {
"KTY": "EC2",
"CURVE": "P_256",
"ALG": "ES256",
"D": os.urandom(32),
"KID": b"demo-kid",
}
PID_DATA = {
"eu.europa.ec.eudiw.pid.1": {
"family_name": "Raffaello",
"given_name": "Mascetti",
"birth_date": "1922-03-13",
}
}

# x509_chain accepts Certificate objects, file paths, and PEM/DER bytes
with tempfile.TemporaryDirectory() as certs_dir:
intermediate_pem = os.path.join(certs_dir, "intermediate.pem")
with open(intermediate_pem, "wb") as f:
f.write(root_cert.public_bytes(serialization.Encoding.PEM))
intermediate_der = root_cert.public_bytes(serialization.Encoding.DER)

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
intermediate_pem, # file path
intermediate_der, # DER/PEM bytes
],
)

assert mdoc
````

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
import os
from datetime import datetime, timedelta, timezone

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from pymdoccbor.mso.issuer import MsoIssuer

root_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
root_name = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example"),
x509.NameAttribute(NameOID.COMMON_NAME, "Example Root CA"),
])
root_cert = (
x509.CertificateBuilder()
.subject_name(root_name)
.issuer_name(root_name)
.public_key(root_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(root_key, hashes.SHA256(), default_backend())
)

ds_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
ds_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example"),
x509.NameAttribute(NameOID.COMMON_NAME, "Example DS"),
]))
.issuer_name(root_name)
.public_key(ds_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.sign(root_key, hashes.SHA256(), default_backend())
)

PKEY = {
"KTY": "EC2",
"CURVE": "P_256",
"ALG": "ES256",
"D": os.urandom(32),
"KID": b"demo-kid",
}
PID_DATA = {
"eu.europa.ec.eudiw.pid.1": {
"family_name": "Raffaello",
"given_name": "Mascetti",
"birth_date": "1922-03-13",
}
}
DEVICE_KEY = {
1: 2,
-1: 1,
-2: os.urandom(32),
-3: os.urandom(32),
}

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, root_cert],
)

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

### Issue an MSO alone

MsoIssuer is a class that handles private keys, data processing, digests and signature operations.
Expand Down
106 changes: 106 additions & 0 deletions docs/CERTIFICATE-CHAIN-VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,112 @@ 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
import os
import tempfile
from datetime import datetime, timedelta, timezone

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from pymdoccbor.mso.issuer import MsoIssuer

root_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
root_name = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example"),
x509.NameAttribute(NameOID.COMMON_NAME, "Example Root CA"),
])
root_cert = (
x509.CertificateBuilder()
.subject_name(root_name)
.issuer_name(root_name)
.public_key(root_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(root_key, hashes.SHA256(), default_backend())
)

ds_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
ds_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example"),
x509.NameAttribute(NameOID.COMMON_NAME, "Example DS"),
]))
.issuer_name(root_name)
.public_key(ds_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.sign(root_key, hashes.SHA256(), default_backend())
)

data = {
"org.iso.18013.5.1.mDL": {
"family_name": "Doe",
"given_name": "Jane",
}
}
ds_private_key = {
"KTY": "EC2",
"CURVE": "P_256",
"ALG": "ES256",
"D": os.urandom(32),
"KID": b"ds-kid",
}
device_key = {
1: 2,
-1: 1,
-2: os.urandom(32),
-3: os.urandom(32),
}

with tempfile.TemporaryDirectory() as certs_dir:
ds_pem = os.path.join(certs_dir, "ds.pem")
intermediate_pem = os.path.join(certs_dir, "intermediate.pem")
with open(ds_pem, "wb") as f:
f.write(ds_cert.public_bytes(serialization.Encoding.PEM))
with open(intermediate_pem, "wb") as f:
f.write(root_cert.public_bytes(serialization.Encoding.PEM))

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, # Document Signer (first)
intermediate_pem, # optional intermediate(s)
],
)

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

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.3.0"
__version__ = "1.4.0"
7 changes: 6 additions & 1 deletion pymdoccbor/mdoc/issuer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pymdoccbor.mdoc.exceptions import InvalidStatusDescriptor
from pymdoccbor.mso.issuer import MsoIssuer
from pymdoccbor.tools import thaw_cbor
from pymdoccbor.x509 import X509ChainSource

logger = logging.getLogger("pymdoccbor")

Expand Down Expand Up @@ -81,6 +82,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 @@ -91,7 +93,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 @@ -181,6 +184,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 @@ -199,6 +203,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
Loading
Loading