Skip to content

Commit f72f209

Browse files
committed
Introduce Multipaz Pass file format.
The Multipaz `.mpzpass` file format provides a standardized, lightweight mechanism for the exchange of low-assurance verifiable credentials. In scenarios where strict cryptographic device-binding introduces unnecessary friction — such as when a user expects their digital assets to seamlessly synchronize across their entire ecosystem of devices — this format offers a pragmatic, portable solution. It is engineered specifically for use cases where the risk of credential sharing is negligible, such as event and movie ticketing, transit passes, or generic membership cards. This format explicitly trades anti-cloning guarantees for portability. Because the credential data and any associated keys are stored in a highly portable container, the credential can be trivially copied. For high-value credentials where cloning or replay attacks are active threat vectors (e.g., mobile driving licenses or financial instruments), this file format is inherently unsuitable. In those high-assurance scenarios, issuers must leverage a robust provisioning protocol like [OpenID4VCI](https://github.com/openid/OpenID4VCI) to ensure secure delivery and hardware-backed device-binding at the time of issuance. This PR has three main components - Defintion of the format with example files, in the `mpzpass` directory - Support routines and import/export in the core Multipaz library - Support in TestApp for generating and importing `.mpzpass` files Additionally, extend `SimplePresentmentSource` to support more than one domain for a given credential type. This is needed because if using this to import a credential with a software-backed key and the app already has a domain for with and without user authentication (and the domain is picked according to a setting of whether user authentication should be used), the software-backed credential (which never has any user authentication) need to be consulted in both cases. This PR also fixes problems with the compose TestApp on iOS and it also makes Credential.replacementForDeleted() internal which it should have been from the start. Test: Unit test and manually tested on both Android and iOS. Signed-off-by: David Zeuthen <zeuthen@google.com>
1 parent 9b80ec0 commit f72f209

File tree

39 files changed

+1477
-195
lines changed

39 files changed

+1477
-195
lines changed

mpzpass/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Multipaz Pass file format
2+
3+
The Multipaz `.mpzpass` file format provides a standardized, lightweight mechanism
4+
for the exchange of low-assurance verifiable credentials.
5+
6+
In scenarios where strict cryptographic device-binding introduces unnecessary
7+
friction — such as when a user expects their digital assets to seamlessly synchronize
8+
across their entire ecosystem of devices — this format offers a pragmatic, portable
9+
solution. It is engineered specifically for use cases where the risk of credential
10+
sharing is negligible, such as event and movie ticketing, transit passes, or generic
11+
membership cards.
12+
13+
## Security Boundary and Anti-Cloning
14+
15+
This format explicitly trades anti-cloning guarantees for portability. Because the
16+
credential data and any associated keys are stored in a highly portable container,
17+
the credential can be trivially copied.
18+
19+
For high-value credentials where cloning or replay attacks are active threat
20+
vectors (e.g., mobile driving licenses or financial instruments), this file format
21+
is inherently unsuitable. In those high-assurance scenarios, issuers must leverage
22+
a robust provisioning protocol like [OpenID4VCI](https://github.com/openid/OpenID4VCI) to ensure secure delivery and
23+
hardware-backed device-binding at the time of issuance.
24+
25+
## Data format
26+
27+
The data is encoded in [CBOR](https://datatracker.ietf.org/doc/html/rfc8949) conforming to the following [CDDL](https://datatracker.ietf.org/doc/html/rfc8610):
28+
29+
```cddl
30+
; Top-level container.
31+
;
32+
MpzPass = [
33+
"MpzPass",
34+
CompressedCredentialDataBytes,
35+
]
36+
37+
; Contains CredentialDataBytes compressed using DEFLATE algorithm according
38+
; to [RFC 1951](https://www.ietf.org/rfc/rfc1951.txt).
39+
;
40+
CompressedCredentialDataBytes = bstr
41+
42+
CredentialDataBytes = bstr .cbor CredentialData
43+
44+
CredentialData = {
45+
"display": Display,
46+
"credential": Credential,
47+
}
48+
49+
; Display data.
50+
;
51+
Display = {
52+
? "name": tstr, ; Display name, e.g. "Erika's Driving License"
53+
? "typeName" : tstr ; Credential type, e.g. "Utopia Driving License"
54+
? "cardArt" bstr, ; PNG or JPEG with aspect ratio of 1.586 (cf. ID-1 from ISO/IEC 7810)
55+
}
56+
57+
; The data for the credential.
58+
;
59+
; At least one of the credential formats must be present. If both credential formats
60+
; are present they must include identical data.
61+
;
62+
; To protect the holder's privacy and prevent RP collusion, multiple credentials may be
63+
; included for a single format, allowing the wallet to rotate between credentials and/or
64+
; implement policy decisions such as single-use. If multiple credentials are included,
65+
; they must all include the same data except for key material and slight variances in
66+
; the validity period necessary to avoid RPs being able to correlate credentials from the
67+
; same batch.
68+
;
69+
Credential = {
70+
? "isoMdoc": [+ IsoMdocCredential],
71+
? "sdJwtVc": [+ SdJwtVcCredential],
72+
}
73+
74+
SdJwtVcCredential = {
75+
; The verifiable credential type.
76+
;
77+
vct: tstr,
78+
79+
; The private key for the key-binding JWT, if used.
80+
;
81+
? "deviceKeyPrivate": COSE_Key,
82+
83+
; The compact serialization of the SD-JWT VC, according to RFC 9901.
84+
;
85+
"compactSerialization": tstr,
86+
}
87+
88+
IsoMdocCredential = {
89+
; The document type.
90+
;
91+
"docType": tstr,
92+
93+
; The private key corresponding to DeviceKey in `issuerSigned`.
94+
;
95+
"deviceKeyPrivate": COSE_Key,
96+
97+
; IssuerSigned according to ISO/IEC 18013-5:2021 clause 8.3.2.1.2.2
98+
;
99+
"issuerSigned": IssuerSigned,
100+
}
101+
```
102+
103+
## MIME Type and file extension
104+
105+
The MIME type `application/vnd.multipaz.mpzpass` shall be used for data containing credentials
106+
encoded in this format and the file extension `.mpzpass` shall be used for files containing
107+
credentials encoded in this format.
108+
109+
## Examples files
110+
111+
- [Driving license ISO mdoc](https://apps.multipaz.org/mpzpass/mDL.mpzpass)
112+
- [EU PID SD-JWT VC](https://apps.multipaz.org/mpzpass/EuPidSdJwt.mpzpass)
113+
- [Utopia Movie ticket SD-JWT VC w/o key-binding key](https://apps.multipaz.org/mpzpass/MovieTicketSdJwtKeyless.mpzpass)
114+

multipaz-dcapi/src/commonTest/kotlin/org/multipaz/presentment/DocumentStoreTestHarness.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,10 @@ class DocumentStoreTestHarness {
142142
documentTypeRepository = documentTypeRepository,
143143
showConsentPromptFn = ::promptModelSilentConsent,
144144
preferSignatureToKeyAgreement = true,
145-
domainMdocSignature = "mdoc",
146-
domainMdocKeyAgreement = "mdoc_key_agreement",
147-
domainKeylessSdJwt = "sdjwt_keyless",
148-
domainKeyBoundSdJwt = "sdjwt"
145+
domainsMdocSignature = listOf("mdoc"),
146+
domainsMdocKeyAgreement = listOf("mdoc_key_agreement"),
147+
domainsKeylessSdJwt = listOf("sdjwt_keyless"),
148+
domainsKeyBoundSdJwt = listOf("sdjwt")
149149
)
150150

151151
val now = Clock.System.now().truncateToWholeSeconds()

multipaz/src/commonMain/kotlin/org/multipaz/credential/Credential.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ import kotlinx.coroutines.sync.withLock
3131
import kotlin.time.Instant
3232
import kotlinx.io.bytestring.ByteString
3333
import org.multipaz.cbor.buildCborMap
34+
import org.multipaz.mpzpass.MpzPass
35+
import org.multipaz.securearea.KeyUnlockData
36+
import org.multipaz.securearea.software.SoftwareSecureArea
3437
import org.multipaz.tags.Tags
3538
import kotlin.concurrent.Volatile
39+
import kotlin.coroutines.cancellation.CancellationException
3640

3741
/**
3842
* Base class for credentials.
@@ -326,7 +330,7 @@ abstract class Credential {
326330

327331
// Deleted identifier for which this one is a replacement
328332
// Called by Document.deleteCredential()
329-
suspend fun replacementForDeleted() {
333+
internal suspend fun replacementForDeleted() {
330334
lock.withLock {
331335
replacementForIdentifier = null
332336
save()
@@ -341,6 +345,22 @@ abstract class Credential {
341345
*/
342346
open fun addSerializedData(builder: MapBuilder<CborBuilder>) {}
343347

348+
/**
349+
* Exports the credential as a [MpzPass] which can be shared with other applications.
350+
*
351+
* Note: If the credential is using key-binding, it must be backed by a [SoftwareSecureArea] in order
352+
* for this to work.
353+
*
354+
* @param keyUnlockData Optional unlock data required to read the underlying private key, if applicable.
355+
* @return The generated [MpzPass].
356+
* @throws IllegalStateException if the credential does not use a SoftwareSecureArea or if the credential
357+
* doesn't support being exported.
358+
*/
359+
@Throws(IllegalStateException::class, CancellationException::class)
360+
open suspend fun exportToMpzPass(keyUnlockData: KeyUnlockData? = null): MpzPass {
361+
throw IllegalStateException("This credential does not support export")
362+
}
363+
344364
/**
345365
* Serializes the credential.
346366
*

multipaz/src/commonMain/kotlin/org/multipaz/document/DocumentStore.kt

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,21 @@ import kotlinx.coroutines.flow.asSharedFlow
2727
import kotlinx.coroutines.sync.Mutex
2828
import kotlinx.coroutines.sync.withLock
2929
import kotlinx.io.bytestring.ByteString
30+
import kotlinx.io.bytestring.encodeToByteString
3031
import org.multipaz.cbor.Cbor
32+
import org.multipaz.cbor.buildCborMap
3133
import org.multipaz.credential.Credential
3234
import org.multipaz.credential.CredentialLoaderBuilder
35+
import org.multipaz.mdoc.credential.MdocCredential
36+
import org.multipaz.mpzpass.MpzPass
3337
import org.multipaz.provisioning.Provisioning
38+
import org.multipaz.sdjwt.credential.KeyBoundSdJwtVcCredential
39+
import org.multipaz.sdjwt.credential.KeylessSdJwtVcCredential
40+
import org.multipaz.securearea.software.SoftwareCreateKeySettings
41+
import org.multipaz.securearea.software.SoftwareSecureArea
3442
import org.multipaz.storage.NoRecordStorageException
3543
import org.multipaz.tags.Tags
44+
import kotlin.coroutines.cancellation.CancellationException
3645
import kotlin.time.Clock
3746
import kotlin.time.Instant
3847

@@ -262,6 +271,112 @@ class DocumentStore private constructor(
262271
return storage.getTable(documentTableSpec)
263272
}
264273

274+
/**
275+
* Imports an [MpzPass] into a [DocumentStore].
276+
*
277+
* Reconstructs the [Document] and internal credential objects (either ISO mDoc, SD-JWT VC, or both)
278+
* using the extracted data and keys in the passed-in [mpzPass] for credentials.
279+
*
280+
* The returned document will have the [Document.provisioned] flag set to `true`.
281+
*
282+
* @param mpzPass The []MpzPass] to import.
283+
* @param isoMdocDomain The domain string to use when creating ISO mdoc credentials.
284+
* @param sdJwtVcDomain The domain string to use when creating SD-JWT VC credentials.
285+
* @param keylessSdJwtVcDomain the domain string to use when creating keyless SD-JWT VC credentials.
286+
* @return The newly created [Document] containing the provisioned credentials.
287+
* @throws IllegalStateException if a SoftwareSecureArea implementation cannot be found in the repository.
288+
* @throws ImportMpzPassException if credential creation or certification fails.
289+
*/
290+
@Throws(IllegalStateException::class, ImportMpzPassException::class, CancellationException::class)
291+
suspend fun importMpzPass(
292+
mpzPass: MpzPass,
293+
isoMdocDomain: String = "mdoc",
294+
sdJwtVcDomain: String = "sdjwtvc",
295+
keylessSdJwtVcDomain: String = "sdjwtvc_keyless"
296+
): Document {
297+
val softwareSecureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)
298+
?: throw IllegalStateException(
299+
"No SoftwareSecureArea implementation found"
300+
)
301+
302+
val document = try {
303+
createDocument(
304+
displayName = mpzPass.name,
305+
typeDisplayName = mpzPass.typeName,
306+
cardArt = mpzPass.cardArt,
307+
)
308+
} catch (e: Exception) {
309+
if (e is CancellationException) throw e
310+
throw ImportMpzPassException("Failed to create document", e)
311+
}
312+
313+
try {
314+
mpzPass.isoMdoc.forEach { isoMdoc ->
315+
val importedKeyInfo = softwareSecureArea.createKey(
316+
alias = null,
317+
createKeySettings = SoftwareCreateKeySettings.Builder()
318+
.setPrivateKey(isoMdoc.deviceKeyPrivate)
319+
.build()
320+
)
321+
val credential = MdocCredential.createForExistingAlias(
322+
document = document,
323+
asReplacementForIdentifier = null,
324+
domain = isoMdocDomain,
325+
secureArea = softwareSecureArea,
326+
docType = isoMdoc.docType,
327+
existingKeyAlias = importedKeyInfo.alias,
328+
)
329+
credential.certify(
330+
issuerProvidedAuthenticationData = ByteString(
331+
Cbor.encode(buildCborMap {
332+
put("nameSpaces", isoMdoc.issuerNamespaces.toDataItem())
333+
put("issuerAuth", isoMdoc.issuerAuth.toDataItem())
334+
})
335+
)
336+
)
337+
}
338+
339+
mpzPass.sdJwtVc.forEach { sdJwtVc ->
340+
val credential = if (sdJwtVc.deviceKeyPrivate != null) {
341+
val importedKeyInfo = softwareSecureArea.createKey(
342+
alias = null,
343+
createKeySettings = SoftwareCreateKeySettings.Builder()
344+
.setPrivateKey(sdJwtVc.deviceKeyPrivate)
345+
.build()
346+
)
347+
KeyBoundSdJwtVcCredential.createForExistingAlias(
348+
document = document,
349+
asReplacementForIdentifier = null,
350+
domain = sdJwtVcDomain,
351+
secureArea = softwareSecureArea,
352+
vct = sdJwtVc.vct,
353+
existingKeyAlias = importedKeyInfo.alias,
354+
)
355+
} else {
356+
KeylessSdJwtVcCredential.create(
357+
document = document,
358+
asReplacementForIdentifier = null,
359+
domain = keylessSdJwtVcDomain,
360+
vct = sdJwtVc.vct
361+
)
362+
}
363+
364+
credential.certify(
365+
issuerProvidedAuthenticationData = sdJwtVc.compactSerialization.encodeToByteString()
366+
)
367+
}
368+
369+
document.edit {
370+
provisioned = true
371+
}
372+
return document
373+
} catch (e: Exception) {
374+
deleteDocument(document.identifier)
375+
if (e is CancellationException) throw e
376+
throw ImportMpzPassException("Failed importing credentials", e)
377+
}
378+
}
379+
265380
/**
266381
* A builder for DocumentStore.
267382
*
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.multipaz.document
2+
3+
/**
4+
* Thrown when importing a [org.multipaz.mpzpass.MpzPass] into [DocumentStore] fails.
5+
*
6+
* @property message the message or `null`.
7+
* @property cause the cause of `null`.
8+
*/
9+
class ImportMpzPassException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)

0 commit comments

Comments
 (0)