Presenting an ISO mdoc Offline
The SDK can present an ISO mdoc to a verifier in person, using the proximity protocol defined in ISO/IEC 18013-5. The wallet and the verifier 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 wallet side.
Prerequisites
- The SDK is installed and initialized.
- An organization is created.
- The wallet holds at least one credential in the mdoc format.
- 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 app, and the verifier responds to it. The exchange has two phases.
Device engagement. The wallet shares the information the verifier 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. The holder shares this either by displaying a QR code for the verifier to scan, or through an NFC tap.
Data retrieval. The two devices connect over BLE. The verifier sends its request, the holder reviews it, and the wallet either sends the requested data or rejects the request. 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's choice of key algorithm for this is set by
sessionEncryptionAlgorithm in your configuration.
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, never the verifier's:
| mdoc peripheral server mode | mdoc central client mode | |
|---|---|---|
| Advertises and hosts the service | Wallet | Verifier |
| Scans and connects | Verifier | 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 the verifier scans for it. In central client mode, the roles reverse: the verifier advertises the identifier it received from the wallet, and the wallet scans for it.
You choose which modes your wallet supports with bleMode. When you
choose BOTH, it runs both at the same time and the verifier chooses
which one to use. ISO/IEC 18013-5 directs verifiers to prefer central
client mode in this case, and verifiers built on the Core SDK always
choose it.
Mandatory and optional claims
The ISO/IEC 18013-5 request format cannot mark individual claims as
mandatory, so every requested claim reaches the wallet as optional.
In the response from getPresentationDefinitionV2, every claim of an
ISO_MDL request has required: false.
The verifier still enforces its own requirements when it validates the presentation: if the holder withholds a claim the verifier requires, the verifier rejects the presentation.
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
params:
holder:
sessionEncryptionAlgorithm: ECDSA
bleMode: BOTH
You reference the keys of these entries (QR_CODE, NFC, and
ISO_MDL) when you start the flow in
step 1.
The holder parameters apply when your app acts as the wallet:
| Parameter | Values | Description |
|---|---|---|
sessionEncryptionAlgorithm | ECDSA, EDDSA | Key algorithm for the ephemeral session key. ECDSA uses the P-256 curve and EDDSA uses X25519. Specify one value only. Required, with no default. |
bleMode | PERIPHERAL, CENTRAL, BOTH | Which BLE modes the wallet supports. Required, with no default. |
BOTH gives the widest compatibility, since the verifier can then use
whichever mode it supports. The SDK's verifier side supports both
modes and needs no BLE configuration of its own.
NFC engagement on iOS is implemented, but is not currently available for production use.
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 device engagement.
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" />
<uses-feature
android:name="android.hardware.nfc.hce"
android:required="false" />
On Android 12 and later, BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT,
and BLUETOOTH_SCAN are runtime permissions, so your app must also
request all three from the user, whichever bleMode you use.
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.
NFC engagement service
For NFC engagement, the wallet acts as an NFC tag that the verifier
reads. On Android, this uses host card emulation, which requires
declaring the SDK's engagement service inside the <application>
element of your manifest:
<service
android:name="ch.procivis.one.core.nfc.EngagementService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/engagement_aid_list" />
</service>
The android:permission attribute ensures that only the system's NFC
service can bind to the engagement service.
The service also needs an AID list, which tells Android which NFC
requests to route to it. Create it at
android/app/src/main/res/xml/engagement_aid_list.xml:
<host-apdu-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:requireDeviceUnlock="true">
<aid-group
android:category="other"
android:description="@string/app_name">
<aid-filter android:name="D2760000850101" />
</aid-group>
</host-apdu-service>
D2760000850101 identifies the NDEF Tag Application, which is how
the verifier finds the wallet's device engagement data over NFC.
With requireDeviceUnlock set to true, the holder must unlock their
device before the wallet responds to an NFC tap.
If you do not use NFC engagement, you can leave out both the service and the AID list.
iOS
Add a Bluetooth usage description to your Info.plist. iOS shows this
text when it asks the user for Bluetooth access:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used to connect to verifiers and share credentials with them.</string>
NFC engagement is not currently available for production use on iOS, so no NFC setup is needed for the wallet.
Flow diagram
1. Start device engagement
const { proofId, interactionId, url } = await core.proposeProof({
protocol: 'ISO_MDL',
organisationId: organisationId,
engagement: ['QR_CODE'], // or ['NFC']
});
protocol and engagement reference keys from your
configuration. engagement takes an array, but
pass exactly one value: the SDK supports one engagement method per
flow.
This call creates a proof in the PENDING state and starts the BLE
side of the connection according to your bleMode. Keep both IDs it
returns. You use proofId to check state and read the request, and
interactionId to submit or reject.
Two optional fields are also available:
uiMessage: text shown on the iOS system overlay during NFC engagement. It has no effect for QR code engagement or on Android.ecosystem: use this to enforce the constraints of a regulatory ecosystem. This gateway behaves the same ashandle-invitationas described in Enforcing an Ecosystem as a Wallet.
Display the QR code
For QR code engagement, url contains the device engagement data.
Render it as a QR code for the verifier to scan, using any QR code
library. For NFC engagement, url is not returned, and the holder
taps their device against the verifier's instead.
2. Wait for the verifier's request
The SDK does not notify your app when the verifier connects. Poll
getProof until the proof leaves the PENDING state:
import { ProofState } from '@procivis/react-native-one-core';
const waitForRequest = async (proofId) => {
while (true) {
const proof = await core.getProof(proofId);
if (proof.state === ProofState.REQUESTED) {
return proof;
}
if (proof.state === ProofState.ERROR) {
throw new Error('Connection to the verifier failed');
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
};
REQUESTED: the verifier connected and sent its request. Continue to step 3. The BLE connection stays open while the holder decides.ERROR: the connection failed, or the verifier cancelled or sent invalid data, or the request failed to meet the constraints of theeocsystemapplied in step 1.
There is no timeout on PENDING. A proof waits for a verifier until
the holder cancels, so give the holder a way to cancel and call
deleteProof when they do.
For more on ecosystem logic, see
Enforcing Ecosystems as a Wallet. The
enforcement logic described there for handle-invitation applies to
this step in the ISO proximity flow.
3. Show the request to the holder
const definition = await core.getPresentationDefinitionV2(proofId);
The response describes what the verifier is asking for:
credentialQueries: one entry per requested credential, keyed by credential query ID. Each entry either lists the wallet's credentials that match (applicableCredentials) or explains why none do (failureHint, with a reason ofNO_CREDENTIAL,VALIDITY, orCONSTRAINT).credentialSets: which combinations of credential queries satisfy the request.
Each claim of a matching credential includes userSelection, which
marks claims the holder can choose whether to share. Keep in mind
that the verifier may still reject a presentation that leaves out
claims it requires (see
Mandatory and optional claims).
For a full walkthrough of handling the presentation definition, see Wallet Workflow.
Check who is asking
The verifier is authenticated to the wallet only when it signs its request with a certificate (reader authentication). The proof details show whether it did:
const proof = await core.getProof(proofId);
const verifier = proof.verifier;
When the request is signed, verifier holds the identifier created
from the verifier's certificate. When it is not, verifier is empty,
and the wallet has no verified information about who is making the
request. Your app should make that clear to the holder.
If the verifier is registered in a trust ecosystem, more detail is available:
const trust = await core.getProofTrustInformation(proofId);
verifier: the verifier as registered in the ecosystem, including its name, country, contact details, service description, and supervisory authority, plus any intermediary acting on its behalf. EUDI is currently the only supported ecosystem type.ecosystemErrors: one entry per ecosystem provider that rejected the verifier, keyed by provider ID.
4. Submit or reject
To share the selected credentials:
await core.holderSubmitProofV2(interactionId, {
[credentialQueryId]: [
{
credentialId: credentialId,
userSelections: ['path/to/optional/claim'],
},
],
});
Both submit and reject take the interactionId from step 1, not the
proofId.
The submission maps each credential query ID from step 3 to the
credentials chosen for it. userSelections lists the paths of any
optional claims (userSelection: true) the holder chose to share.
Omit it, or pass an empty array, to withhold all optional claims.
The SDK sends the presentation, closes the BLE session, and moves the
proof to ACCEPTED. On the wallet side, ACCEPTED means the
presentation was sent. The verifier does not report its validation
result back to the wallet.
To decline the request instead:
await core.holderRejectProof(interactionId);
The SDK sends an empty response to the verifier, closes the BLE
session, and moves the proof to REJECTED.
Either way, the session is over. To present again, start a new flow with step 1.
5. Cancel the flow
await core.deleteProof(proofId);
What this does depends on how far the flow has progressed:
- In
PENDING, before the verifier has sent a request, the proof is deleted completely with no history retained, and the BLE session closes. - In
REQUESTED, the proof moves toRETRACTED. The BLE session closes and the history of the interaction is retained.
Proof states
A proof presented with this flow moves through these states:
| State | Meaning |
|---|---|
PENDING | Device engagement started. Waiting for the verifier to connect and send a request. |
REQUESTED | The verifier's request was received. Waiting for the holder to respond. |
ACCEPTED | The holder submitted the presentation. |
REJECTED | The holder rejected the request. |
RETRACTED | The holder cancelled after the request was received. |
ERROR | A technical error occurred, or the connection was lost. |
For all proof state transitions, see Proof request states as a holder.
Limitations
- One request per session. After the wallet submits or rejects, the BLE connection closes. Any further request requires 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. The wallet can currently answer an
age_over_NNrequest only if the credential contains that exact data element. - NFC engagement on iOS is not currently available for production use.
Next steps
- Wallet Workflow covers issuance and online presentation.