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
IssuerAuthCOSE_Sign1over theMobileSecurityObject(MSO); the document-signerx5chain(COSE label 33) path-validated to a caller-provided IACA trust anchor; the MSOdocTypeandvalidityInfowindow; and, for every disclosedIssuerSignedItem, the recomputed digest matched againstMobileSecurityObject.valueDigests(the mdoc analogue of SD-JWT disclosure hashing). - Device authentication (holder binding) — the
DeviceSignature(COSE_Sign1) orDeviceMac(COSE_Mac0) over theDeviceAuthenticationstructure 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 | |
MdocMalformed
Bases: MdocError
The DeviceResponse / MSO / COSE structure is not well-formed.
Source code in src/openvc/mdoc.py
82 83 | |
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 | |
MdocSignatureInvalid
Bases: MdocError
The IssuerAuth (MSO) signature does not verify.
Source code in src/openvc/mdoc.py
90 91 | |
MdocDigestMismatch
Bases: MdocError
A disclosed IssuerSignedItem's digest does not match the MSO valueDigests.
Source code in src/openvc/mdoc.py
94 95 | |
MdocValidityError
Bases: MdocError
The MSO validityInfo window does not include the evaluation instant.
Source code in src/openvc/mdoc.py
98 99 | |
MdocDocTypeMismatch
Bases: MdocError
The MSO docType disagrees with the document (or the expected docType).
Source code in src/openvc/mdoc.py
102 103 | |
MdocDeviceAuthError
Bases: MdocError
Device authentication (holder binding) is missing or does not verify.
Source code in src/openvc/mdoc.py
106 107 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |