Case Study: ISO mdoc
Longfellow ships a production-grade verifier for the ISO/IEC 18013-5 mobile driving licence (mdoc) credential — selective-disclosure proofs over a CBOR-encoded credential signed by an issuer and bound to a device key. The verifier lives in lib/circuits/mdoc/ and is the largest integration in the upstream tree. It is worth reading even if you are not building an identity system: the patterns translate directly to TPM2 quotes, Intel TDX TDREPORTs, JWT / JWS bodies, and any other signed structured document where the prover wants to reveal a few fields and hide the rest.
This page walks through the module end-to-end, treating it as the canonical realised instance of the signed-document pattern. Everything described here is in the source; every filename below is a real header in the repo.
What the circuit proves
A single call to run_mdoc_prover() produces a proof that, given:
- an issuer public key
(pk_x, pk_y), - a session transcript (the verifier’s challenge bytes),
- a list of requested attributes
(namespace_id, element_id, claimed_cbor_value), - a current time
now,
there exists an mdoc credential signed by the issuer whose MSO (Mobile Security Object) contains digests of the requested attributes, whose validity window contains now, and which is bound to a device key that also signed the transcript — without revealing the full credential. The caller learns only which attributes were disclosed and that they matched the claimed CBOR values; everything else stays hidden.
The C ABI surface
The module is exposed as a C ABI — the only place in Longfellow that crosses out of C++. Three entry points matter:
MdocProverErrorCode run_mdoc_prover(
const uint8_t* bcp, size_t bcsz, // compressed circuit blob
const uint8_t* mdoc, size_t mdoc_len, // raw mdoc bytes
const char* pkx, const char* pky, // issuer pubkey (hex)
const uint8_t* transcript, size_t tr_len,
const RequestedAttribute* attrs, size_t attrs_len,
const char* now, // ISO 8601
uint8_t** prf, size_t* proof_len, // output
const ZkSpecStruct* zk_spec);
MdocVerifierErrorCode run_mdoc_verifier(
/* same shape, plus proof bytes and docType */);
CircuitGenerationErrorCode generate_circuit(
const ZkSpecStruct* zk_spec,
uint8_t** cb, size_t* clen); // output: zlib-compressed circuitgenerate_circuit() produces the compressed circuit blob that both prover and verifier then consume. Callers typically run it once per (attribute count, spec version) tuple and cache the result. A fourth entry point, circuit_id(), writes a 32-byte identifier into an output buffer by parsing the input bytes into two circuits (a hash circuit and a signature circuit), computing each circuit’s individual id, and then computing the SHA-256 hash of those two ids — used for cache keys and transcript binding. See Compiling & Caching Circuits for that pattern.
Two circuits, two fields
The headline architectural detail: mdoc verification is not a single monolithic circuit. It is two circuits over two different fields, composed by the top-level prover.
The signature circuit lives in the prime field that matches the curve: Fp256Base (the P-256 base field). It verifies the two ECDSA signatures — issuer over MSO, device over transcript — using the ECDSA gadget, and additionally runs the polynomial MAC gadget on three values (see below).
The hash circuit lives in GF(2^{128}). It runs SHA-256 over the tagged MSO, uses the in-circuit CBOR parser to pick out specific byte ranges (validity window, device key info, attribute digests), and verifies those ranges against witness-supplied values. The binary field is chosen because SHA-256 and byte-level CBOR parsing are overwhelmingly XOR-heavy; the characteristic-two arithmetic makes them cheap.
The two circuits share a single Fiat-Shamir transcript and a single Ligero commitment, so the proof blob is one object even though the circuits run over different fields. See mdoc_zk.cc for the composition.
CBOR: host-side and in-circuit
Parsing CBOR inside a fixed-topology circuit is impossible in general — the format is recursive and unbounded. Longfellow handles this by splitting the work:
- Host-side decoder (
lib/cbor/host_decoder.h). ACborDocclass parses the full mdoc device response on the caller’s side, validates the structure, and records offsets and lengths into the input buffer: where the MSO lives, where each namespace lives, where each attribute lives. These offsets become witness values. - In-circuit decoder (
lib/circuits/cbor_parser/). The circuit does not re-parse the full CBOR tree. Instead, it receives the witness-supplied offsets and uses the byte-range selection pattern from Parsing Structured Bytes in Circuit —Routing::shiftfor range extraction,Memcmpfor tag and length checks — to prove that the bytes at those offsets look like correctly tagged CBOR fields. The prover cannot fake a field without matching the CBOR encoding bit-for-bit.
The consequence: the host does full CBOR validation; the circuit does structural CBOR validation on extracted byte ranges. The prover cannot get away with claiming “byte range 1024..1088 is the family-name attribute” if those bytes are not in fact a CBOR-tagged text string of the declared length.
MAC verification and the random challenge
After the prover commits to the witness, the transcript is extended with a random 128-bit challenge av. The prover then computes MACs on three critical values — the MSO hash e, and the device public-key coordinates dpkx and dpky — using av as the MAC key, and the verifier replays the same computation. The MAC gadget is a polynomial MAC over \mathrm{GF}(2^{128}); see MAC Gadget.
Why bother? The Ligero commitment pins the witness before the challenge is drawn, so the prover cannot adaptively choose values after seeing av. The MAC check then forces the prover to reveal the exact values of e, dpkx, and dpky as part of the proof — not merely commit to them. Those values need to be visible to the verifier because they flow into the ECDSA identity check in the base field (for dpkx, dpky) and into the SHA-256 output check (for e), but naively revealing them would break zero knowledge. The MAC is the workaround: the prover reveals MACs of the values under a post-commitment challenge, and the ECDSA and hash circuits check consistency against the revealed values rather than against the raw witness.
Circuit generation, caching, and version negotiation
generate_circuit() is parameterised by num_attributes and a version tag. The module ships a fixed table kZkSpecs[12] in mdoc_zk.h enumerating every supported (attribute count, version) pair, along with the expected 32-byte circuit id for each. The prover and verifier use this table to agree on a spec out-of-band — typically the verifier picks one when it issues its request and the prover’s ABI call references that spec via ZkSpecStruct*.
Because compilation is slow and the output is stable per spec tuple, in practice both sides cache the compressed circuit blob keyed by spec id. The blob is zlib-compressed — the raw circuit runs to a few MB, the compressed blob to roughly 100 KB. mdoc_decompress.h handles decompression on load.
This pattern — fixed spec table, compile-once-ship-bytes, caller-facing id — is the attestation-shape take on the same machinery described in Compiling & Caching Circuits. The difference is only that mdoc hardcodes the allowed specs; a bespoke TPM2 verifier would substitute its own table.
Error taxonomy
MdocProverErrorCode and MdocVerifierErrorCode together enumerate 48 distinct failure modes in mdoc_zk.h (36 prover codes + 12 verifier codes). Categories that matter:
- Bad caller input.
MDOC_*_NULL_INPUT,MDOC_*_INVALID_INPUT,MDOC_*_INVALID_ZK_SPEC_VERSION. Missing or malformed arguments; fix by checking the caller. - mdoc malformed.
MDOC_PROVER_ROOT_DECODING_FAILURE,MDOC_PROVER_MSO_MISSING,MDOC_PROVER_DEVICE_KEY_MISSING, and about a dozen siblings. The host-side CBOR decoder rejected the credential. - Constraint violation.
MDOC_PROVER_ATTRIBUTE_TOO_LONG,MDOC_PROVER_TAGGED_MSO_TOO_BIG. The credential is valid but exceeds the compiled circuit’s bounds (e.g., more SHA blocks than the current spec supports). Fix by bumping the spec, not by patching the credential. - Cryptographic failure.
MDOC_PROVER_SIGNATURE_FAILURE,MDOC_PROVER_DEVICE_SIGNATURE_FAILURE. The issuer or device signature did not verify; this is a real authenticity failure, not a bug. - System failure.
MDOC_*_MEMORY_ALLOCATION_FAILURE,MDOC_*_CIRCUIT_PARSING_FAILURE. Infrastructure problems.
The categorisation is not explicit in the enum, but it shows up clearly in the per-value naming conventions.
Not in this module
Two omissions worth calling out:
- OpenID4VP glue. The module is a pure ZK ABI; it knows nothing about OpenID4VP request formats, signing-parameter negotiation, or cross-device flows. Those belong in the caller (see
reference/verifier-service/in the upstream tree for a reference wiring). - Pre-compiled circuit blobs. The library ships source and the
kZkSpecsmetadata table, but not the compiled circuits themselves. Every deployment compiles its own — fast enough to do at build time, slow enough that you want to cache the result.
What this teaches for other attestation formats
The mdoc-specific pieces — CBOR tag decoding, ISO 18013-5 attribute taxonomy, the MSO / DeviceAuth schema — do not transfer to TPM2 or TDX. Everything else does:
- The two-circuit split (prime-field signature circuit + binary-field hash circuit) maps directly to any scheme where an ECDSA signature covers a SHA-256 digest of a structured blob. TPM2
TPMS_ATTESTand TDX TDQUOTE bodies both fit this shape. - The host-decoder-plus-circuit-structural-check pattern solves the variable-length-parsing problem for any tagged-length format. TLV, ASN.1 DER, TPM2’s internal marshalling, TDX’s attestation-report layout — all workable with the same primitive split.
- The post-commitment MAC trick is reusable any time you need to reveal a value the zero-knowledge layer would rather hide. Attestation measurements fall into this category whenever the verifier needs to check the measurement against a policy expressed outside the circuit.
- The
kZkSpecstable is a clean version-negotiation pattern. A bespoke verifier for a different format can ship its own analogous table keyed by(document_shape, version).
Read mdoc_zk_test.cc and mdoc_signature_test.cc for the end-to-end wiring; they are the shortest path from “I understand the pattern” to “I can see every call that matters.”
Related
- Verifying a Signed Document — the generic pattern this module realises.
- Parsing Structured Bytes in Circuit — the byte-range primitives the CBOR check uses.
- Compiling & Caching Circuits — the compile-once-ship-bytes pattern.
- SHA Gadget, ECDSA Gadget, MAC Gadget — the three in-circuit primitives the signature circuit composes.