Skip to main content

Proximity presentation

Proximity is in-person, offline presentation of an mdoc — for example a mobile driving licence (mDL) — to a nearby reader over a short-range transport (BLE / NFC). No network is involved: the wallet and the reader exchange credentials directly, device to device.

The SDK owns the entire ISO/IEC 18013-5 device-retrieval exchange — and both roles, the holder (wallet.proximity) presenting an mdoc and the reader (wallet.reader) verifying one:

  • Device engagement — a QR code or NFC handover — static or negotiated (Handover Select over HCE), either carrying a BLE carrier in peripheral server or central client mode.
  • ECDH session keys — deriving SKDevice/SKReader (HKDF salted by the SessionTranscript) and encrypting every SessionData frame both ways with AES-256-GCM.
  • DeviceRequest / DeviceResponse — decoding what the reader asked for and building the selectively-disclosed, holder-authenticated response.
  • Reader authentication — verifying the reader's readerAuth signature + certificate chain.
  • Device authentication — deviceSignature (COSE_Sign1) for signing device keys or deviceMac (COSE_Mac0, EMacKey from the EReaderKey↔DeviceKey ECDH) for key-agreement keys. Both are produced and verified; see Device authentication to choose.

You provide only the transport — the radio bytes in and out. Everything cryptographic and protocol-level lives inside the SDK, and it interoperates with conformant ISO 18013-5 devices.

The flow​

Call present(transport) with your ProximityTransport, then observe the session state. The state machine walks through GeneratingEngagement → EngagementReady (show a QR for the reader) → RequestReceived (inspect and respond) → Completed.

val session = wallet.proximity.present(transport) // QR engagement
// val session = wallet.proximity.present(transport, nfc = true) // NFC static handover

session.state.collect { s ->
when (s) {
is ProximityState.EngagementReady ->
if (s.handoverNdef != null) serveOverNfc(s.handoverNdef) // NFC: serve the Handover Select via HCE
else showQr(s.deviceEngagement) // QR: render "mdoc:" + base64url

is ProximityState.RequestReceived -> {
val request = s.request
// request.documents — requested doctype(s) + elements
// request.reader.trusted — reader authentication result
// request.reader.commonName
session.respond(ProximitySelection.auto(request)) // or a user-chosen selection
}

is ProximityState.Completed -> onDone()
is ProximityState.Declined -> onDeclined()
is ProximityState.Failed -> onError(s.error)
else -> { /* GeneratingEngagement, Submitting */ }
}
}

EngagementReady carries the deviceEngagement bytes (render as the QR string mdoc: + base64url) and, in NFC mode, a handoverNdef Handover Select message to serve over Host Card Emulation. The BLE carrier (service UUID + mode) travels inside either one. Once the reader connects and sends its DeviceRequest, the SDK decrypts and parses it and emits RequestReceived.

The terminal states are Completed, Declined, and Failed; every state exposes .isTerminal. Failed.error is a WalletError.Proximity (Kotlin) / ProximityError (Swift): SessionFailed / .sessionFailed (the transport or session-encryption exchange broke), NoMatchingCredential / .noMatchingCredential (nothing stored answers the reader's request), or Unexpected / .unexpected.

Inspecting the request​

RequestReceived gives you a ProximityRequest. Inspect documents to see what the reader asked for, check reader for the authentication result, and use satisfiable to decide whether you can answer at all. Each document's candidates lists every stored credential that answers its doctype, so you can let the User choose one; ProximitySelection.auto(request) picks the first for each, and decline() refuses.

is ProximityState.RequestReceived -> {
val request = s.request

for (doc in request.documents) {
doc.docType // e.g. "org.iso.18013.5.1.mDL"
doc.requestedElements // Map<namespace, List<elementId>>
doc.candidates // CredentialIds that answer it — let the User pick when there's >1
}

val reader = request.reader
if (reader.trusted) {
reader.commonName // verified reader identity from readerAuth
}

if (request.satisfiable) {
session.respond(ProximitySelection.auto(request))
} else {
session.decline()
}
}

The transport port​

The SDK drives a framed, encrypted message exchange but never touches the radio. You implement the ProximityTransport port — three methods, one for each direction plus teardown:

interface ProximityTransport {
suspend fun send(message: ByteArray) // framed SessionData → peer
suspend fun receive(): ByteArray // framed SessionData ← peer
suspend fun close()

// How the reader is told to connect (defaults: empty / null):
fun retrievalMethods(): List<ByteArray> = emptyList() // BLE DeviceRetrievalMethod(s) for the QR engagement
fun nfcCarrier(): NfcCarrier? = null // BLE carrier (UUID + mode) for NFC static handover
}

The bytes crossing send / receive are already the ISO 18013-5 session messages. Your job is only to move them over the wire.

A BLE implementation​

BLE runs in one of two ISO 18013-5 §8.3.3.1.1 modes, and the SDK is agnostic about which:

  • Peripheral server mode — the holder is the GATT server (advertises the service UUID); the reader is the GATT client. Characteristics 00000001/2/3-a123-48ce-896b-4c76973373e6.
  • Central client mode — roles reversed (holder = client, reader = server). Characteristics 00000005/6/7-….

Your transport advertises its UUID + mode to the SDK through retrievalMethods() (QR) and nfcCarrier() (NFC); the SDK builds the engagement around it. It exposes the state / client2server / server2client characteristics, feeds inbound writes/notifications into receive(), and turns each send() into a chunked write/notify (0x01 = more, 0x00 = last). The SDK never opens a socket, advertises, or reads a characteristic — it only calls your port. The demo app ships working BleGattServerTransport + BleGattClientTransport that cover both modes with one class each.

// A BLE peripheral that carries ISO 18013-5 mdoc device retrieval.
// The SDK calls send()/receive(); you bridge them to GATT.
class BleProximityTransport(
private val serviceUuid: UUID, // from the DeviceEngagement
) : ProximityTransport {

private val inbound = Channel<ByteArray>(Channel.UNLIMITED)

// Advertise `serviceUuid`; expose the state / client2server / server2device
// characteristics (ISO 18013-5 §8). Route inbound notifications into `inbound`.

override suspend fun send(message: ByteArray) {
// write `message` to the outbound characteristic (chunked to the MTU)
}

override suspend fun receive(): ByteArray =
inbound.receive() // fed by notifications from the reader

override suspend fun close() {
// stop advertising, tear down the GATT server
inbound.close()
}
}

NFC handover​

With present(transport, nfc = true), EngagementReady.handoverNdef is an ISO 18013-5 §8.3.3.1.2 Handover Select NDEF message (an Hs record + a DeviceEngagement record + a BLE carrier record), built by the SDK from the transport's nfcCarrier(). Serve it from an Android Host Card Emulation Type 4 Tag service; the reader taps, reads it, extracts the engagement + BLE UUID, and the session continues over BLE exactly as for QR. The full Handover Select message is hashed into the SessionTranscript so both sides derive the same keys. The SDK exposes MdocNfcEngagement (build/parse) and Ndef for the app's HCE + reader-mode plumbing; the demo ships the HCE service and NFC reader.

That is static handover, where the mdoc serves a Select the reader simply takes. §8.2.2.1 also defines negotiated handover: the mdoc advertises a Connection Handover service over TNEP, the reader writes a Handover Request naming the carriers it supports, and the mdoc answers with a Select choosing exactly one of them. Pass those Request bytes to present(transport, nfc = true, handoverRequestNdef = hr) and the SDK binds [Hs, Hr] into the SessionTranscript (static binds [Hs, null]). NfcEngagementProcessor is the holder-side APDU state machine for both; it is transport-agnostic, so the platform layer is a thin HCE bridge.

Who picks the BLE mode​

In negotiated handover the reader proposes and the mdoc selects, so the mode is not something either side simply asserts. Each BLE carrier record carries a Bluetooth LE Role (0x1C), and the value is always the sender's own role — which inverts against the ISO mode names, since those are written from the mdoc's point of view:

LE RoleThe sender saysOn a Handover Request (sender = the reader)
0x00Peripheral onlythe reader is the GATT server ⇒ mdoc central client mode only
0x01Central onlythe reader connects out ⇒ mdoc peripheral server mode only
0x02both, Peripheral preferredeither mode; the reader would rather be the peripheral ⇒ it prefers mdoc central client
0x03both, Central preferredeither mode; it prefers mdoc peripheral server

The two "both" values are not a curiosity — §8.3.3.1.1.1 says an mdoc or reader indicates whether it supports "the Central role, the Peripheral role or both", and real readers use them. Reading LE Role as a boolean is therefore wrong twice over: it loses the "either" answer, and it mispairs the UUID with the mode, because §8.3.3.1.1.2 defines a UUID in the Request as the mdoc-central-client address and one in the Select as the mdoc-peripheral-server address. An mdoc that answers "both" has to be dialled on the reader's UUID, not the one in its own Select. MdocNfcEngagement models all four values (NfcBleCarrier.supportsPeripheral / supportsCentral), and parseHandover(select, request) resolves the pair for the reader.

On the holder side, NfcHandoverRequest.selectCarrier() is the policy: walk the reader's carriers in the order it listed them, honour the LE Role preference bit where a carrier supports both, and take mdoc central client mode where the reader expresses no preference — §8.3.3.1.1.1 recommends it, Google Wallet always picks it, and it keeps the wallet from broadcasting a service UUID of its own. Everything in the SDK and demo defaults to that mode: the reader advertises, the wallet only scans.

// Holder: choose from what the reader actually offered (§8.2.2.1), then present over that transport.
val choice = MdocNfcEngagement.parseHandoverRequest(hr)?.selectCarrier()
val transport = if (choice?.peripheralServerMode == false && choice.serviceUuid != null) {
BleGattClientTransport(context, Ble.bytesToUuid(choice.serviceUuid), Ble.CENTRAL_CLIENT) // dial the reader
} else {
gattServer // advertise our own
}
wallet.proximity.present(transport, nfc = true, handoverRequestNdef = hr)

The reader's own Request is built by MdocNfcEngagement.buildHandoverRequest. It sends one BLE carrier at LE Role 0x02 — the shape §8.2.2.1 describes, an alternative carrier being a transmission technology rather than a BLE role. alsoOfferMdocPeripheralServer emits the alternative encoding, one record per mode with central client first, for a peer that needs it.

Device-verified​

The wire behaviour above is verified phone-to-phone, both roles, against the Multipaz test app and Google Wallet — the full matrix (QR + both handover kinds × both BLE modes, 15 rows) and its dated results live in demo/PROXIMITY-MATRIX.md. The negotiated-handover findings worth knowing before you integrate:

PartnerIts roleWhat it does with our Request
Google Walletholderreads the LE Role preference bit; takes mdoc central client mode
Multipazholderignores the preference bit; answers mdoc peripheral server (0x02 and 0x03 both measured). Carrier order it does follow
Multipazreaderoffers one carrier at LE Role 0x03 — it would rather be the Central, so our holder answers peripheral server

Both outcomes complete: a holder that declines the preferred mode simply takes the other one, which is why the policy is a preference and not a requirement. The demo logs both negotiated messages verbatim while presenting, and tools/ndef-dump.py decodes them into carriers, LE Roles, and UUIDs — the only way to see what a peer actually offered.

Reading another wallet (reader side)​

The same SDK is also the reader. wallet.reader.read(transport, engagement, documents) drives the verifier half over your transport: it builds the DeviceRequest, establishes the encrypted session, and verifies the returned DeviceResponse — issuer trust, digests, and the deviceSignature / deviceMac holder binding. engagement is what you scanned from the wallet's QR (or read over NFC); pass handoverNdef for the NFC case so the SessionTranscript matches.

val engagement = decodeQr(scanned) // "mdoc:" + base64url → DeviceEngagement bytes
val request = listOf(
RequestedDocument("org.iso.18013.5.1.mDL", mapOf("org.iso.18013.5.1" to listOf("family_name", "given_name"))),
)
val verified = wallet.reader.read(transport, engagement, request) // your BLE central transport
for (doc in verified) {
doc.docType // "org.iso.18013.5.1.mDL"
doc.elements // Map<namespace, Map<elementId, value>>
doc.deviceAuthenticated // true when the holder binding verified
}

With issuer trust anchors configured, deviceAuthenticated reflects a verified issuer + holder binding; without them the reader still returns the disclosed elements, marked unverified. MSO value-digest verification supports SHA-256, SHA-384 and SHA-512 (as readers must per §9.1.2.5), automatically following the MSO digestAlgorithm.

Reader authentication​

If the DeviceRequest carries readerAuth and its certificate chains to a trust anchor configured in readerAnchorsDer, then request.reader.trusted is true, and the reader's CommonName and full certificate chain are recorded in the audit log. A missing, rogue, or unverifiable reader signature yields trusted = false.

This is the same trust model as remote signed requests (OpenID4VP): the SDK verifies, records the outcome, and surfaces it — but does not block on it. The wallet shows the user whether the reader is trusted; the user still decides whether to disclose.

// Configure the reader (verifier) trust anchors when assembling the wallet.
val config = WalletConfig(
trust = TrustConfig(
issuerAnchorsDer = issuerAnchors,
readerAnchorsDer = readerAnchors, // trusted reader roots (DER-encoded)
),
)

// Later, on each request:
val reader = request.reader
reader.trusted // chained to a configured anchor + valid readerAuth
reader.commonName // reader CN, when present
reader.certificateChainDer // full presented chain (also written to the audit log)

Every completed exchange lands in wallet.transactions, with the relying party (reader), its trust verdict and chain, and the exact claims disclosed — see Trust and audit.

Testability​

Because engagement, session encryption, and the DeviceResponse signature are pure, the whole exchange is unit-testable with an in-memory transport — no Bluetooth, no NFC, no device. You implement the same ProximityTransport port with in-process queues instead of a radio, and drive the other end from a mock reader. On device, you swap that one class for the BLE/NFC implementation; the rest of your code is unchanged.

// In-memory transport: two queues wired to a mock reader — no radio.
class InMemoryTransport : ProximityTransport {
val toReader = Channel<ByteArray>(Channel.UNLIMITED) // SDK.send() lands here
val toWallet = Channel<ByteArray>(Channel.UNLIMITED) // SDK.receive() reads here

override suspend fun send(message: ByteArray) = toReader.send(message)
override suspend fun receive(): ByteArray = toWallet.receive()
override suspend fun close() { toReader.close(); toWallet.close() }
}

@Test
fun `reader retrieves the requested elements`() = runTest {
val transport = InMemoryTransport()
val session = wallet.proximity.present(transport)

// A mock reader reads `transport.toReader`, replies into `transport.toWallet`,
// and the SDK runs engagement, session encryption and DeviceResponse signing
// fully in-process.

val done = session.state.first { it.isTerminal }
assertTrue(done is ProximityState.Completed)
}

The in-memory transport keeps proximity logic testable on a plain CI runner (including Linux); only the radio adapter needs a real device.

Device authentication​

ISO 18013-5 §9.1.3.5 lets the mdoc authenticate its DeviceResponse two ways. The SDK produces and verifies both; the holder side is a one-line config choice:

WalletConfig(presentation = PresentationConfig(mdocDeviceAuth = MdocDeviceAuthMode.Mac))

mdocDeviceAuth governs the device-auth form for both proximity and OpenID4VP mdoc presentations, not proximity alone.

deviceSignature (default)deviceMac
StructureCOSE_Sign1, ES256COSE_Mac0, HMAC 256/256
Keysigning DeviceKeykey-agreement DeviceKey
Who can verifyanyoneonly the reader in this session

deviceMac is keyed by the EMacKey: HKDF(ECDH(DeviceKey, EReaderKey), salt = SHA-256(SessionTranscriptBytes), info = "EMacKey"). The holder computes that ECDH inside its SecureArea — the private half never leaves — while the reader computes the mirror ECDH from its own ephemeral key. Because only those two parties can derive the key, a MAC is non-transferable: the reader cannot later prove to a third party that the wallet answered. A signature can be shown to anyone, which is exactly what some deployments want and others do not.

:::warning The DeviceKey must support ECDH MdocDeviceAuthMode.Mac requires a key-agreement DeviceKey. On Android Keystore and the Secure Enclave the key purpose is fixed at creation, so a wallet holding sign-only credentials must re-issue them before switching. The SDK checks SecureArea.capabilities.keyAgreement and fails the session with a clear error rather than silently falling back to a signature. :::

:::caution One key, one mechanism (§9.1.3.4)

A single mdoc authentication key shall not be used to produce both MACs and signatures during its lifetime.

Over OpenID4VP the mdoc can MAC only for an encrypted response whose deviceauth_alg_values requests it (ISO 18013-7 B.4.5) — the verifier's response-encryption key doubles as the EReaderKey for the ECDH. The org-iso-mdoc HPKE DC API path has no such key and still always signs. With the default KeyUse.Rotate policy the same DeviceKey is reused, so a wallet set to Mac that also presents bound-and-signing over another channel makes one key do both — which the clause forbids, and which quietly undoes the deniability that Mac was chosen for.

Issue the credential with KeyUse.OneTime and a batch (CredentialPolicy(batchSize = n, use = OneTime)) to satisfy the clause structurally: each DeviceKey is consumed by exactly one presentation, so it can never produce both. This is also what HAIP recommends for unlinkability. :::

:::note Session-key curve The proximity session's ephemeral key (EDeviceKey/EReaderKey) uses EcCurve.P256 by default; set WalletConfig.presentation.proximitySessionCurve to .P384 or .P521 (Swift .p256/.p384/.p521) per ISO 18013-5 §9.1.5.2 Table 22. As reader, the SDK automatically matches whatever curve the mdoc offers. :::