How a request and response flow
Five hops from clinical intent to a rendered response. Each later step references the bytes the previous step produced.
org-iso-mdoc mdoc request, generates an HPKE recipient key,
and calls navigator.credentials.get.
HPKE opened, digest matched, four artifacts, and four fulfilled item statuses.
That means the browser origin, the SessionTranscript, the HPKE key schedule, and every digest line up across the two sides.
It does not by itself prove requester identity, clinical-source provenance, patient matching, or EHR write-back authorization.
Roles and what each owns
| Role | Owns | Sends | Trust signal |
|---|---|---|---|
| Verifier page | SMART intent, HPKE recipient keypair, nonce | navigator.credentials.get(...) |
HPKE opening proves the response was encrypted to this request's retained key and transcript, not clinical provenance. |
| Browser | Web origin assertion | Credential Manager request plus callingAppInfo.origin |
Authenticated origin/equivalent is one input to policy; it is not organization identity by itself. |
| Android Credential Manager | Matcher dispatch, picker UI, PendingIntent delivery | ProviderGetCredentialRequest to the wallet |
Platform dispatch and user-selected entry; requester trust still comes from separate policy. |
| Wallet matcher | Picker entry display | One entry for org.smarthealthit.checkin.1 |
Only enough request structure to decide whether to offer the entry. |
| Wallet handler | FHIR data, holder review UI, mdoc response, device key | DigitalCredential wrapping a base64url CBOR response |
Origin, readerAuth, issuer/device evidence, holder choice, and clinical-source provenance remain separate states. |
Build the request verifier
The verifier turns clinical intent into an mdoc request that the wallet can decode and bind to an encryption key.
SMART intent JSON
Start with a typed clinical request — the same shape the SMART model explainer documents. Each item names a FHIR resource or questionnaire intent, plus accepted media types. Selectors are not disclosure limits.
{
"type": "smart-health-checkin-request",
"version": "1",
"id": "...",
"items": [...]
}
Encoding JSON. Why human / clinical-readable intent the wallet can show to the user.
Wrap as an mdoc ItemsRequest
Stringify the SMART JSON and place it at requestInfo["org.smarthealthit.checkin.request"].
Ask for the one stable mdoc element with intentToRetain=true — clinical workflows expect to ingest the artifacts into an EHR.
ItemsRequest = {
docType: "org.smarthealthit.checkin.1",
nameSpaces: { "org.smarthealthit.checkin": { smart_health_checkin_response: true } },
requestInfo: {
"org.smarthealthit.checkin.request": <SMART JSON string>
}
}
Encoding CBOR. Why mdoc readers and matchers see a fixed element name; the SMART payload rides as a string in requestInfo.
Tag-24 wrap into DeviceRequest
Re-encode the ItemsRequest as a CBOR byte string, wrap with tag 24 (encoded-CBOR-data-item), and place
in docRequests[0].itemsRequest. The tag-24 wrap is what later participants hash, so locking the bytes here matters.
DeviceRequest = {
version: "1.0",
docRequests: [{ itemsRequest: Tag(24, bstr .cbor ItemsRequest) }]
}
Encoding CBOR + tag 24. Why a stable byte boundary for downstream digests.
Generate an HPKE recipient keypair
Generate a fresh baseline ECDH P-256 keypair. The private key stays in the verifier page; the public key travels to the wallet so the wallet can encrypt to it. Deployment profiles can use another HPKE suite only when both parties support it through the wire identifiers.
recipientPublicKey = COSE_Key {
1: 2, // kty: EC2
-1: 1, // crv: P-256
-2: x,
-3: y // public coordinates
}
Math baseline ECDH on P-256 (HPKE DHKEM). Lifetime per request. Why binds the response ciphertext to this verifier session.
encryptionInfo
Wrap a fresh nonce and the recipient public key in a CBOR array tagged for the DC API.
encryptionInfo = CBOR([
"dcapi",
{
nonce: bstr,
recipientPublicKey: COSE_Key
}
])
Encoding CBOR. Why the wallet needs the same bytes the verifier sent so the transcript binding is reproducible on both sides.
Hand off to the DC API
Pack deviceRequest and encryptionInfo as base64url and call
navigator.credentials.get with protocol: "org-iso-mdoc".
navigator.credentials.get({
digital: {
requests: [{
protocol: "org-iso-mdoc",
data: {
deviceRequest: base64url(DeviceRequest),
encryptionInfo: base64url(encryptionInfo)
}
}]
}
})
Browser surface Web Digital Credentials API. Why the browser brokers the call to a vetted wallet, with user approval.
Bind origin and SessionTranscript wallet
When the wallet receives the request, it has to recover the same SessionTranscript bytes the verifier will compute later — that's what proves both sides agree on origin, encryption parameters, and request session.
COSE_Sign1 over DeviceAuthentication
includes the SessionTranscript as a payload field, and the HPKE seal uses the SessionTranscript as info.
If the two sides disagree on a single byte here, decryption and signature verification both fail.
Origin from a privileged caller
AndroidX exposes callingAppInfo.origin only when the wallet passes a privileged-caller allowlist
into getOrigin(...). The dev wallet reflects the actual caller signature for traceability;
production should pin trusted browser package names plus signing-cert fingerprints.
What it is an authenticated origin or approved equivalent (e.g. https://clinic.example). Caveat origin is a presentation-layer signal, not requester or organization identity by itself.
dcapiInfo
Build an intermediate CBOR array from the exact base64url encryptionInfo string (as the verifier sent it) and the origin.
dcapiInfo = CBOR([ encryptionInfoBase64Url, origin ])
Encoding CBOR. Why deterministic input for the handover hash.
Handover hash
Hash the encoded byte string with SHA-256 and tag with "dcapi" so other handover mechanisms (NFC, BLE) can coexist in the same field.
handover = [ "dcapi", SHA-256(dcapiInfo) ]
Math SHA-256. Why a fixed-size, transport-tagged binding to the request session.
SessionTranscript
The exact CBOR byte string used both as HPKE info and as a field inside the device-signed payload.
SessionTranscript = CBOR([ null, null, handover ])
Encoding CBOR. Why the two leading null slots are reserved for legacy DeviceEngagement / EReader handover; DC API uses only the third.
Build the response wallet
The wallet packs the SMART response into one mdoc element, then assembles the issuer and device signatures that prove its integrity and bind it to this verifier's request.
SMART response JSON
The wallet produces {type:"smart-health-checkin-response", version:"1", requestId, artifacts, requestStatus}.
Each artifact declares mediaType, media-type-specific payload fields, and the request-item ids it fulfills. Raw FHIR uses value plus fhirVersion; SMART Health Cards use value.verifiableCredential[]; extensions define their own typed fields. requestStatus[].item records whether each requested item was fulfilled, declined, unsupported, or errored.
Encoding JSON. Why the same shape the verifier later renders to the user.
IssuerSignedItem and tag-24 wrap
The SMART response JSON is the elementValue of one stable mdoc element. The item is canonical CBOR,
then wrapped as tag 24 — that wrapper is what gets hashed, so the bytes are pinned at this boundary.
IssuerSignedItem = {
digestID: 0,
random: random16,
elementIdentifier: "smart_health_checkin_response",
elementValue: <SMART response JSON string>
}
issuerSignedItemTag24 = Tag(24, bstr .cbor IssuerSignedItem)
Encoding CBOR + tag 24. Why the digest must be reproducible byte-for-byte on the verifier side.
Value digest
SHA-256 over the tag-24 bytes, not just the inner map.
valueDigest = SHA-256(issuerSignedItemTag24)
Math SHA-256. Why commits the issuer to one specific JSON byte string.
MobileSecurityObject (MSO)
Records the value digest, the docType, the device public key, the digest algorithm, and a validity window.
Encoded as CBOR; that CBOR becomes the payload of an issuer COSE_Sign1.
MSO = {
version: "1.0",
digestAlgorithm: "SHA-256",
valueDigests: { "org.smarthealthit.checkin": { 0: valueDigest } },
deviceKeyInfo: { deviceKey: COSE_Key },
docType: "org.smarthealthit.checkin.1",
validityInfo: { signed, validFrom, validUntil }
}
issuerAuth = COSE_Sign1(MSO, issuerKey)
Math baseline ES256 / SHA-256 fixture values. Why binds digest + deviceKey + docType + validity into one signed object. Mutually supported alternatives use the normal COSE/MSO identifiers.
DeviceAuthentication payload
The device signature payload binds the response to the verifier's request session.
DeviceAuthentication = Tag(24, CBOR([
"DeviceAuthentication",
SessionTranscript,
"org.smarthealthit.checkin.1",
Tag(24, bstr .cbor {})
]))
Why the SessionTranscript field is what makes a response un-replayable into a different verifier session.
Device signature
Sign the DeviceAuthentication payload with the wallet's device key — the same key whose public part the issuer pinned in the MSO.
deviceSignature = COSE_Sign1(DeviceAuthentication, deviceKey)
Math baseline ES256 fixture value. Why proves the device key signed for this session; holder approval remains a separate wallet/policy decision.
DeviceResponse envelope
DeviceResponse = {
version: "1.0",
documents: [{
docType: "org.smarthealthit.checkin.1",
issuerSigned: {
nameSpaces: { "org.smarthealthit.checkin": [issuerSignedItemTag24] },
issuerAuth: COSE_Sign1(MSO)
},
deviceSigned: {
nameSpaces: Tag(24, bstr .cbor {}),
deviceAuth: { deviceSignature: COSE_Sign1(DeviceAuthentication) }
}
}],
status: 0
}
Encoding CBOR. Why the bundle the verifier walks during parse and verify.
Encrypt with HPKE wallet
The wallet seals the plaintext DeviceResponse to the verifier's recipientPublicKey,
using the same SessionTranscript both sides computed.
| Parameter | Baseline fixture value |
|---|---|
| KEM | DHKEM(P-256, HKDF-SHA256) · suite id 0x0010 |
| KDF | HKDF-SHA256 · suite id 0x0001 |
| AEAD | AES-128-GCM · suite id 0x0001 |
| HPKE info | SessionTranscript bytes |
| AEAD AAD | empty byte string |
| Nonce | HPKE base nonce for sequence number 0 |
These algorithms are baseline required support and fixture choices,
not immutable pins. Mutually supported alternatives are allowed only
when carried through the existing COSE, MSO, HPKE,
encryptionInfo, and dcapiResponse identifiers.
The encrypted result is wrapped as a small CBOR array and returned to the browser through Credential Manager.
dcapiResponse = CBOR([
"dcapi",
{
enc: hpkeEphemeralPublicKeyRawP256,
cipherText: aesGcmCiphertextAndTag
}
])
DigitalCredential = {
protocol: "org-iso-mdoc",
data: { response: base64url(dcapiResponse) }
}
Parse and verify verifier
The verifier walks the response in reverse: open the seal, walk the signatures, compare digests, render artifacts.
Recompute SessionTranscript locally
The verifier never receives the wallet's transcript bytes — it must re-derive the same bytes from its own origin and the encryptionInfo it originally sent.
dcapiInfo = CBOR([ encryptionInfoBase64Url, myOrigin ]) handover = [ "dcapi", SHA-256(dcapiInfo) ] SessionTranscript = CBOR([ null, null, handover ])
If the bytes match the wallet's, HPKE-open and signature verify will succeed. If they don't, both fail.
HPKE-open the dcapiResponse
Extract enc and cipherText. Use the saved private key plus the wallet's ephemeral public key to derive a shared secret. For the baseline fixture suite, run HKDF-SHA256 with info = SessionTranscript bytes, then decrypt with AES-128-GCM, nonce 0, AAD empty.
plaintext = HPKE_Open({
kem: DHKEM(P-256, HKDF-SHA256),
kdf: HKDF-SHA256,
aead: AES-128-GCM,
privKey: recipientPrivKey,
enc: hpkeEphemeralPublicKeyRawP256,
info: SessionTranscript,
aad: <empty>,
ct: cipherText
})
Math baseline ECDH(P-256) → HKDF → AES-GCM. Why only this verifier holds the private half; the SessionTranscript info prevents replays into a different verifier session.
Walk the DeviceResponse
Decode the plaintext CBOR, step into documents[0], and pull out
issuerSigned.nameSpaces["org.smarthealthit.checkin"][0] (the IssuerSignedItem tag-24 wrapper)
and the issuer COSE_Sign1.
Verify the issuer signature
The MSO sits in the COSE_Sign1 payload. Verify the signature using the issuer certificate
or key evidence in the headers, then evaluate issuer trust under deployment policy.
verify_COSE_Sign1(issuerAuth, issuerCertChainPubKey)
What it proves the issuer signed this MSO — its digest, deviceKey, docType, and validity.
Re-hash and compare the value digest
Take the IssuerSignedItem exactly as wrapped by the wallet (tag 24 + bytes), SHA-256 it, and compare to
the MSO's expected digest at (namespace, digestID).
SHA-256(issuerSignedItemTag24Bytes) == MSO.valueDigests["org.smarthealthit.checkin"][0]
What it proves the SMART response JSON in this item is exactly what the issuer committed to.
Verify the device signature
Reconstruct the DeviceAuthentication payload — using the SessionTranscript you computed
in step 1 — and verify the device COSE_Sign1 with the deviceKey from the MSO.
DeviceAuthentication = Tag(24, CBOR([
"DeviceAuthentication",
SessionTranscript,
"org.smarthealthit.checkin.1",
Tag(24, bstr .cbor {})
]))
verify_COSE_Sign1(deviceSignature, MSO.deviceKey)
What it proves the wallet signed this response in this verifier's request session — no replay across sessions or origins.
Render SMART artifacts
Parse IssuerSignedItem.elementValue as the SMART response JSON. Walk artifacts[]
and requestStatus[]; render each artifact according to its media-type-specific fields; show fulfillment status
for every requested item.
Fixture/debug verification steps
These steps are non-normative debugging guidance for the checked-in fixtures, not a normative test plan.
Request
Decode device-request.cbor. Confirm docType, namespace, element id,
intentToRetain=true, and that the SMART JSON sits at
requestInfo["org.smarthealthit.checkin.request"].
Transcript
Recompute CBOR([encryptionInfoB64u, origin]), SHA-256 it, then compare to
session-transcript.cbor.
Response
Check SHA-256(issuer-signed-item-tag24.cbor) == MSO.valueDigests[namespace][0],
then verify the issuer and device COSE_Sign1 envelopes.
Offline tools
bun run inspect:mdoc \ /tmp/shc-handler-runs/run-* \ --out /tmp/out/request bun run inspect:response \ /tmp/shc-handler-runs/run-*/device-response.cbor \ --out /tmp/out/response
Expected verifier UI
Success shows HPKE opened, digest matched, 4 artifacts,
and 4 fulfilled item statuses.
Known edges
| Area | Current behavior | Future hardening |
|---|---|---|
| Browser origin trust | Dev build reflects the actual caller app/signature into the AndroidX privileged allowlist. | Use an up-to-date allowlist of trusted browser package names and official signing-cert fingerprints. |
| Clinical source trust | The demo mdoc issuer certificate is fixture material. SMART artifacts carry their own media semantics: signed SMART Health Cards retain issuer proof; raw FHIR JSON is treated as patient-mediated content. | Deployments can layer source-specific policy when they require source attestation; it is not assumed by the transport demo. |
| HPKE binding | SessionTranscript is HPKE info; AEAD AAD is empty. |
Profiles may allow mutually supported alternatives only through existing wire identifiers; unilateral substitutions fail closed. |
| Request carrier | requestInfo["org.smarthealthit.checkin.request"] is load-bearing. |
Other request carriers are not SMART Health Check-in 1.0. |
See it on real bytes
For byte-for-byte comparison against your own implementation, the capture inspector loads a checked-in Chrome + Android run and walks the actual fixture bytes for every artifact above — byte ledger, structural drilldown, and per-artifact viewers.