← SMART Health Check-in

Wire protocol

How a SMART Health Check-in request and response are built, signed, encrypted, parsed, and verified — with the encoding, the keys, and the math called out at every step. For what the request asks and what the response means, see the SMART model explainer.

Version 1.0 standardizes only the same-device direct org-iso-mdoc presentation flow. QR, kiosk, SMS, portal, and relay handoffs are deployment UX that land on this flow, not separate SMART Health Check-in wire protocols.

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.

1. Verifier page Builds a SMART intent, wraps it in an org-iso-mdoc mdoc request, generates an HPKE recipient key, and calls navigator.credentials.get.
2. Browser Mediates user interaction and supplies an authenticated origin/equivalent to the wallet.
3. Android Credential Manager Runs wallet matchers and launches the chosen handler activity with the request payload.
4. Wallet Recovers the SessionTranscript, performs holder review, packs the SMART response into a self-attested mdoc, signs it, and HPKE-seals it.
5. Verifier page Recomputes the SessionTranscript, opens the HPKE seal, walks the issuer and device signatures, and renders SMART artifacts.
End-to-end success signal: the verifier displays 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

RoleOwnsSendsTrust 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.

Why this matters: the wallet's 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.

ParameterBaseline fixture value
KEMDHKEM(P-256, HKDF-SHA256) · suite id 0x0010
KDFHKDF-SHA256 · suite id 0x0001
AEADAES-128-GCM · suite id 0x0001
HPKE infoSessionTranscript bytes
AEAD AADempty byte string
NonceHPKE 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

AreaCurrent behaviorFuture 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.

Open the capture inspector →