Skip to main content
Version: v0.25.0 (Latest)

Transfer Manager

The TransferManager handles the second phase of the mDoc protocol: the actual exchange of credential data. After engagement establishes how to communicate, the transfer manager takes over to receive the verifier's request, build a signed response from the holder's credentials, and send it back.

You get a TransferManager by calling start() on an EngagementInstance. It lives for the duration of one transfer and should not be reused.

Obtaining the Transfer Manager​

val engagement = engagementManager.activeEngagement.value!!

// Exception-based
val transferManager: TransferManager = engagement.start()

// Result-based (no exceptions)
val result = engagement.tryOps().start()

start() sets up session encryption (ECDH key agreement, HKDF key derivation, AES-256-GCM), connects the transport, and begins listening for incoming data. From this point, all communication with the verifier is encrypted.

Receiving the Device Request​

Once the transfer is running, the first thing to do is wait for the verifier's DeviceRequest. This is a blocking call that returns when the request arrives over the transport (BLE, NFC, HTTP, or OID4VP).

val deviceRequest: DeviceRequest = transferManager.receiveDeviceRequest()

A DeviceRequest contains one or more DocRequest entries, each specifying a document type and the data elements the verifier wants. You'll typically iterate over these to build a consent screen:

for (docRequest in deviceRequest.docRequests) {
println("Document type: ${docRequest.docType}")

for ((namespace, elements) in docRequest.itemsRequest.nameSpaces) {
for ((elementId, intentToRetain) in elements) {
println(" $namespace / $elementId (retain: $intentToRetain)")
}
}
}

The intentToRetain flag tells you whether the verifier plans to store the data element beyond this session. You may want to highlight this to the user or apply stricter consent rules for retained elements.

Validating Reader Authentication​

Before showing the consent screen, you can check whether the verifier signed its request. Reader authentication is optional in ISO 18013-5, but when present, the DocRequest includes a COSE_Sign1 signature that proves the verifier's identity.

val isValid = transferManager.validateReaderAuthentication(
docRequest = docRequest,
requireReaderAuthentication = true
)

if (!isValid) {
showWarning("Could not verify the verifier's identity")
}

Set requireReaderAuthentication to true if your policy mandates it. When set to false, the method returns true even if no reader authentication is present (it only fails on invalid signatures).

Creating and Sending the Response​

The simplest path is createResponse, which takes the request and a document provider, applies selective disclosure, signs the documents with the device key, and returns a complete DeviceResponse ready to send.

val deviceResponse = transferManager.createResponse(
deviceRequest = deviceRequest,
documentProvider = documentProvider
)

transferManager.sendDeviceResponse(deviceResponse)

createResponse handles the full pipeline: matching requested elements to available credentials, filtering via limitDisclosures, computing the DeviceAuthentication structure with the session transcript, and signing with the device key from the KMS. The document provider is your callback that supplies the raw credential data.

If the user declines, send an error response instead:

val errorResponse = DeviceResponse.Builder()
.withStatus(DeviceResponseStatus(20u)) // session termination
.build()
transferManager.sendDeviceResponse(errorResponse)

Signing Documents Manually​

When you need more control than createResponse provides, you can sign documents individually or in batches. This is useful when you want to apply custom logic per document (e.g., different key per credential type, or adding device-signed claims).

Single document​

val signedDocument = transferManager.signDocument(
request = docRequest,
document = document,
deviceKeyInfo = deviceKeyInfo
)

Batch signing​

val signedDocuments = transferManager.signDocuments(listOf(
SigningRequest(docRequest1, document1, deviceKeyInfo1),
SigningRequest(docRequest2, document2, deviceKeyInfo2)
))

Fluent builder​

For assembling a multi-document response step by step:

val documents = transferManager.documentsBuilder()
.add(docRequest1, document1, deviceKeyInfo1)
.add(docRequest2, document2, deviceKeyInfo2)
.build()

All three approaches compute the same DeviceAuthentication signature using the session transcript from the current transfer. The session transcript binds each signature to this specific session, so signed documents cannot be replayed.

Accessing the Session Transcript​

The session transcript is a CBOR structure that cryptographically ties the transfer to its engagement. It's computed automatically during start() and used internally for signing, but you can access the raw bytes if you need them for custom verification logic or debugging.

val transcriptBytes: ByteArray = transferManager.getSessionTranscript()

See the Session Transcript page for details on what this structure contains and how it varies across transport types.

Building Responses Manually​

When you need full control over the DeviceResponse structure (e.g., for error responses or non-standard flows), use the builder directly:

val response = DeviceResponse.Builder()
.withVersion("1.0")
.withStatus(DeviceResponseStatus(0u))
.withDocuments(listOf(signedDocument1, signedDocument2))
.build()

Status codes​

CodeMeaningWhen to use
0SuccessRequest processed, documents included in response
10General errorSomething went wrong that doesn't fit the other codes
11CBOR decoding errorThe incoming request had invalid CBOR
12CBOR errorCBOR-related processing failure
20Session terminationUser declined, or holder is ending the session

Complete Transfer Flow​

Putting the pieces together into a typical holder-side flow:

class MdocTransferHandler(
private val engagementManager: MdocEngagementManager,
private val documentProvider: DocumentProvider
) {
suspend fun handleTransfer(engagement: EngagementInstance) {
try {
val transferManager = engagement.start()

val deviceRequest = transferManager.receiveDeviceRequest()
val userApproved = showConsentDialog(deviceRequest)

if (userApproved) {
val deviceResponse = transferManager.createResponse(
deviceRequest = deviceRequest,
documentProvider = documentProvider
)
transferManager.sendDeviceResponse(deviceResponse)
} else {
val errorResponse = DeviceResponse.Builder()
.withStatus(DeviceResponseStatus(20u))
.build()
transferManager.sendDeviceResponse(errorResponse)
}
} catch (e: Exception) {
handleError(e)
} finally {
engagementManager.closeEngagementByInstance(engagement)
}
}
}

The key steps: start the transfer, receive the request, get consent, build and send the response, clean up. The finally block ensures the engagement is closed even if something goes wrong mid-transfer.

Transfer Instance Tracking​

The engagement manager keeps a map of active TransferInstance objects, each representing an in-progress transfer. This is useful for UIs that need to show transfer progress or handle multiple concurrent sessions (e.g., a server verifier handling several holders at once).

engagementManager.transferInstances.collect { transfers ->
for ((id, transfer) in transfers) {
val state = transfer.getCurrentState()
println("Transfer $id: $state")
}
}

Each TransferInstance has its own events stream and states flow if you need per-transfer granularity.

Transfer Lifecycle​

mDoc Transfer Lifecycle

Tips​

  • Always get user consent before sending any data. This is both a UX requirement and a regulatory one in most jurisdictions.
  • Use createResponse for the common case. Only drop down to signDocument or the builder when you need custom per-document logic.
  • Close the engagement after the transfer completes. Resources (BLE connections, NFC listeners) are not released until you do.
  • Handle transport errors. BLE disconnects, HTTP timeouts, and NFC interruptions are all possible during transfer.