Wire protocol

How the request and response travel through the W3C Digital Credentials API as an ISO mdoc presentation: who builds each structure, what gets hashed, signed, and encrypted, and what each side checks. The steps follow spec §8, and each links its requirement. For what the JSON inside means, see Request and response.

  • Protocol org-iso-mdoc
  • docType org.smarthealthit.checkin.1
  • Namespace org.smarthealthit.checkin
  • Element smart_health_checkin_response
  • All fixed values §8.1

Strict to build, lenient to receive. Whoever builds a message builds exactly what the spec describes RCV-0. Whoever receives one stops only at a step marked fail; any other problem is a warn: it keeps going and reports it RCV-1 RCV-2. A bad signature is a warning, not a failure. See Security and timeouts for why.

How a request and response flow

1. Verifier page Builds the SMART request, wraps it in an mdoc request with a fresh HPKE key, and calls navigator.credentials.get.
2. Browser and platform Show the wallet picker and hand the request to the chosen wallet, with the calling page's origin. How this works on Android and iOS is in Platform notes.
3. Wallet Checks the request, computes the SessionTranscript, lets the patient choose, and builds a signed mdoc around the SMART response.
4. Wallet Encrypts the mdoc to the Verifier's key, bound to the same SessionTranscript.
5. Verifier page Recomputes the SessionTranscript, decrypts, checks the mdoc, and validates the SMART response against the request before using it.

1. Build the request verifier

Spec §8.2. The Verifier builds, in order:

The SMART request, inside an ItemsRequest VRQ-1 VRQ-2 VRQ-3

The SMART request JSON rides as a text string in requestInfo. The mdoc part asks for the one element, and its boolean is intentToRetain: true only if the Verifier may keep the response after the session. The whole ItemsRequest is CBOR-encoded and wrapped in tag 24, which fixes the exact bytes that reader authentication later signs.

ItemsRequest = {
  docType:    "org.smarthealthit.checkin.1",
  nameSpaces: { "org.smarthealthit.checkin": { smart_health_checkin_response: intentToRetain } },
  requestInfo: { "org.smarthealthit.checkin.request": "<SMART request JSON text>" }
}
ItemsRequestBytes = tag24(CBOR(ItemsRequest))

A fresh HPKE key and encryptionInfo VRQ-4 VRQ-5

A new P-256 key pair and a random nonce (16 bytes or more) for this request. The public key goes to the wallet inside encryptionInfo; the private key never leaves the page. The Verifier keeps the exact base64url string it sends, because the transcript is computed from that string VRQ-9.

EncryptionInfo = ["dcapi", {
  nonce:              bstr,
  recipientPublicKey: { 1: 2, -1: 1, -2: x, -3: y }   // COSE_Key, EC2 P-256
}]

Reader authentication, if used VRQ-6 RA-1

Optional. The Verifier signs ReaderAuthenticationBytes, which covers this session's SessionTranscript and the exact ItemsRequestBytes, with a key whose certificate goes in x5chain. A wallet that checks it puts the result in one of five states (absent, malformed, invalid, valid but untrusted, trusted) and shows a verifier identity only when trusted TRUST-2. It never stops the exchange WRQ-9. Without a deployment trust list, "valid but untrusted" is the normal result.

DeviceRequest, then the browser call VRQ-7 VRQ-8

DeviceRequest = { version: "1.0", docRequests: [{ itemsRequest: ItemsRequestBytes, readerAuth? }] }

navigator.credentials.get({
  mediation: "required",
  digital: { requests: [{ protocol: "org-iso-mdoc",
    data: { deviceRequest: base64url(DeviceRequest), encryptionInfo: base64url(EncryptionInfo) } }] }
})

Set a timeout on the call VRQ-10. On some platforms a failed delivery never settles the call (timeouts).

2. SessionTranscript verifier wallet

Spec §8.3. Both sides compute it separately, and it must come out byte for byte the same: it is the HPKE info, and it is inside what reader authentication and the device signature cover TR-5.

dcapiInfo         = CBOR([encryptionInfoBase64url, origin])
Handover          = ["dcapi", SHA-256(dcapiInfo)]
SessionTranscript = [null, null, Handover]      // HPKE info = CBOR(SessionTranscript)
  • encryptionInfoBase64url is the exact string the Verifier sent, not a re-encoding TR-1.
  • origin for a web page is its ASCII origin, such as https://clinic.example: no path, no trailing slash TR-2. For a native app, it is exactly what the platform reports; see Platform notes.
  • The wallet takes the origin only from the browser or platform, never from the request TR-3. With no origin it cannot respond TR-4.
  • The two null slots are ISO 18013-5's DeviceEngagementBytes and EReaderKeyBytes, which the Digital Credentials API flow doesn't use.

3. Check the request wallet

Spec §8.4. On a fail, the wallet does not respond, and the platform ends the call with an error WRQ-1.

StepCheckOn a problem
WRQ-2Decode deviceRequest (base64url, CBOR)fail if it can't be decoded; warn for another protocol name or padded base64url
WRQ-3DeviceRequest.version is "1.0"warn
WRQ-4Find the DocRequest with this profile's docTypefail if none; warn if several (use the first)
WRQ-5Take the request text from requestInfofail if missing; warn for other nameSpaces or intentToRetain problems
WRQ-6Validate the SMART request (spec §5)fail where §5 rejects it; problems inside one item make that item unsupported
WRQ-7Decode encryptionInfo and its P-256 keyfail if no usable key; warn for other shape problems
WRQ-8Compute the SessionTranscriptfail if there is no origin
WRQ-9Check readerAuth, if presentNever fails; classified as above

Then the patient chooses, item by item, and the wallet builds the SMART response WRQ-10.

4. Build the response wallet

Spec §8.4. The wallet signs its own mdoc: it acts as the issuer and as the device, with keys it controls. The signatures make the mdoc complete and verifiable; they don't say who issued the content (§7).

The issuer-signed item and its digest WRS-1

IssuerSignedItem = { digestID: 0, random: 16+ random bytes,
  elementIdentifier: "smart_health_checkin_response", elementValue: "<SMART response JSON text>" }
IssuerSignedItemBytes = tag24(CBOR(IssuerSignedItem))
digest = SHA-256(IssuerSignedItemBytes)          // tag included

Receivers hash these bytes exactly as received and never re-encode them ENC-2.

The MSO and issuerAuth WRS-2 WRS-3 WRS-4

MSO = { version: "1.0", digestAlgorithm: "SHA-256", docType: "org.smarthealthit.checkin.1",
  valueDigests:  { "org.smarthealthit.checkin": { digestID: digest } },
  deviceKeyInfo: { deviceKey: COSE_Key },
  validityInfo:  { signed, validFrom, validUntil } }     // tag-0 UTC date-times, no fractions
issuerAuth = COSE_Sign1(protected {1: -7}, unprotected {33: x5chain}, payload tag24(CBOR(MSO)))

The certificate in x5chain may be self-signed.

The device signature, detached WRS-5 WRS-6

DeviceAuthentication = ["DeviceAuthentication", SessionTranscript, "org.smarthealthit.checkin.1",
                        DeviceNameSpacesBytes]           // SessionTranscript as the array, not bytes
deviceSignature = COSE_Sign1(protected {1: -7}, payload null)
                  signed over tag24(CBOR(DeviceAuthentication)) with the MSO's deviceKey

The payload is null; the verifier rebuilds the signed bytes from its own transcript. DeviceNameSpacesBytes is tag24(CBOR({})).

The DeviceResponse WRS-7

DeviceResponse = { version: "1.0", status: 0, documents: [{
  docType: "org.smarthealthit.checkin.1",
  issuerSigned: { nameSpaces: { "org.smarthealthit.checkin": [IssuerSignedItemBytes] }, issuerAuth },
  deviceSigned: { nameSpaces: DeviceNameSpacesBytes, deviceAuth: { deviceSignature } } }] }

Every structure is defined in CDDL in §8.7; which bytes each hash and signature covers is in the table in §8.6.

5. Encrypt wallet

Spec §8.5 HPKE-1 HPKE-2. One fixed suite; nothing on the wire names another ALG-1.

ParameterValue
ModeHPKE base mode
KEMDHKEM(P-256, HKDF-SHA256), 0x0010
KDFHKDF-SHA256, 0x0001
AEADAES-128-GCM, 0x0001
infoCBOR(SessionTranscript)
aadempty
dcapiResponse = ["dcapi", { enc: 65-byte P-256 point, cipherText }]
result        = { protocol: "org-iso-mdoc", data: { response: base64url(CBOR(dcapiResponse)) } }

6. Check the response verifier

Spec §8.5. On a fail, the Verifier rejects the whole response VRS-1.

StepCheckOn a problem
VRS-2Decode data.response as ["dcapi", {enc, cipherText}]fail if it can't be decoded; warn for another protocol name or padding
VRS-3Recompute the transcript from its own origin and encryptionInfo string, and decryptfail if it doesn't open. HPKE is authenticated, so this also catches tampering in transit.
VRS-4Decode the DeviceResponse, find the document with this docTypefail if none; warn for version, status, or extra documents
VRS-5Verify issuerAuth with the first x5chain certificate; check MSO fieldswarn
VRS-6SHA-256 of IssuerSignedItemBytes as received equals the MSO digest for its digestIDwarn
VRS-7Rebuild DeviceAuthenticationBytes, verify the device signaturewarn, also for an attached payload that differs from the rebuilt bytes
VRS-8Find smart_health_checkin_response with a text valuefail if missing
VRS-9Parse and validate the SMART response against the requestfail only for the whole-response problems in §6.4; the rest affects one item or record (Request and response)
VRS-10MSO validityInfo includes now, give or take a few minuteswarn

Real bytes

Spec Appendix A walks one real Chrome-to-Android capture step by step: the transcript hash, the decryption, the digest, and all three signatures, with every value recomputed from the fixture on each build. The Capture inspector shows the same capture byte by byte, and can load a run from the Testing EHR.

Open the capture inspector →