Verifying an ISO mdoc Offline
The SDK can verify an ISO mdoc presented in person, using the proximity protocol defined in ISO/IEC 18013-5. The verifier and the wallet connect directly to each other over Bluetooth Low Energy (BLE), so neither device needs an internet connection during the exchange.
This guide walks through the flow from the verifier side.
Prerequisites
- The SDK is installed and initialized.
- An organization is created.
- A proof schema exists that requests one or more credentials in the mdoc format.
- A verifier identifier exists. To authenticate your app to the wallet, use a certificate identifier (see Reader authentication).
- Your configuration includes the entries described in Configuration below.
- Your app declares and has been granted the permissions described in Platform setup below.
How the flow works
In a proximity presentation, the wallet is the initiator. The holder starts the flow in their wallet, and your app responds to it. The exchange has two phases.
Device engagement. The wallet shares the information your app needs to connect: an ephemeral public key for the session, the BLE modes the wallet supports, and a service identifier (UUID) for the BLE connection. Your app receives this either by scanning a QR code shown on the wallet, or by reading it from the wallet over NFC.
Data retrieval. The two devices connect over BLE. Your app sends its request, the holder reviews it, and the wallet either sends the requested data or rejects the request. Your app validates what it receives, and the BLE connection then closes.
Session encryption
The devices do not use BLE pairing. Instead, each side generates an ephemeral key pair for the session, and the two derive a shared key from them. Every message in the data retrieval phase is encrypted with that key, so the BLE connection itself carries only encrypted content.
The wallet chooses the key algorithm for the session, and the SDK follows it. Your app needs no configuration for this.
BLE modes
BLE always has one device that advertises its presence and hosts a service (the peripheral and server), and one that scans for it and connects (the central and client). ISO/IEC 18013-5 defines two modes for which device takes which role.
Mode names always describe the wallet's role, not your app's. In "mdoc peripheral server mode", your app is the central and client:
| mdoc peripheral server mode | mdoc central client mode | |
|---|---|---|
| Advertises and hosts the service | Wallet | Your app |
| Scans and connects | Your app | Wallet |
The wallet always chooses the service identifier and includes it in its device engagement data, whichever device ends up advertising. In peripheral server mode, the wallet advertises this identifier and your app scans for it. In central client mode, the roles reverse: your app advertises the identifier it received from the wallet, and the wallet scans for it.
The wallet lists the modes it supports during device engagement, and the SDK supports both, so you do not configure a BLE mode on the verifier side. When the wallet supports both modes, the SDK always chooses central client mode, as ISO/IEC 18013-5 directs.
Reader authentication
Reader authentication lets the wallet verify who is making the request. Whether it happens depends on the identifier you use to create the proof request:
- Certificate identifier: the SDK signs the request with the certificate, and the wallet can show the holder who is asking.
- DID identifier: the request is not signed. The wallet receives no verified information about your app.
Key identifiers cannot be used to create ISO_MDL proof requests.
What the SDK checks
When the presentation arrives, the SDK validates it before the proof
reaches the ACCEPTED state:
- The signatures on the data elements are verified using the public key of the signer certificate, which is carried in the mobile security object (MSO).
- The disclosed data elements are checked against the digests in the MSO.
- If you passed an
ecosystemduring proof creation there may be additional checks performed. See Ecosystem Enforcement Reference for Verifiers.
Mandatory and optional claims
The ISO/IEC 18013-5 request format cannot mark individual claims as
mandatory, so every claim you request reaches the wallet as optional.
The SDK still enforces your proof schema: if a claim marked as
required is missing from the presentation, the SDK rejects it and
the proof moves to ERROR.
Configuration
The flow uses two parts of your configuration: the device engagement
methods, and the ISO_MDL verification protocol.
verificationEngagement:
QR_CODE:
display: verificationEngagement.qrCode
order: 1
enabled: true
NFC:
display: verificationEngagement.nfc
order: 2
enabled: true
verificationProtocol:
ISO_MDL:
type: "ISO_MDL"
display: "exchange.isoMdl"
order: 7
You reference the keys of these entries (QR_CODE, NFC, and
ISO_MDL) when you create the proof request in
step 2.
The ISO_MDL entry also accepts holder parameters, which apply only
when your app acts as a wallet.
Platform setup
The SDK does not request permissions on its own. If the required permissions have not been granted when the flow starts, calls will fail. Declare the permissions below, and request the runtime permissions in your app before starting the flow.
Scanning the wallet's QR code is handled by your app, not the SDK, so also declare camera access for whichever scanning library you use.
Android
Add the following to your AndroidManifest.xml:
<!-- Android 12 and later -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 11 and earlier -->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<!-- NFC engagement -->
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
On Android 12 and later, BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT,
and BLUETOOTH_SCAN are runtime permissions, so your app must also
request them from the user. Because the wallet decides which BLE mode
is used, a verifier needs all three: it scans in peripheral server
mode and advertises in central client mode.
The neverForLocation flag declares that your app does not use
Bluetooth scanning to determine location, so no location permission
is needed on Android 12 and later. On Android 11 and earlier,
Bluetooth scanning requires location access instead, so request
ACCESS_FINE_LOCATION at runtime on these versions.
iOS
Add the following to your Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used to connect to holders and request proofs from them.</string>
<!-- NFC engagement -->
<key>NFCReaderUsageDescription</key>
<string>Used to read device engagement data from wallets.</string>
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
<string>D2760000850101</string>
</array>
D2760000850101 identifies the NDEF Tag Application, which is how a
wallet exposes its device engagement data over NFC.
For NFC engagement, your app also needs the Near Field Communication Tag Reading capability, which you add to your target in Xcode.
Flow diagram
1. Get the device engagement data
How you get the engagement data depends on how the wallet shares it.
QR code engagement
Scan the QR code on the wallet with any QR code scanning library. The
SDK does not provide a scanner. Keep the scanned content as a string
and pass it to createProof in step 2.
NFC engagement
const isoMdlEngagement = await core.nfcReadIsoMdlEngagement({
inProgressMessage: 'Hold your device near the wallet',
successMessage: 'Wallet found',
failureMessage: 'Could not read the wallet',
});
This starts an NFC scan and resolves with the wallet's engagement data once the holder taps their device against yours. The three messages are shown on the iOS system overlay during the scan. All three are optional, and they have no effect on Android.
To cancel a scan that is still in progress, for example when the user leaves the screen:
await core.nfcStopIsoMdlEngagement();
2. Create the proof request
const proofId = await core.createProof({
proofSchemaId: proofSchemaId,
verifierIdentifierId: verifierIdentifierId,
protocol: 'ISO_MDL',
transport: ['BLE'],
engagement: 'QR_CODE', // or 'NFC'
isoMdlEngagement: isoMdlEngagement,
});
protocolandengagementreference keys from your configuration. Use the engagement method you used in step 1.isoMdlEngagementis the data from step 1: the scanned QR code content, or the result ofnfcReadIsoMdlEngagement.transportmust beBLE.verifierIdentifierIdis required. With a certificate identifier, the request is signed for reader authentication. If the identifier holds more than one certificate, useverifierCertificateto choose which one signs the request. Otherwise the first suitable certificate is used.
This call creates the proof directly in the PENDING state and
starts the BLE connection using the engagement data. Unlike online
verification, there is no shareProof step: the wallet already
started the exchange, and your app is responding to it.
Two optional fields may also apply:
ecosystem: use this to enforce the constraints of a regulatory ecosystem. See Enforcing an Ecosystem as a Verifier for details.transactionData: transaction authorization is not supported in the ISO offline flow.
3. Wait for the presentation
The SDK does not notify your app when the exchange finishes. Poll
getProof until the proof reaches a final state:
import { ProofState } from '@procivis/react-native-one-core';
const waitForResult = async (proofId) => {
while (true) {
const proof = await core.getProof(proofId);
if (
proof.state === ProofState.ACCEPTED ||
proof.state === ProofState.ERROR
) {
return proof;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
};
While you poll, the proof moves through these states:
PENDING: your app is waiting for the BLE connection.REQUESTED: the connection is up and your request was sent. The holder is reviewing it.ACCEPTED: the presentation arrived and passed validation.ERROR: the presentation failed validation, the connection was lost, or another technical error occurred.
If the holder rejects your request, the wallet sends an empty response.
There is no timeout on either PENDING or REQUESTED. Because the
wallet started the exchange, the flow normally ends when the holder
responds or cancels. Still give your user a way to cancel, and call
deleteProof when they do.
4. Read the result
const proof = await core.getProof(proofId);
for (const input of proof.proofInputs) {
console.log(input.credentialSchema.name);
console.log(input.claims);
}
proofInputs contains one entry per credential the holder shared:
claims: the claims that were disclosed, each with its claim schema and value. Values of nested claims contain further claims.credential: metadata about the shared credential.credentialSchema: the schema of the shared credential.trustInformation: trust information about the shared credential and its issuer.
The shared data is kept until retainUntilDate, which is set by the
expireDuration of your proof schema. To delete it sooner, for
example as soon as your app has displayed the result:
await core.deleteProofClaims(proofId);
The proof itself remains, and claimsRemovedAt records when its
claims were deleted.
5. Cancel the flow
await core.deleteProof(proofId);
What this does depends on how far the flow has progressed:
- In
PENDING, before the request was sent, the proof is deleted completely, with no history retained. - In
REQUESTED, the proof moves toRETRACTED. The BLE session closes and the history of the interaction is retained.
Proof states
A proof request created with this flow moves through these states:
| State | Meaning |
|---|---|
PENDING | The proof request was created from the engagement data. Waiting for the BLE connection. |
REQUESTED | The request was sent to the wallet. Waiting for the holder to respond. |
ACCEPTED | The presentation was received and passed validation. |
RETRACTED | Your app cancelled after the request was sent. |
ERROR | The presentation failed validation, or a technical error occurred. |
For all proof state transitions, see Proof States.
Limitations
- One request per session. After the wallet responds, the BLE connection closes. To request more data, the holder must start a new device engagement.
- Age over checks. ISO/IEC 18013-5 lets a verifier ask whether the
holder is over a given age, and the wallet answers true or false
without revealing the exact age. Wallets built on the SDK can
currently answer an
age_over_NNrequest only if the credential contains that exact data element.
Next steps
- Proof States covers all proof state transitions.