Skip to content

ISO mdoc (mso_mdoc)

Server-side verification of an OpenID4VP-delivered ISO 18013-5 mso_mdoc (experimental) — read-only IssuerAuth (a COSE_Sign1 MSO with an x5chain → IACA anchor and valueDigests) plus DeviceAuth over the W3C Digital Credentials API SessionTranscript. The COSE/CBOR is hand-rolled, so this adds no runtime dependency. Engagement, proximity, issuance and COSE signing stay out of scope (ADR-0005).

mdoc verification

openvc.mdoc

openvc.mdoc — verify a received ISO/IEC 18013-5 mso_mdoc (verify-only, server-side).

Given a DeviceResponse a wallet returned over OpenID4VP (ISO 18013-7 online), this checks the two authentications ISO 18013-5 §9.1 defines and nothing more (ADR-0005):

  • Issuer data authentication — the IssuerAuth COSE_Sign1 over the MobileSecurityObject (MSO); the document-signer x5chain (COSE label 33) path-validated to a caller-provided IACA trust anchor; the MSO docType and validityInfo window; and, for every disclosed IssuerSignedItem, the recomputed digest matched against MobileSecurityObject.valueDigests (the mdoc analogue of SD-JWT disclosure hashing).
  • Device authentication (holder binding) — the DeviceSignature (COSE_Sign1) or DeviceMac (COSE_Mac0) over the DeviceAuthentication structure built from the OpenID4VP / ISO 18013-7 SessionTranscript (the caller supplies its bytes — see :mod:openvc.openid4vp). This is the session/replay binding, the security-critical crux.

Out of scope, unchanged from the ROADMAP: device engagement, NFC/BLE/QR proximity, issuance / provisioning, and any COSE signing surface. This module consumes and verifies one received document. It is dependency-free (hand-rolled CBOR + COSE, reusing :mod:openvc.keys for the signature and :mod:openvc.x5c for the chain), fails closed on every check, and ships experimental until interop-tested against the EUDI reference wallet (ADR-0005 D7).

MdocError

Bases: OpenvcError

Base: an mdoc DeviceResponse is malformed or fails to verify.

Source code in src/openvc/mdoc.py
78
79
class MdocError(OpenvcError):
    """Base: an mdoc ``DeviceResponse`` is malformed or fails to verify."""

MdocMalformed

Bases: MdocError

The DeviceResponse / MSO / COSE structure is not well-formed.

Source code in src/openvc/mdoc.py
82
83
class MdocMalformed(MdocError):
    """The DeviceResponse / MSO / COSE structure is not well-formed."""

MdocTrustError

Bases: MdocError

The document-signer chain did not path-validate to an IACA trust anchor.

Source code in src/openvc/mdoc.py
86
87
class MdocTrustError(MdocError):
    """The document-signer chain did not path-validate to an IACA trust anchor."""

MdocSignatureInvalid

Bases: MdocError

The IssuerAuth (MSO) signature does not verify.

Source code in src/openvc/mdoc.py
90
91
class MdocSignatureInvalid(MdocError):
    """The IssuerAuth (MSO) signature does not verify."""

MdocDigestMismatch

Bases: MdocError

A disclosed IssuerSignedItem's digest does not match the MSO valueDigests.

Source code in src/openvc/mdoc.py
94
95
class MdocDigestMismatch(MdocError):
    """A disclosed IssuerSignedItem's digest does not match the MSO ``valueDigests``."""

MdocValidityError

Bases: MdocError

The MSO validityInfo window does not include the evaluation instant.

Source code in src/openvc/mdoc.py
98
99
class MdocValidityError(MdocError):
    """The MSO ``validityInfo`` window does not include the evaluation instant."""

MdocDocTypeMismatch

Bases: MdocError

The MSO docType disagrees with the document (or the expected docType).

Source code in src/openvc/mdoc.py
102
103
class MdocDocTypeMismatch(MdocError):
    """The MSO ``docType`` disagrees with the document (or the expected docType)."""

MdocDeviceAuthError

Bases: MdocError

Device authentication (holder binding) is missing or does not verify.

Source code in src/openvc/mdoc.py
106
107
class MdocDeviceAuthError(MdocError):
    """Device authentication (holder binding) is missing or does not verify."""

MdocValidity dataclass

The MSO validityInfo timestamps (ISO 18013-5 §9.1.2.4).

Source code in src/openvc/mdoc.py
114
115
116
117
118
119
@dataclass(frozen=True)
class MdocValidity:
    """The MSO ``validityInfo`` timestamps (ISO 18013-5 §9.1.2.4)."""
    signed: datetime | None
    valid_from: datetime | None
    valid_until: datetime | None

VerifiedMdoc dataclass

One verified mdoc document.

namespaces maps each disclosed namespace to {elementIdentifier: elementValue} — only the items whose digest matched the issuer's seal. device_signed is True when holder binding (DeviceAuth) was verified; it is False for a result from :func:verify_issuer_signed, which checks issuer data authentication only.

Source code in src/openvc/mdoc.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
@dataclass(frozen=True)
class VerifiedMdoc:
    """One verified mdoc document.

    *namespaces* maps each disclosed namespace to ``{elementIdentifier: elementValue}`` —
    only the items whose digest matched the issuer's seal. *device_signed* is ``True`` when
    holder binding (DeviceAuth) was verified; it is ``False`` for a result from
    :func:`verify_issuer_signed`, which checks issuer data authentication only.
    """
    doc_type: str
    namespaces: dict[str, dict[str, Any]]
    device_signed: bool
    issuer_key: dict[str, Any]
    validity: MdocValidity

    def elements(self, namespace: str) -> dict[str, Any]:
        """The disclosed ``{elementIdentifier: elementValue}`` for *namespace* (empty if
        the document disclosed nothing under it)."""
        return dict(self.namespaces.get(namespace, {}))

elements(namespace)

The disclosed {elementIdentifier: elementValue} for namespace (empty if the document disclosed nothing under it).

Source code in src/openvc/mdoc.py
137
138
139
140
def elements(self, namespace: str) -> dict[str, Any]:
    """The disclosed ``{elementIdentifier: elementValue}`` for *namespace* (empty if
    the document disclosed nothing under it)."""
    return dict(self.namespaces.get(namespace, {}))

verify_device_response(device_response, *, trust_anchors, session_transcript, now=None, leeway_s=DEFAULT_LEEWAY_S, expected_doc_type=None, device_mac_key=None)

Verify a received DeviceResponse (CBOR bytes) end to end — issuer data authentication and device authentication — and return one :class:VerifiedMdoc per document.

trust_anchors are the IACA root x509.Certificate objects the document-signer chain must validate to (e.g. openvc.trustlist anchors). session_transcript is the CBOR-encoded ISO 18013-7 / OpenID4VP SessionTranscript the DeviceAuth is bound to — build it with :func:openvc.openid4vp from the request's client_id / origin, nonce and the mdoc-generated nonce. now pins the instant for the validityInfo and chain-validity checks (defaults to now, UTC). Pass expected_doc_type to require every document to be that docType. device_mac_key supplies the ISO 18013-5 EMacKey for a DeviceMac binding (proximity); the OpenID4VP online flow uses DeviceSignature and does not need it.

Raises a typed :class:MdocError (fail closed) on any malformed structure or failed check; a single document failing rejects the whole response.

Source code in src/openvc/mdoc.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def verify_device_response(
    device_response: bytes,
    *,
    trust_anchors: Sequence[Any],
    session_transcript: bytes,
    now: datetime | None = None,
    leeway_s: int = DEFAULT_LEEWAY_S,
    expected_doc_type: str | None = None,
    device_mac_key: bytes | None = None,
) -> list[VerifiedMdoc]:
    """Verify a received ``DeviceResponse`` (CBOR bytes) end to end — issuer data
    authentication **and** device authentication — and return one
    :class:`VerifiedMdoc` per document.

    *trust_anchors* are the IACA root ``x509.Certificate`` objects the document-signer
    chain must validate to (e.g. ``openvc.trustlist`` anchors). *session_transcript* is
    the CBOR-encoded ISO 18013-7 / OpenID4VP ``SessionTranscript`` the ``DeviceAuth``
    is bound to — build it with :func:`openvc.openid4vp` from the request's
    client_id / origin, nonce and the mdoc-generated nonce. *now* pins the instant for
    the ``validityInfo`` and chain-validity checks (defaults to now, UTC). Pass
    *expected_doc_type* to require every document to be that ``docType``.
    *device_mac_key* supplies the ISO 18013-5 ``EMacKey`` for a ``DeviceMac`` binding
    (proximity); the OpenID4VP online flow uses ``DeviceSignature`` and does not need it.

    Raises a typed :class:`MdocError` (fail closed) on any malformed structure or failed
    check; a single document failing rejects the whole response.
    """
    documents = _parse_response(device_response)
    results = []
    for document in documents:
        parts = _verify_issuer_signed(
            document, trust_anchors=trust_anchors, now=now, leeway_s=leeway_s,
            expected_doc_type=expected_doc_type)
        _verify_device_auth(document, parts, session_transcript, device_mac_key)
        results.append(VerifiedMdoc(
            doc_type=parts.doc_type, namespaces=parts.namespaces, device_signed=True,
            issuer_key=parts.issuer_key, validity=parts.validity))
    return results

verify_issuer_signed(document, *, trust_anchors, now=None, leeway_s=DEFAULT_LEEWAY_S, expected_doc_type=None)

Verify issuer data authentication only for a single decoded mdoc document (the IssuerAuth seal over the MSO, the x5chain to an IACA anchor, the MSO validity window, and the valueDigests of every disclosed item) and return the disclosed claims.

This does not check device authentication (holder binding) — the returned :class:VerifiedMdoc has device_signed=False. Use it to verify the issuer seal of an mdoc at rest; for a presented mdoc use :func:verify_device_response, which also binds the holder to the session.

Source code in src/openvc/mdoc.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def verify_issuer_signed(
    document: Any,
    *,
    trust_anchors: Sequence[Any],
    now: datetime | None = None,
    leeway_s: int = DEFAULT_LEEWAY_S,
    expected_doc_type: str | None = None,
) -> VerifiedMdoc:
    """Verify **issuer data authentication only** for a single decoded mdoc *document*
    (the ``IssuerAuth`` seal over the MSO, the ``x5chain`` to an IACA anchor, the MSO
    validity window, and the ``valueDigests`` of every disclosed item) and return the
    disclosed claims.

    This does **not** check device authentication (holder binding) — the returned
    :class:`VerifiedMdoc` has ``device_signed=False``. Use it to verify the issuer seal of
    an mdoc at rest; for a presented mdoc use :func:`verify_device_response`, which also
    binds the holder to the session.
    """
    parts = _verify_issuer_signed(
        document, trust_anchors=trust_anchors, now=now, leeway_s=leeway_s,
        expected_doc_type=expected_doc_type)
    return VerifiedMdoc(
        doc_type=parts.doc_type, namespaces=parts.namespaces, device_signed=False,
        issuer_key=parts.issuer_key, validity=parts.validity)

COSE (COSE_Sign1 / COSE_Mac0)

openvc.cose

openvc.cose — verify COSE_Sign1 / COSE_Mac0 (RFC 9052), the signature layer of ISO 18013-5 mso_mdoc.

A verify-only, hand-rolled, dependency-free reader for the two COSE structures an mdoc verifier meets: COSE_Sign1 (the issuer's IssuerAuth over the MSO, and the holder's DeviceSignature) and COSE_Mac0 (the holder's DeviceMac). It parses the structure, rebuilds the Sig_structure / MAC_structure the signer covered (RFC 9052 §4.4 / §6.3), and checks it — reusing :func:openvc.keys.verify_signature (COSE ECDSA is the same raw R‖S as JOSE) and, for the MAC, a constant-time HMAC.

Like the JOSE path, the algorithm is allow-listed before any crypto runs: only ES256 (COSE -7), ES384 (-35) and EdDSA (-8) for signatures, and HMAC 256/256 (5) for the MAC. Everything else — RSA, ES512, the reserved values — is rejected up front, mirroring the JOSE {ES256, ES384, EdDSA} allow-list. There is no signing surface here (ADR-0005): openvc consumes and verifies.

CoseError

Bases: OpenvcError

A COSE structure is malformed, uses an unsupported algorithm, or fails to verify.

Source code in src/openvc/cose.py
73
74
class CoseError(OpenvcError):
    """A COSE structure is malformed, uses an unsupported algorithm, or fails to verify."""

CoseMalformed

Bases: CoseError

The COSE_Sign1 / COSE_Mac0 shape or a header is not well-formed.

Source code in src/openvc/cose.py
77
78
class CoseMalformed(CoseError):
    """The COSE_Sign1 / COSE_Mac0 shape or a header is not well-formed."""

CoseUnsupportedAlgorithm

Bases: CoseError

The COSE alg is not in the allow-list (rejected before any crypto).

Source code in src/openvc/cose.py
81
82
class CoseUnsupportedAlgorithm(CoseError):
    """The COSE ``alg`` is not in the allow-list (rejected before any crypto)."""

CoseSign1 dataclass

A parsed COSE_Sign1 (RFC 9052 §4.2): [protected, unprotected, payload, signature]. protected is the raw protected-header bstr (signed as-is); payload is the attached message bytes, or None when detached (nil).

Source code in src/openvc/cose.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
@dataclass(frozen=True)
class CoseSign1:
    """A parsed ``COSE_Sign1`` (RFC 9052 §4.2): ``[protected, unprotected, payload,
    signature]``. *protected* is the raw protected-header bstr (signed as-is);
    *payload* is the attached message bytes, or ``None`` when detached (nil)."""
    protected: bytes
    protected_header: dict[Any, Any]
    unprotected: dict[Any, Any]
    payload: bytes | None
    signature: bytes

    @property
    def alg(self) -> int:
        """The signature algorithm (COSE label 1) from the **protected** header. Raises
        :class:`CoseMalformed` if absent or not an integer (RFC 9052 §3.1: `alg` is
        integrity-critical, so it is never taken from the unsigned unprotected header)."""
        return _require_alg(self.protected_header)

alg property

The signature algorithm (COSE label 1) from the protected header. Raises :class:CoseMalformed if absent or not an integer (RFC 9052 §3.1: alg is integrity-critical, so it is never taken from the unsigned unprotected header).

CoseMac0 dataclass

A parsed COSE_Mac0 (RFC 9052 §6.2): [protected, unprotected, payload, tag]. Same shape as :class:CoseSign1 with the MAC in tag.

Source code in src/openvc/cose.py
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclass(frozen=True)
class CoseMac0:
    """A parsed ``COSE_Mac0`` (RFC 9052 §6.2): ``[protected, unprotected, payload,
    tag]``. Same shape as :class:`CoseSign1` with the MAC in *tag*."""
    protected: bytes
    protected_header: dict[Any, Any]
    unprotected: dict[Any, Any]
    payload: bytes | None
    tag: bytes

    @property
    def alg(self) -> int:
        return _require_alg(self.protected_header)

parse_sign1(obj)

Parse a COSE_Sign1 (tagged #6.18 or bare). Does not verify.

Source code in src/openvc/cose.py
179
180
181
182
183
184
185
186
187
188
189
190
191
def parse_sign1(obj: Any) -> CoseSign1:
    """Parse a ``COSE_Sign1`` (tagged ``#6.18`` or bare). Does **not** verify."""
    protected_raw, unprotected, payload, signature = _unwrap(obj, cbor.TAG_COSE_SIGN1, "COSE_Sign1")
    protected, header = _parse_protected(protected_raw, "COSE_Sign1")
    if not isinstance(unprotected, dict):
        raise CoseMalformed("COSE_Sign1: unprotected header must be a map")
    if payload is not None and not isinstance(payload, (bytes, bytearray)):
        raise CoseMalformed("COSE_Sign1: payload must be a byte string or nil")
    if not isinstance(signature, (bytes, bytearray)):
        raise CoseMalformed("COSE_Sign1: signature must be a byte string")
    return CoseSign1(
        protected=protected, protected_header=header, unprotected=unprotected,
        payload=None if payload is None else bytes(payload), signature=bytes(signature))

parse_mac0(obj)

Parse a COSE_Mac0 (tagged #6.17 or bare). Does not verify.

Source code in src/openvc/cose.py
194
195
196
197
198
199
200
201
202
203
204
205
206
def parse_mac0(obj: Any) -> CoseMac0:
    """Parse a ``COSE_Mac0`` (tagged ``#6.17`` or bare). Does **not** verify."""
    protected_raw, unprotected, payload, tag = _unwrap(obj, cbor.TAG_COSE_MAC0, "COSE_Mac0")
    protected, header = _parse_protected(protected_raw, "COSE_Mac0")
    if not isinstance(unprotected, dict):
        raise CoseMalformed("COSE_Mac0: unprotected header must be a map")
    if payload is not None and not isinstance(payload, (bytes, bytearray)):
        raise CoseMalformed("COSE_Mac0: payload must be a byte string or nil")
    if not isinstance(tag, (bytes, bytearray)):
        raise CoseMalformed("COSE_Mac0: tag must be a byte string")
    return CoseMac0(
        protected=protected, protected_header=header, unprotected=unprotected,
        payload=None if payload is None else bytes(payload), tag=bytes(tag))

verify_sign1(sign1, *, public_jwk, detached_payload=None, external_aad=b'')

Verify a COSE_Sign1 against public_jwk. Pass detached_payload when the payload field is nil (DeviceSignature over DeviceAuthenticationBytes); for an attached payload (IssuerAuth over the MSO) leave it None. The alg is allow-listed first (raises :class:CoseUnsupportedAlgorithm); returns True/False.

Source code in src/openvc/cose.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def verify_sign1(
    sign1: CoseSign1,
    *,
    public_jwk: dict[str, Any],
    detached_payload: bytes | None = None,
    external_aad: bytes = b"",
) -> bool:
    """Verify a ``COSE_Sign1`` against *public_jwk*. Pass *detached_payload* when the
    payload field is nil (``DeviceSignature`` over ``DeviceAuthenticationBytes``); for an
    attached payload (``IssuerAuth`` over the MSO) leave it ``None``. The ``alg`` is
    allow-listed first (raises :class:`CoseUnsupportedAlgorithm`); returns ``True``/``False``."""
    jose_alg = COSE_ALG_TO_JOSE.get(sign1.alg)
    if jose_alg is None:
        raise CoseUnsupportedAlgorithm(
            f"COSE_Sign1 alg {sign1.alg} is not allow-listed (need one of "
            f"{sorted(COSE_ALG_TO_JOSE)} = ES256/ES384/EdDSA)")
    payload = _effective_payload(sign1.payload, detached_payload, "COSE_Sign1")
    tbs = _tbs("Signature1", sign1.protected, external_aad, payload)
    return verify_signature(
        alg=jose_alg, public_jwk=public_jwk, signing_input=tbs, signature=sign1.signature)

verify_mac0(mac0, *, mac_key, detached_payload=None, external_aad=b'')

Verify a COSE_Mac0 tag with mac_key (the derived EMacKey) under HMAC-SHA-256, constant-time. Only HMAC 256/256 (alg 5) is accepted.

Source code in src/openvc/cose.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def verify_mac0(
    mac0: CoseMac0,
    *,
    mac_key: bytes,
    detached_payload: bytes | None = None,
    external_aad: bytes = b"",
) -> bool:
    """Verify a ``COSE_Mac0`` tag with *mac_key* (the derived ``EMacKey``) under
    HMAC-SHA-256, constant-time. Only ``HMAC 256/256`` (alg 5) is accepted."""
    if mac0.alg != _COSE_HMAC_256:
        raise CoseUnsupportedAlgorithm(
            f"COSE_Mac0 alg {mac0.alg} is not allow-listed (need 5 = HMAC 256/256)")
    payload = _effective_payload(mac0.payload, detached_payload, "COSE_Mac0")
    tbm = _tbs("MAC0", mac0.protected, external_aad, payload)
    expected = hmac.new(mac_key, tbm, hashlib.sha256).digest()
    return hmac.compare_digest(expected, mac0.tag)

x5chain_ders(sign1)

The certificate chain (label 33) as DER bytes, leaf first. A single certificate may appear as one bstr; a chain is an array of bstr. Checks the unprotected header then the protected one. Raises :class:CoseMalformed if absent or malformed.

Source code in src/openvc/cose.py
271
272
273
274
275
276
277
278
279
280
281
282
def x5chain_ders(sign1: CoseSign1) -> list[bytes]:
    """The certificate chain (label 33) as DER bytes, leaf first. A single certificate
    may appear as one bstr; a chain is an array of bstr. Checks the unprotected header
    then the protected one. Raises :class:`CoseMalformed` if absent or malformed."""
    value = sign1.unprotected.get(_HDR_X5CHAIN, sign1.protected_header.get(_HDR_X5CHAIN))
    if value is None:
        raise CoseMalformed("COSE_Sign1 has no x5chain (label 33) to anchor the signer")
    if isinstance(value, (bytes, bytearray)):
        return [bytes(value)]
    if isinstance(value, list) and value and all(isinstance(c, (bytes, bytearray)) for c in value):
        return [bytes(c) for c in value]
    raise CoseMalformed("x5chain must be a certificate bstr or a non-empty array of bstr")

cose_key_to_jwk(cose_key)

Convert a COSE_Key (RFC 9052 §7) map to a public JWK. Supports EC2 (P-256 / P-384) and OKP (Ed25519) — the curves :func:openvc.keys.verify_signature verifies. Raises :class:CoseMalformed on any other key type/curve or a malformed coordinate.

Source code in src/openvc/cose.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
def cose_key_to_jwk(cose_key: Any) -> dict[str, Any]:
    """Convert a ``COSE_Key`` (RFC 9052 §7) map to a public JWK. Supports EC2 (P-256 /
    P-384) and OKP (Ed25519) — the curves :func:`openvc.keys.verify_signature` verifies.
    Raises :class:`CoseMalformed` on any other key type/curve or a malformed coordinate."""
    if not isinstance(cose_key, dict):
        raise CoseMalformed("COSE_Key must be a CBOR map")
    kty = cose_key.get(_KEY_KTY)
    if kty == _KTY_EC2:
        crv = cose_key.get(_KEY_CRV)
        info = (_EC2_CRV_TO_JWK.get(crv)
                if isinstance(crv, int) and not isinstance(crv, bool) else None)
        if info is None:
            raise CoseMalformed(f"COSE_Key EC2 curve {crv!r} is not P-256 or P-384")
        name, size = info
        x, y = cose_key.get(_KEY_X), cose_key.get(_KEY_Y)
        if not isinstance(x, (bytes, bytearray)) or not isinstance(y, (bytes, bytearray)):
            raise CoseMalformed("COSE_Key EC2 needs byte-string x (-2) and y (-3) coordinates")
        if len(x) != size or len(y) != size:
            raise CoseMalformed(f"COSE_Key EC2 {name} coordinates must be {size} bytes")
        return {"kty": "EC", "crv": name, "x": _b64url(bytes(x)), "y": _b64url(bytes(y))}
    if kty == _KTY_OKP:
        crv = cose_key.get(_KEY_CRV)
        if crv != _CRV_ED25519 or isinstance(crv, bool):
            raise CoseMalformed(f"COSE_Key OKP curve {crv!r} is not Ed25519")
        x = cose_key.get(_KEY_X)
        if not isinstance(x, (bytes, bytearray)) or len(x) != 32:
            raise CoseMalformed("COSE_Key OKP Ed25519 needs a 32-byte x (-2)")
        return {"kty": "OKP", "crv": "Ed25519", "x": _b64url(bytes(x))}
    raise CoseMalformed(f"COSE_Key kty {kty!r} is not EC2 (2) or OKP (1)")