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 everySessionDataframe 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
readerAuthsignature + certificate chain. - Device authentication —
deviceSignature(COSE_Sign1) for signing device keys ordeviceMac(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.
- Kotlin
- Swift
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 */ }
}
}
let session = wallet.proximity.present(transport) // your ProximityTransport
for await state in session.states {
switch state {
case let .engagementReady(deviceEngagement, handoverNdef):
if let handoverNdef { serveOverNfc(handoverNdef) } // NFC: serve the Handover Select via HCE
else { showQr(deviceEngagement) } // QR: render "mdoc:" + base64url
case .requestReceived(let request):
// request.documents — requested doctype(s) + elements
// request.reader.trusted — reader authentication result
// request.reader.commonName
session.respond(.auto(request)) // or a user-chosen selection
case .completed:
onDone()
case .declined:
onDeclined()
case .failed(let error):
onError(error)
default:
break // 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.
- Kotlin
- Swift
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()
}
}
case .requestReceived(let request):
for doc in request.documents {
doc.docType // e.g. "org.iso.18013.5.1.mDL"
doc.requestedElements // [namespace: [elementId]]
doc.candidates // CredentialIds that answer it — let the User pick when there's >1
}
let reader = request.reader
if reader.trusted {
reader.commonName // verified reader identity from readerAuth
}
if request.satisfiable {
session.respond(.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:
- Kotlin
- Swift
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
}
protocol ProximityTransport: Sendable {
func send(_ message: [UInt8]) async throws // framed SessionData → peer
func receive() async throws -> [UInt8] // framed SessionData ← peer
func close() async
// How the reader is told to connect (defaults: empty / nil):
func retrievalMethods() -> [[UInt8]] // BLE DeviceRetrievalMethod(s) for the QR engagement
func nfcCarrier() -> NfcCarrier? // 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.
- Kotlin
- Swift
// 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()
}
}
// A BLE peripheral that carries ISO 18013-5 mdoc device retrieval.
// The SDK calls send()/receive(); you bridge them to CoreBluetooth.
final class BleProximityTransport: ProximityTransport {
let serviceUUID: CBUUID // from the DeviceEngagement
// Advertise `serviceUUID`; expose the state / client2server / server2device
// characteristics (ISO 18013-5 §8). Route inbound notifications into a buffer.
func send(_ message: [UInt8]) async throws {
// write `message` to the outbound characteristic (chunked to the MTU)
}
func receive() async throws -> [UInt8] {
// await the next notification from the reader
}
func close() async {
// stop advertising, tear down the peripheral manager
}
}
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 Role | The sender says | On a Handover Request (sender = the reader) |
|---|---|---|
0x00 | Peripheral only | the reader is the GATT server ⇒ mdoc central client mode only |
0x01 | Central only | the reader connects out ⇒ mdoc peripheral server mode only |
0x02 | both, Peripheral preferred | either mode; the reader would rather be the peripheral ⇒ it prefers mdoc central client |
0x03 | both, Central preferred | either 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:
| Partner | Its role | What it does with our Request |
|---|---|---|
| Google Wallet | holder | reads the LE Role preference bit; takes mdoc central client mode |
| Multipaz | holder | ignores the preference bit; answers mdoc peripheral server (0x02 and 0x03 both measured). Carrier order it does follow |
| Multipaz | reader | offers 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.
- Kotlin
- Swift
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
}
let engagement = decodeQr(scanned) // "mdoc:" + base64url → DeviceEngagement bytes
let request = [
RequestedDocument(docType: "org.iso.18013.5.1.mDL", elements: [("org.iso.18013.5.1", ["family_name", "given_name"])]),
]
let verified = try await wallet.reader.read(transport: transport, engagement: engagement, documents: request)
for doc in verified {
doc.docType // "org.iso.18013.5.1.mDL"
doc.elements // [namespace: [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.
- Kotlin
- Swift
// 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)
// Configure the reader (verifier) trust anchors when assembling the wallet.
let config = WalletConfig(
trust: TrustConfig(
issuerAnchorsDer: issuerAnchors,
readerAnchorsDer: readerAnchors // trusted reader roots (DER-encoded)
)
)
// Later, on each request:
let 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.
- Kotlin
- Swift
// 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)
}
// In-memory transport: two queues wired to a mock reader — no radio.
final class InMemoryTransport: ProximityTransport, @unchecked Sendable {
let toReader: AsyncStream<[UInt8]> // SDK.send() lands here
private let toReaderIn: AsyncStream<[UInt8]>.Continuation
private var toWallet: AsyncStream<[UInt8]>.Iterator // SDK.receive() reads here
init(fromReader: AsyncStream<[UInt8]>) {
var cont: AsyncStream<[UInt8]>.Continuation!
self.toReader = AsyncStream { cont = $0 }
self.toReaderIn = cont
self.toWallet = fromReader.makeAsyncIterator()
}
func send(_ message: [UInt8]) async throws { toReaderIn.yield(message) }
func receive() async throws -> [UInt8] { await toWallet.next() ?? [] }
func close() async { toReaderIn.finish() }
}
func testReaderRetrievesRequestedElements() async {
let transport = InMemoryTransport(fromReader: mockReaderReplies)
let session = wallet.proximity.present(transport)
// The mock reader consumes `transport.toReader` and feeds `mockReaderReplies`;
// the SDK runs engagement, session encryption and DeviceResponse signing
// fully in-process.
for await state in session.states where state.isTerminal {
if case .completed = state { /* pass */ }
break
}
}
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:
- Kotlin
- Swift
WalletConfig(presentation = PresentationConfig(mdocDeviceAuth = MdocDeviceAuthMode.Mac))
WalletConfig(presentation: PresentationConfig(mdocDeviceAuth: .mac))
mdocDeviceAuth governs the device-auth form for both proximity and OpenID4VP mdoc presentations,
not proximity alone.
deviceSignature (default) | deviceMac | |
|---|---|---|
| Structure | COSE_Sign1, ES256 | COSE_Mac0, HMAC 256/256 |
| Key | signing DeviceKey | key-agreement DeviceKey |
| Who can verify | anyone | only 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.
:::