diff --git a/.github/workflows/oid4vc-conformance-tests.yaml b/.github/workflows/oid4vc-conformance-tests.yaml index 5fbfdb0b3..2ee194fb2 100644 --- a/.github/workflows/oid4vc-conformance-tests.yaml +++ b/.github/workflows/oid4vc-conformance-tests.yaml @@ -53,7 +53,7 @@ jobs: tags: oid4vc-integration-acapy-issuer:latest build-args: | ACAPY_VERSION=1.4.0 - ISOMDL_BRANCH=fix/python-build-system + ISOMDL_BRANCH=feat/prepare-complete-signing cache-from: type=gha,scope=acapy-oid4vc cache-to: type=gha,mode=max,scope=acapy-oid4vc @@ -67,7 +67,7 @@ jobs: tags: oid4vc-integration-acapy-verifier:latest build-args: | ACAPY_VERSION=1.4.0 - ISOMDL_BRANCH=fix/python-build-system + ISOMDL_BRANCH=feat/prepare-complete-signing # Issuer + verifier share all layers; use same cache scope. cache-from: type=gha,scope=acapy-oid4vc diff --git a/.github/workflows/pr-linting-and-unit-tests.yaml b/.github/workflows/pr-linting-and-unit-tests.yaml index 350da9c98..6c4880e83 100644 --- a/.github/workflows/pr-linting-and-unit-tests.yaml +++ b/.github/workflows/pr-linting-and-unit-tests.yaml @@ -82,7 +82,7 @@ jobs: if: contains(steps.changed-plugins.outputs.changed-plugins, 'oid4vc') run: | cd oid4vc - poetry run pip install --force-reinstall https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-test12/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + poetry run pip install --force-reinstall https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-indicio.1/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl cd .. #---------------------------------------------- # Lint plugins diff --git a/.gitignore b/.gitignore index 6f8d45374..0e7bb9e39 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,7 @@ settings.json /docs mkdocs.yml *.code-workspace + +# Local process documentation (not for upstream) +ISOMDL_TAGGING.md +ACAPY_TAGGING.md diff --git a/oid4vc/.pre-commit-config.yaml b/oid4vc/.pre-commit-config.yaml index 6de2e7dcd..3e38c49cc 100644 --- a/oid4vc/.pre-commit-config.yaml +++ b/oid4vc/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: stages: [commit] - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.0.285 + rev: v0.15.8 hooks: - id: ruff stages: [commit] diff --git a/oid4vc/docker/Dockerfile b/oid4vc/docker/Dockerfile index 74bfae94b..59c65bf36 100644 --- a/oid4vc/docker/Dockerfile +++ b/oid4vc/docker/Dockerfile @@ -16,9 +16,17 @@ ENV PATH="/root/.cargo/bin:${PATH}" # Accept branch, tag, or commit ref to build from ARG ISOMDL_BRANCH=main +# Optional: pin to an exact commit SHA to bypass Docker layer caching of git clone. +# When this value changes, Docker invalidates the clone layer and fetches fresh source. +ARG ISOMDL_COMMIT="" RUN git clone --depth 1 --branch "${ISOMDL_BRANCH}" \ - https://github.com/Indicio-tech/isomdl-uniffi.git /build/isomdl-uniffi + https://github.com/Indicio-tech/isomdl-uniffi.git /build/isomdl-uniffi \ + && if [ -n "${ISOMDL_COMMIT}" ]; then \ + cd /build/isomdl-uniffi \ + && git fetch --depth=1 origin "${ISOMDL_COMMIT}" \ + && git checkout FETCH_HEAD; \ + fi WORKDIR /build/isomdl-uniffi/python diff --git a/oid4vc/integration/docker-compose.yml b/oid4vc/integration/docker-compose.yml index b32af0733..4f4237998 100644 --- a/oid4vc/integration/docker-compose.yml +++ b/oid4vc/integration/docker-compose.yml @@ -10,7 +10,8 @@ services: target: base args: ACAPY_VERSION: 1.5.1 - ISOMDL_BRANCH: fix/python-build-system + ISOMDL_BRANCH: main + ISOMDL_COMMIT: 161bd687313bb52793394f7beb9dbc7b4b6f68e2 image: oid4vc-base:latest profiles: - base-only # Only build when explicitly requested @@ -54,7 +55,8 @@ services: context: ../../ args: ACAPY_VERSION: 1.5.1 - ISOMDL_BRANCH: fix/python-build-system + ISOMDL_BRANCH: main + ISOMDL_COMMIT: 161bd687313bb52793394f7beb9dbc7b4b6f68e2 ports: - "${ACAPY_ISSUER_INBOUND_PORT:-18020}:8020" # inbound transport - "${ACAPY_ISSUER_ADMIN_PORT:-18021}:8021" # admin @@ -92,7 +94,8 @@ services: context: ../../ args: ACAPY_VERSION: 1.5.1 - ISOMDL_BRANCH: fix/python-build-system + ISOMDL_BRANCH: main + ISOMDL_COMMIT: 161bd687313bb52793394f7beb9dbc7b4b6f68e2 ports: - "${ACAPY_VERIFIER_INBOUND_PORT:-18030}:8030" # inbound transport - "${ACAPY_VERIFIER_ADMIN_PORT:-18031}:8031" # admin diff --git a/oid4vc/integration/pyproject.toml b/oid4vc/integration/pyproject.toml index 80970c754..b3613dd3a 100644 --- a/oid4vc/integration/pyproject.toml +++ b/oid4vc/integration/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # sd_jwt dependencies "jsonpointer>=3.0.0,<4.0.0", # isomdl-uniffi from GitHub releases (pre-built wheels) - "isomdl-uniffi @ https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-test12/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl ; sys_platform == 'linux'", + "isomdl-uniffi @ https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-indicio.1/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl ; sys_platform == 'linux'", "acapy-agent>=1.4.0", "Appium-Python-Client>=4.0.0", "bitarray>=2.9.2", diff --git a/oid4vc/integration/uv.lock b/oid4vc/integration/uv.lock index fb91bbfe2..7ef8215b7 100644 --- a/oid4vc/integration/uv.lock +++ b/oid4vc/integration/uv.lock @@ -779,9 +779,9 @@ wheels = [ [[package]] name = "isomdl-uniffi" version = "0.1.0" -source = { url = "https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-test12/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" } +source = { url = "https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-indicio.1/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" } wheels = [ - { url = "https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-test12/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05a6e8f9fa34436981d7a523efd0717271b921ec3485d43a37c10d334df396b0" }, + { url = "https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-indicio.1/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5" }, ] [package.metadata] @@ -1045,7 +1045,7 @@ requires-dist = [ { name = "cwt", specifier = ">=1.6.0" }, { name = "did-peer-4", specifier = ">=0.1.4" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "isomdl-uniffi", marker = "sys_platform == 'linux'", url = "https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-test12/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, + { name = "isomdl-uniffi", marker = "sys_platform == 'linux'", url = "https://github.com/Indicio-tech/isomdl-uniffi/releases/download/v0.1.0-indicio.1/isomdl_uniffi-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, { name = "jsonpointer", specifier = ">=3.0.0,<4.0.0" }, { name = "jsonrpc-api-proxy-client", git = "https://github.com/Indicio-tech/json-rpc-api-proxy.git?subdirectory=clients%2Fpython&rev=main" }, { name = "pycose", specifier = ">=1.0.0" }, diff --git a/oid4vc/mso_mdoc/__init__.py b/oid4vc/mso_mdoc/__init__.py index 966c81a2c..3d3ed5efa 100644 --- a/oid4vc/mso_mdoc/__init__.py +++ b/oid4vc/mso_mdoc/__init__.py @@ -5,6 +5,7 @@ from acapy_agent.config.injection_context import InjectionContext from mso_mdoc.cred_processor import MsoMdocCredProcessor +from mso_mdoc.signing_backend import MdocSigningBackend, SoftwareSigningBackend from oid4vc.cred_processor import CredProcessors from . import routes as routes # noqa: F401 — triggers ACA-Py route discovery @@ -15,6 +16,12 @@ async def setup(context: InjectionContext): """Setup the plugin.""" LOGGER.info("Setting up MSO_MDOC plugin") + # Register signing backend (default: software PEM-based signing). + # Deployments can override by binding a different MdocSigningBackend + # implementation before this plugin loads. + if not context.inject_or(MdocSigningBackend): + context.injector.bind_instance(MdocSigningBackend, SoftwareSigningBackend()) + processors = context.inject_or(CredProcessors) if not processors: processors = CredProcessors() diff --git a/oid4vc/mso_mdoc/cred_processor.py b/oid4vc/mso_mdoc/cred_processor.py index 6e7f13e4e..da1df1686 100644 --- a/oid4vc/mso_mdoc/cred_processor.py +++ b/oid4vc/mso_mdoc/cred_processor.py @@ -23,12 +23,12 @@ from oid4vc.models.supported_cred import SupportedCredential from oid4vc.pop_result import PopResult -from .mdoc.issuer import MDL_MANDATORY_FIELDS, isomdl_mdoc_sign +from .mdoc.issuer import MDL_MANDATORY_FIELDS from .mdoc.cred_verifier import MsoMdocCredVerifier from .mdoc.pres_verifier import MsoMdocPresVerifier from .payload import normalize_mdoc_result, prepare_mdoc_payload +from .signing_backend import MdocSigningBackend, SoftwareSigningBackend from .trust_anchor import TrustAnchorRecord -from .signing_key import MdocSigningKeyRecord __all__ = [ "MsoMdocCredProcessor", @@ -289,66 +289,37 @@ def _build_headers( headers["deviceKey"] = device_key_str return headers - async def _resolve_signing_key( + async def _resolve_signing_material( self, supported: SupportedCredential, profile: Optional[Profile] = None, ) -> Dict[str, Any]: - """Resolve the signing key for credential issuance. + """Resolve signing material via the pluggable signing backend. - Resolution order: - 1. ``signing_key_id`` in ``vc_additional_data`` — fetch a specific - ``MdocSigningKeyRecord`` by ID. - 2. ``MdocSigningKeyRecord`` query by doctype — first matching record. - - Returns: - Dict with ``private_key_pem`` and ``certificate_pem``. + Delegates to the ``MdocSigningBackend`` bound on the injection context. + Falls back to a ``SoftwareSigningBackend`` if nothing is bound. """ + if not profile: + raise CredProcessorError( + "Profile is required for signing material resolution" + ) + + backend = profile.inject_or(MdocSigningBackend) + if not backend: + backend = SoftwareSigningBackend() + additional = supported.vc_additional_data or {} doctype = (supported.format_data or {}).get("doctype") - - # 1. Explicit signing key record ID signing_key_id = additional.get("signing_key_id") - if signing_key_id and profile: - try: - async with profile.session() as session: - key_record = await MdocSigningKeyRecord.retrieve_by_id( - session, signing_key_id - ) - if key_record.private_key_pem and key_record.certificate_pem: - return { - "private_key_pem": key_record.private_key_pem, - "certificate_pem": key_record.certificate_pem, - } - except Exception as exc: - LOGGER.warning( - "Could not load MdocSigningKeyRecord %s: %s", signing_key_id, exc - ) - # 2. MdocSigningKeyRecord query by doctype (or any key if no doctype) - if profile: - try: - async with profile.session() as session: - tag_filter = {"doctype": doctype} if doctype else None - key_records = await MdocSigningKeyRecord.query( - session, tag_filter=tag_filter - ) - if not key_records and doctype: - # fall back to wildcard keys (no doctype set) - key_records = await MdocSigningKeyRecord.query(session) - for key_record in key_records: - if key_record.private_key_pem and key_record.certificate_pem: - return { - "private_key_pem": key_record.private_key_pem, - "certificate_pem": key_record.certificate_pem, - } - except Exception as exc: - LOGGER.debug("MdocSigningKeyRecord query failed: %s", exc) - - raise CredProcessorError( - "No mDoc signing key configured. " - "Create a MdocSigningKeyRecord via POST /mso-mdoc/signing-keys." - ) + try: + return await backend.resolve_signing_material( + profile, + signing_key_id=signing_key_id, + doctype=doctype, + ) + except ValueError as err: + raise CredProcessorError(str(err)) from err async def _assign_status_entry( self, @@ -463,13 +434,15 @@ async def issue( # Resolve signing key — check MdocSigningKeyRecord first, then # fall back to vc_additional_data and env vars - key_data = await self._resolve_signing_key(supported, context.profile) - private_key_pem = key_data["private_key_pem"] - certificate_pem = key_data["certificate_pem"] + signing_material = await self._resolve_signing_material( + supported, context.profile + ) + certificate_pem = signing_material.get("certificate_pem", "") # Validity-period guard: reject expired or not-yet-valid certificates - # before passing them to the Rust signing library. - check_certificate_not_expired(certificate_pem) + # before passing them to the signing backend. + if certificate_pem: + check_certificate_not_expired(certificate_pem) if not device_key_str and not pop.holder_jwk: raise CredProcessorError( @@ -504,7 +477,7 @@ async def issue( ) # Use cleaned JWK if available, otherwise fall back to # the device key extracted from holder_kid / verification_method. - # isomdl_mdoc_sign expects a dict-like JWK. + # The signing backend expects a dict-like JWK. signing_holder_key = holder_jwk_clean if signing_holder_key is None and device_key_str: try: @@ -524,8 +497,13 @@ async def issue( "Unable to resolve a holder JWK for device key binding." ) - mso_mdoc = isomdl_mdoc_sign( - signing_holder_key, headers, payload, certificate_pem, private_key_pem + # Sign via the pluggable backend + backend = context.profile.inject_or(MdocSigningBackend) + if not backend: + backend = SoftwareSigningBackend() + + mso_mdoc = await backend.sign_mdoc( + signing_material, signing_holder_key, headers, payload ) # Normalize mDoc result handling for robust string/bytes processing diff --git a/oid4vc/mso_mdoc/signing_backend.py b/oid4vc/mso_mdoc/signing_backend.py new file mode 100644 index 000000000..0eea2ab8a --- /dev/null +++ b/oid4vc/mso_mdoc/signing_backend.py @@ -0,0 +1,224 @@ +"""Pluggable signing backend for mDoc credential issuance. + +Defines an abstract ``MdocSigningBackend`` interface that decouples signing-key +resolution and mDoc signing from the storage representation. The default +``SoftwareSigningBackend`` wraps the prepare/complete signing flow from +isomdl-uniffi — private keys never cross the FFI boundary. A future +``PKCS11SigningBackend`` can implement the same interface using HSM-backed +key material. + +Backends are registered on the injection context via ``CredProcessors`` or +directly on the ``InjectionContext`` so they can be swapped per deployment. +""" + +import abc +import json +import logging +from typing import Any, Dict, Mapping, Optional + +from acapy_agent.core.profile import Profile +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from isomdl_uniffi import Mdoc, PreparedMdoc + +from .signing_key import MdocSigningKeyRecord + +LOGGER = logging.getLogger(__name__) + + +class MdocSigningBackend(abc.ABC): + """Abstract base class for mDoc signing backends. + + Implementations must provide: + - ``resolve_signing_material`` — locate key + cert for a given doctype + - ``sign_mdoc`` — produce a signed CBOR mDoc + + The split allows backends that hold key material externally (HSM, KMS) + to avoid ever exposing raw private keys. + """ + + @abc.abstractmethod + async def resolve_signing_material( + self, + profile: Profile, + *, + signing_key_id: Optional[str] = None, + doctype: Optional[str] = None, + ) -> Dict[str, Any]: + """Resolve signing material for credential issuance. + + Returns a dict that ``sign_mdoc`` can consume. The dict contents + are backend-specific — the software backend returns PEM strings + while an HSM backend might return a key handle / URI. + + Raises: + ValueError: If no suitable signing material is found. + """ + + @abc.abstractmethod + async def sign_mdoc( + self, + signing_material: Dict[str, Any], + holder_jwk: dict, + headers: Mapping[str, Any], + payload: Mapping[str, Any], + ) -> str: + """Produce a signed CBOR mDoc. + + Args: + signing_material: Output of ``resolve_signing_material``. + holder_jwk: The holder's public key (EC JWK dict). + headers: mDoc headers (must include ``doctype``). + payload: Namespace-keyed claim data. + + Returns: + The signed mDoc as a string (base64url or hex, depending on + the underlying library). + """ + + +class SoftwareSigningBackend(MdocSigningBackend): + """Default software-based signing using isomdl-uniffi with PEM keys. + + This mirrors the original ``_resolve_signing_key`` + ``isomdl_mdoc_sign`` + path that existed before the pluggable backend refactor. + """ + + async def resolve_signing_material( + self, + profile: Profile, + *, + signing_key_id: Optional[str] = None, + doctype: Optional[str] = None, + ) -> Dict[str, Any]: + """Resolve PEM key material from MdocSigningKeyRecord storage.""" + + # 1. Explicit signing key record ID + if signing_key_id: + try: + async with profile.session() as session: + key_record = await MdocSigningKeyRecord.retrieve_by_id( + session, signing_key_id + ) + if key_record.private_key_pem and key_record.certificate_pem: + return { + "private_key_pem": key_record.private_key_pem, + "certificate_pem": key_record.certificate_pem, + } + except Exception as exc: + LOGGER.warning( + "Could not load MdocSigningKeyRecord %s: %s", + signing_key_id, + exc, + ) + + # 2. Query by doctype (or any key if no doctype) + try: + async with profile.session() as session: + tag_filter = {"doctype": doctype} if doctype else None + key_records = await MdocSigningKeyRecord.query( + session, tag_filter=tag_filter + ) + if not key_records and doctype: + # fall back to wildcard keys (no doctype set) + key_records = await MdocSigningKeyRecord.query(session) + for key_record in key_records: + if key_record.private_key_pem and key_record.certificate_pem: + return { + "private_key_pem": key_record.private_key_pem, + "certificate_pem": key_record.certificate_pem, + } + except Exception as exc: + LOGGER.debug("MdocSigningKeyRecord query failed: %s", exc) + + raise ValueError( + "No mDoc signing key configured. " + "Import a signing key via POST /mso-mdoc/signing-keys." + ) + + async def sign_mdoc( + self, + signing_material: Dict[str, Any], + holder_jwk: dict, + headers: Mapping[str, Any], + payload: Mapping[str, Any], + ) -> str: + """Sign an mDoc using isomdl-uniffi with PEM key material. + + For the ISO 18013-5 mDL doctype, ``Mdoc.create_and_sign_mdl()`` is + used directly so that mDL namespace elements are encoded with the + correct CBOR field types (e.g. ``birth_date`` as a CBOR full-date + rather than a plain text string). + + For all other doctypes, the prepare/complete flow is used — the mDoc + structure is prepared in the Rust FFI layer, the signature payload is + signed in Python, and the mDoc is completed with the raw signature and + certificate chain. Private keys never cross the FFI boundary in that + path. + """ + doctype = headers.get("doctype", "") + holder_jwk_str = ( + json.dumps(holder_jwk) if isinstance(holder_jwk, dict) else str(holder_jwk) + ) + cert_pem = signing_material["certificate_pem"] + key_pem = signing_material["private_key_pem"] + + if doctype == "org.iso.18013.5.1.mDL": + # Use the typed mDL builder (OrgIso1801351::from_json) which + # encodes ISO 18013-5 fields with proper CBOR types. + # The credential subject may be namespaced ({"org.iso.18013.5.1": + # {...}}) or flat; normalise here as the old code did. + mdl_ns = "org.iso.18013.5.1" + aamva_key = "org.iso.18013.5.1.aamva" + mdl_payload = payload.get(mdl_ns, payload) + mdl_items: Dict[str, Any] = { + k: v for k, v in mdl_payload.items() if k != aamva_key + } + mdl_items.setdefault("driving_privileges", []) + aamva_payload: Optional[Dict[str, Any]] = payload.get(aamva_key) + mdoc = Mdoc.create_and_sign_mdl( + mdl_items=json.dumps(mdl_items), + aamva_items=( + json.dumps(aamva_payload) if aamva_payload is not None else None + ), + holder_jwk=holder_jwk_str, + iaca_cert_pem=cert_pem, + iaca_key_pem=key_pem, + ) + return mdoc.issuer_signed_b64() + + # Generic doctype: prepare/complete flow (private key stays in Python) + namespaces: Dict[str, Dict[str, str]] = { + doctype: {k: json.dumps(v) for k, v in payload.items()} + } + prepared = PreparedMdoc( + doc_type=doctype, + namespaces=namespaces, + holder_jwk=holder_jwk_str, + signature_algorithm="ES256", + ) + + sig_payload = prepared.signature_payload() + + # Sign with the DS private key via Python's cryptography library + private_key = serialization.load_pem_private_key( + key_pem.encode("utf-8"), + password=None, + ) + der_sig = private_key.sign(sig_payload, ec.ECDSA(hashes.SHA256())) + + # Convert DER-encoded ECDSA signature to raw r||s for COSE + r_int, s_int = decode_dss_signature(der_sig) + key_size = (private_key.key_size + 7) // 8 + raw_sig = r_int.to_bytes(key_size, byteorder="big") + s_int.to_bytes( + key_size, byteorder="big" + ) + + # Complete the mDoc with signature and certificate chain + mdoc = prepared.complete( + certificate_chain_pem=cert_pem, + signature=raw_sig, + ) + + return mdoc.issuer_signed_b64() diff --git a/oid4vc/mso_mdoc/signing_key.py b/oid4vc/mso_mdoc/signing_key.py index 93d1f10a3..115d8e401 100644 --- a/oid4vc/mso_mdoc/signing_key.py +++ b/oid4vc/mso_mdoc/signing_key.py @@ -4,18 +4,19 @@ used by the issuer to sign mDoc credentials. Records are scoped to the current wallet session, giving multi-tenant isolation automatically. -The recommended workflow is: +Two key-creation workflows are supported: 1. **Generate** – ``POST /mso-mdoc/signing-keys`` creates a new EC P-256 - key pair server-side. The response includes the ``public_key_pem`` (and - optionally a CSR) so that the caller can submit the public key to an - IACA for certificate signing. -2. **Attach certificate** – ``PUT /mso-mdoc/signing-keys/{id}`` uploads the - CA-signed certificate once it has been obtained. - -For pre-existing keys already registered with a public trust registry, -use ``POST /mso-mdoc/signing-keys/import`` to load the private key and -certificate in a single step. + key pair server-side (following ACA-Py's convention of wallet-managed + key generation). The response includes the ``public_key_pem`` so the + caller can submit it to an IACA for certificate signing. +2. **Import** – ``POST /mso-mdoc/signing-keys/import`` loads a pre-existing + EC P-256 private key and (optionally) X.509 certificate in a single + step. Use this for keys produced by an HSM, IACA ceremony, or other + external tooling. + +In both cases, ``PUT /mso-mdoc/signing-keys/{id}`` can attach or replace +the CA-signed certificate once it has been obtained. """ from typing import Optional @@ -28,6 +29,10 @@ from marshmallow import fields +ALLOWED_CURVES = (ec.SECP256R1,) # P-256 only; extend as needed +"""EC curves accepted for mDoc signing keys (ISO 18013-5 mandates P-256).""" + + def generate_ec_p256_key_pem() -> str: """Generate an EC P-256 private key and return it as a PKCS8 PEM string.""" private_key = ec.generate_private_key(ec.SECP256R1()) @@ -38,6 +43,30 @@ def generate_ec_p256_key_pem() -> str: ).decode("utf-8") +def validate_ec_p256_private_key(private_key_pem: str) -> ec.EllipticCurvePrivateKey: + """Load a PEM private key and verify it is an EC key on an allowed curve. + + Returns the validated private key object. + Raises ``ValueError`` for non-EC keys or unsupported curves. + """ + private_key = serialization.load_pem_private_key( + private_key_pem.encode("utf-8"), password=None + ) + if not isinstance(private_key, ec.EllipticCurvePrivateKey): + raise ValueError( + "Only EC private keys are supported for mDoc signing; " + f"received {type(private_key).__name__}" + ) + if not any(isinstance(private_key.curve, c) for c in ALLOWED_CURVES): + curve_name = private_key.curve.name if private_key.curve else "unknown" + allowed = ", ".join(c.name for c in ALLOWED_CURVES) + raise ValueError( + f"EC curve '{curve_name}' is not supported for mDoc signing; " + f"allowed curves: {allowed}" + ) + return private_key + + def public_key_pem_from_private(private_key_pem: str) -> str: """Derive the PEM-encoded public key from a PEM-encoded private key.""" private_key = serialization.load_pem_private_key( @@ -244,6 +273,15 @@ class MdocSigningKeyImportSchema(OpenAPISchema): class MdocSigningKeyUpdateSchema(OpenAPISchema): """Request schema for ``PUT /mso-mdoc/signing-keys/{id}``.""" + private_key_pem = fields.Str( + required=False, + metadata={ + "description": ( + "PEM-encoded EC private key. Supply together with " + "certificate_pem to re-import after key rotation." + ) + }, + ) certificate_pem = fields.Str( required=False, metadata={ diff --git a/oid4vc/mso_mdoc/tests/test_cred_processor.py b/oid4vc/mso_mdoc/tests/test_cred_processor.py index 940f9a3f1..91a11095c 100644 --- a/oid4vc/mso_mdoc/tests/test_cred_processor.py +++ b/oid4vc/mso_mdoc/tests/test_cred_processor.py @@ -134,17 +134,25 @@ async def test_issue_calls_signer_correctly( key_rec.private_key_pem = "test-priv-key" key_rec.certificate_pem = "test-cert" + mock_backend = MagicMock() + mock_backend.resolve_signing_material = AsyncMock( + return_value={ + "private_key_pem": key_rec.private_key_pem, + "certificate_pem": key_rec.certificate_pem, + } + ) + mock_backend.sign_mdoc = AsyncMock( + return_value="oLHC0-T1" # base64url without padding + ) + with ( - patch("mso_mdoc.cred_processor.isomdl_mdoc_sign") as mock_sign, patch("mso_mdoc.cred_processor.check_certificate_not_expired"), patch( - "mso_mdoc.cred_processor.MdocSigningKeyRecord.query", + "mso_mdoc.signing_backend.MdocSigningKeyRecord.query", AsyncMock(return_value=[key_rec]), ), ): - mock_sign.return_value = ( - "oLHC0-T1" # base64url without padding as returned by isomdl-uniffi - ) + mock_context.profile.inject_or = MagicMock(return_value=mock_backend) # Setup input ex_record = MagicMock(spec=OID4VCIExchangeRecord) @@ -172,11 +180,11 @@ async def test_issue_calls_signer_correctly( # Verify result: issuer_signed_b64() returns base64url directly assert result == "oLHC0-T1" - # Verify signer was called with correct arguments - mock_sign.assert_called_once() - call_args = mock_sign.call_args - assert call_args[0][0] == pop.holder_jwk # holder_jwk - assert call_args[0][1]["doctype"] == "org.iso.18013.5.1.mDL" # headers - assert call_args[0][2] == sample_body # payload - assert call_args[0][3] == "test-cert" # cert - assert call_args[0][4] == "test-priv-key" # priv key + # Verify backend was called + mock_backend.sign_mdoc.assert_called_once() + call_args = mock_backend.sign_mdoc.call_args + # signing_material, holder_jwk, headers, payload + assert call_args[0][0]["certificate_pem"] == "test-cert" + assert call_args[0][0]["private_key_pem"] == "test-priv-key" + assert call_args[0][1]["kty"] == "EC" # holder_jwk + assert call_args[0][2]["doctype"] == "org.iso.18013.5.1.mDL" # headers diff --git a/oid4vc/mso_mdoc/tests/test_expired_certificate.py b/oid4vc/mso_mdoc/tests/test_expired_certificate.py index aa1f0d5f2..87850194b 100644 --- a/oid4vc/mso_mdoc/tests/test_expired_certificate.py +++ b/oid4vc/mso_mdoc/tests/test_expired_certificate.py @@ -251,13 +251,23 @@ async def test_issue_raises_when_certificate_is_expired(self): ex_record = self._make_ex_record() pop = self._make_pop() + mock_backend = MagicMock() + mock_backend.resolve_signing_material = AsyncMock( + return_value={ + "private_key_pem": private_key_pem, + "certificate_pem": expired_cert_pem, + } + ) + mock_backend.sign_mdoc = AsyncMock() + with ( - patch("mso_mdoc.cred_processor.isomdl_mdoc_sign") as mock_sign, patch( - "mso_mdoc.cred_processor.MdocSigningKeyRecord.query", + "mso_mdoc.signing_backend.MdocSigningKeyRecord.query", AsyncMock(return_value=[key_rec]), ), ): + profile.inject_or = MagicMock(return_value=mock_backend) + context = self._make_admin_context(profile) processor = MsoMdocCredProcessor() with pytest.raises(CredProcessorError, match=r"(?i)expir"): @@ -269,8 +279,8 @@ async def test_issue_raises_when_certificate_is_expired(self): context=context, ) - # The Rust signer must NEVER have been called with an expired cert. - mock_sign.assert_not_called() + # The signing backend must NEVER have been called with an expired cert. + mock_backend.sign_mdoc.assert_not_called() @pytest.mark.asyncio async def test_issue_succeeds_when_certificate_is_valid(self): @@ -287,16 +297,25 @@ async def test_issue_succeeds_when_certificate_is_valid(self): ex_record = self._make_ex_record() pop = self._make_pop() + mock_backend = MagicMock() + mock_backend.resolve_signing_material = AsyncMock( + return_value={ + "private_key_pem": private_key_pem, + "certificate_pem": valid_cert_pem, + } + ) + mock_backend.sign_mdoc = AsyncMock( + return_value="oLHC0-T1", # base64url without padding + ) + with ( patch( - "mso_mdoc.cred_processor.isomdl_mdoc_sign", - return_value="oLHC0-T1", # base64url without padding as returned by isomdl-uniffi - ), - patch( - "mso_mdoc.cred_processor.MdocSigningKeyRecord.query", + "mso_mdoc.signing_backend.MdocSigningKeyRecord.query", AsyncMock(return_value=[key_rec]), ), ): + profile.inject_or = MagicMock(return_value=mock_backend) + context = self._make_admin_context(profile) processor = MsoMdocCredProcessor() try: diff --git a/oid4vc/mso_mdoc/tests/test_trust_registry.py b/oid4vc/mso_mdoc/tests/test_trust_registry.py index a99f2f8ae..880975e6c 100644 --- a/oid4vc/mso_mdoc/tests/test_trust_registry.py +++ b/oid4vc/mso_mdoc/tests/test_trust_registry.py @@ -2,7 +2,7 @@ Also covers the processor-layer functions that use these records: - ``_get_trust_anchors()`` — queries TrustAnchorRecord with doctype-aware filtering. -- ``_resolve_signing_key()`` — resolves signing key from registry or legacy fallback. +- ``_resolve_signing_material()`` — resolves signing material via pluggable backend. - ``_assign_status_entry()`` — assigns IETF Token Status List entry at issuance. """ @@ -289,12 +289,12 @@ async def test_reader_auth_records_excluded(self, profile): # --------------------------------------------------------------------------- -# _resolve_signing_key processor method tests +# _resolve_signing_material processor method tests # --------------------------------------------------------------------------- class TestResolveSigningKey: - """Tests for MsoMdocCredProcessor._resolve_signing_key().""" + """Tests for MsoMdocCredProcessor._resolve_signing_material().""" @pytest.fixture def processor(self): @@ -320,7 +320,7 @@ async def test_resolves_from_signing_key_record_by_doctype(self, processor): format="mso_mdoc", format_data={"doctype": "org.iso.18013.5.1.mDL"}, ) - result = await processor._resolve_signing_key(supported, fresh_profile) + result = await processor._resolve_signing_material(supported, fresh_profile) assert result["private_key_pem"] == "PRIV_KEY_FROM_RECORD" assert result["certificate_pem"] == "CERT_FROM_RECORD" diff --git a/oid4vc/mso_mdoc/trust_anchor_routes.py b/oid4vc/mso_mdoc/trust_anchor_routes.py index 7c36b67d3..47a4a05ae 100644 --- a/oid4vc/mso_mdoc/trust_anchor_routes.py +++ b/oid4vc/mso_mdoc/trust_anchor_routes.py @@ -4,10 +4,9 @@ - ``TrustAnchorRecord``: X.509 CA certificates trusted for mDoc verification - ``MdocSigningKeyRecord``: EC private keys and certificates for mDoc issuance -These records replace the previous pattern of cramming signing material and -trust anchors into ``SupportedCredential.vc_additional_data``. Using -``BaseRecord`` gives multi-tenant isolation automatically through profile -session scoping. +Signing keys can be generated server-side (following ACA-Py's wallet-managed +key creation pattern) or imported from external sources (HSM, IACA ceremony). +Curve validation (EC P-256) is enforced on both paths. """ import logging @@ -35,6 +34,7 @@ MdocSigningKeyImportSchema, MdocSigningKeyUpdateSchema, generate_ec_p256_key_pem, + validate_ec_p256_private_key, validate_cert_matches_private_key, ) @@ -69,7 +69,10 @@ class TrustAnchorQuerySchema(OpenAPISchema): doctype = fields.Str( required=False, - metadata={"description": "Filter by doctype", "example": "org.iso.18013.5.1.mDL"}, + metadata={ + "description": "Filter by doctype", + "example": "org.iso.18013.5.1.mDL", + }, ) purpose = fields.Str( required=False, @@ -82,7 +85,10 @@ class SigningKeyQuerySchema(OpenAPISchema): doctype = fields.Str( required=False, - metadata={"description": "Filter by doctype", "example": "org.iso.18013.5.1.mDL"}, + metadata={ + "description": "Filter by doctype", + "example": "org.iso.18013.5.1.mDL", + }, ) label = fields.Str( required=False, @@ -252,8 +258,9 @@ async def create_signing_key(request: web.Request): async def import_signing_key(request: web.Request): """Import a pre-existing EC signing key and optional certificate. - Use this for keys already registered with a public trust registry - (IACA, etc.) that cannot be regenerated. + The private key must be an EC key on the P-256 curve (as required by + ISO 18013-5). Use this for keys generated externally, e.g. via an + HSM, IACA key ceremony, or local tooling. """ context: AdminRequestContext = request["context"] body = await request.json() @@ -262,6 +269,11 @@ async def import_signing_key(request: web.Request): if not private_key_pem: raise web.HTTPBadRequest(reason="private_key_pem is required for import") + try: + validate_ec_p256_private_key(private_key_pem) + except ValueError as err: + raise web.HTTPBadRequest(reason=str(err)) from err + certificate_pem = body.get("certificate_pem") if certificate_pem: try: @@ -295,6 +307,11 @@ async def update_signing_key(request: web.Request): If ``certificate_pem`` is provided, the certificate's public key is validated against the stored private key to prevent mismatched pairs. + + To re-import a key and certificate together (e.g. after certificate + renewal with a new key), supply both ``private_key_pem`` and + ``certificate_pem``. The new private key is validated for curve + compliance (P-256). """ context: AdminRequestContext = request["context"] signing_key_id = request.match_info["signing_key_id"] @@ -304,11 +321,23 @@ async def update_signing_key(request: web.Request): async with context.profile.session() as session: record = await MdocSigningKeyRecord.retrieve_by_id(session, signing_key_id) + private_key_pem = body.get("private_key_pem") certificate_pem = body.get("certificate_pem") - if certificate_pem and record.private_key_pem: + + # If a new private key is supplied, validate its curve + if private_key_pem: + try: + validate_ec_p256_private_key(private_key_pem) + except ValueError as err: + raise web.HTTPBadRequest(reason=str(err)) from err + record.private_key_pem = private_key_pem + + # Validate cert matches whichever private key will be stored + effective_private_key = private_key_pem or record.private_key_pem + if certificate_pem and effective_private_key: try: validate_cert_matches_private_key( - record.private_key_pem, certificate_pem + effective_private_key, certificate_pem ) except ValueError as err: raise web.HTTPBadRequest(reason=str(err)) from err