Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions mpzpass/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,24 @@ CompressedCredentialDataBytes = bstr

CredentialDataBytes = bstr .cbor CredentialData

; Credential data.
;
; Each pass has an unique identifier assigned by the issuer which can be used by a wallet
; to check if it has already imported the pass. This identifier also serves as a authentication
; secret which can be used to check for updates. A pass also has a version field which is a
; monotonically increasing number starting at 0 and represents the version of the pass.
;
; The issuer may also include a URL to update for the wallet to check for updates. The
; wallet can use a HTTP POST to check for update with the body `update <uniqueId> <version>`.
; If HTTP status code 204 is returned it means no update is available and if HTTP status
; code 200 is returned, the bytes of the updated pass will be included in the HTTP response.
;
CredentialData = {
"uniqueId": tstr, ; Unique identifier for the pass, containing only alphanumerical
; and underscore and hyphen characters and contains at least 128
; bit of entropy.
"version": uint, ; Version of the pass, monotonically increasing starting at 0.
? "updateUrl": tstr ; If set, an URL where the wallet can download updates.
"display": Display,
"credential": Credential,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,17 @@ class DocumentModel private constructor(
if (existingDocumentInfo == null) {
Logger.w(TAG, "Didn't find DocumentInfo for document with id $id")
} else {
val newDocumentInfo = document.toDocumentInfo()
if (newDocumentInfo != existingDocumentInfo) {
remove(existingDocumentInfo)
add(newDocumentInfo)
} else {
Logger.w(TAG, "DocumentInfo for document with id $id didn't change")
try {
val newDocumentInfo = document.toDocumentInfo()
if (newDocumentInfo != existingDocumentInfo) {
remove(existingDocumentInfo)
add(newDocumentInfo)
} else {
Logger.w(TAG, "DocumentInfo for document with id $id didn't change")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Logger.w(TAG, "Error generating DocumentInfo", e)
}
}
}.sorted()
Expand Down
34 changes: 19 additions & 15 deletions multipaz-swiftui/src/iosMain/swift/DocumentModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,30 +71,34 @@ public class DocumentModel {
self.documentTypeRepository = documentTypeRepository
self.documentOrderKey = documentOrderKey
self.documentStore = documentStore
self.storageData = if let encoded = try! await documentStore.getTags().getByteString(key: documentOrderKey) {
self.storageData = if let encoded = try await documentStore.getTags().getByteString(key: documentOrderKey) {
DocumentModelStorageData.fromDataItem(
try! Cbor.shared.decode(encodedCbor: encoded.toByteArray(startIndex: 0, endIndex: encoded.size))
try Cbor.shared.decode(encodedCbor: encoded.toByteArray(startIndex: 0, endIndex: encoded.size))
)
} else {
DocumentModelStorageData()
}

for document in try! await documentStore.listDocuments(sort: true) {
await _documentInfos.append(getDocumentInfo(document))
for document in try await documentStore.listDocuments(sort: true) {
await _documentInfos.append(try getDocumentInfo(document))
}
Task {
for await event in documentStore.eventFlow {
if event is DocumentAdded {
let document = try! await documentStore.lookupDocument(identifier: event.documentId)
let document = try await documentStore.lookupDocument(identifier: event.documentId)
if document != nil {
await self._documentInfos.append(getDocumentInfo(document!))
await self._documentInfos.append(try getDocumentInfo(document!))
}
} else if event is DocumentUpdated {
let index = self._documentInfos.firstIndex { documentInfo in
documentInfo.document.identifier == event.documentId
}
if (index != nil) {
self._documentInfos[index!] = await getDocumentInfo(self._documentInfos[index!].document)
do {
if (index != nil) {
self._documentInfos[index!] = await try getDocumentInfo(self._documentInfos[index!].document)
}
} catch {
print("Ignoring error in getDocumentInfo() for DocumentUpdated event: \(error)")
}
} else if event is DocumentDeleted {
self._documentInfos.removeAll { documentInfo in
Expand Down Expand Up @@ -161,10 +165,10 @@ public class DocumentModel {
)
}

private func getDocumentInfo(_ document: Document) async -> DocumentInfo {
private func getDocumentInfo(_ document: Document) async throws -> DocumentInfo {
var credentialInfos: [CredentialInfo] = []
for credential in try! await document.getCredentials() {
await credentialInfos.append(getCredentialInfo(credential))
for credential in try await document.getCredentials() {
await credentialInfos.append(try getCredentialInfo(credential))
}
return DocumentInfo(
document: document,
Expand All @@ -173,16 +177,16 @@ public class DocumentModel {
)
}

private func getCredentialInfo(_ credential: Credential) async -> CredentialInfo {
private func getCredentialInfo(_ credential: Credential) async throws -> CredentialInfo {

var keyInfo: KeyInfo? = nil
var keyInvalidated = false
if let secureAreaBoundCredential = credential as? SecureAreaBoundCredential {
keyInfo = try! await secureAreaBoundCredential.secureArea.getKeyInfo(alias: secureAreaBoundCredential.alias)
keyInvalidated = try! await secureAreaBoundCredential.isInvalidated().boolValue
keyInfo = try await secureAreaBoundCredential.secureArea.getKeyInfo(alias: secureAreaBoundCredential.alias)
keyInvalidated = try await secureAreaBoundCredential.isInvalidated().boolValue
}
let claims: [Claim] = if credential.isCertified {
try! await credential.getClaims(documentTypeRepository: documentTypeRepository)
try await credential.getClaims(documentTypeRepository: documentTypeRepository)
} else {
[]
}
Expand Down
9 changes: 0 additions & 9 deletions multipaz/config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -758,15 +758,6 @@
<ID>UndocumentedPublicProperty:Document.kt$Document$val created: Instant get() = data.created</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Companion$val defaultTableSpec = object: StorageTableSpec( name = "Documents", supportPartitions = false, supportExpiration = false, schemaVersion = 1 ) { override suspend fun schemaUpgrade(oldTable: BaseStorageTable) { var version = oldTable.spec.schemaVersion // Keep all version upgrades basically forever, so that we can upgrade even from // the oldest used schema. if (version == 0L) { version = upgradeSchema0To1(oldTable) } // Add future schema version upgrades here if (version != schemaVersion) { throw IllegalStateException("Unexpected schema version $version") } } private suspend fun upgradeSchema0To1(table: BaseStorageTable): Long { val customFn = customSchema0_97_0_MigrationFn Logger.i(TAG, "Upgrading Documents table schema version from 0 to 1...") if (customFn != null) { Logger.i(TAG, "Using custom migration function") for (documentId in table.enumerate()) { val data = table.get(documentId) if (data == null) { Logger.e(TAG, "Error reading document '$documentId'") table.delete(documentId) // just in case } else { table.update( key = documentId, data = customFn.invoke(documentId, data) ) } } } else { val now = Clock.System.now() for (documentId in table.enumerate()) { // We want to be very defensive here val data = table.get(documentId) val newData = if (data == null) { Logger.e(TAG, "Error reading document '$documentId'") // keep the document as not provisioned; this will have to be handled by // the app (e.g. consult credentials, delete document, // update metadata from the server, etc.) DocumentData(provisioned = false, created = now) } else { try { // This corresponds to default DocumentMetadata serialization in schema // version 0. Apps might have used their own serialization, in that case // we will be out of luck here. val existing = SchemaV0DocumentData.fromCbor(data.toByteArray()) DocumentData( provisioned = existing.provisioned, created = now, displayName = existing.displayName, typeDisplayName = existing.typeDisplayName, cardArt = existing.cardArt, issuerLogo = existing.issuerLogo, authorizationData = existing.authorizationData, metadata = existing.other ) } catch (err: Exception) { Logger.e(TAG, "Error parsing document '$documentId'", err) // keep the document as not provisioned; this will have to be handled by // the app (e.g. consult credentials, delete document, // update metadata from the server, etc.) Keep existing data in other. DocumentData(provisioned = false, created = now, metadata = data) } } try { table.update(documentId, ByteString(newData.toCbor())) } catch (err: Exception) { Logger.e(TAG, "Error writing document '$documentId'", err) table.delete(documentId) } } } Logger.i(TAG, "Upgraded Documents table schema version from 0 to 1") return 1L } }</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Companion$var customSchema0_97_0_MigrationFn: ((documentId: String, data: ByteString) -> ByteString)? = null</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$val tags: Tags.Editor</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var authorizationData: ByteString?</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var cardArt: ByteString?</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var created: Instant</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var displayName: String?</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var issuerLogo: ByteString?</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var metadata: AbstractDocumentMetadata?</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var provisioned: Boolean</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.Editor$var typeDisplayName: String?</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.UsableCredentialResult$val numCredentials: Int</ID>
<ID>UndocumentedPublicProperty:Document.kt$Document.UsableCredentialResult$val numCredentialsAvailable: Int</ID>
<ID>UndocumentedPublicProperty:DocumentAttributeType.kt$DocumentAttributeType.IntegerOptions$val options: List&lt;IntegerOption></ID>
Expand Down
20 changes: 20 additions & 0 deletions multipaz/src/commonMain/kotlin/org/multipaz/document/Document.kt
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ class Document internal constructor(
*/
val authorizationData: ByteString? get() = data.authorizationData

/**
* The unique identifier if this document is imported from a [org.multipaz.mpzpass.MpzPass].
*/
val mpzPassId: String? get() = data.mpzPassId

/**
* A [Tags] for storing application-specific data.
*
Expand All @@ -167,6 +172,7 @@ class Document internal constructor(
cardArt = data.cardArt,
issuerLogo = data.issuerLogo,
authorizationData = data.authorizationData,
mpzPassId = data.mpzPassId,
metadata = data.metadata,
tagsData = if (newData.asMap.isEmpty()) {
null
Expand Down Expand Up @@ -428,6 +434,7 @@ class Document internal constructor(
cardArt = data.cardArt,
issuerLogo = data.issuerLogo,
authorizationData = data.authorizationData,
mpzPassId = data.mpzPassId,
metadata = metadata,
tags = Tags.Editor(this@Document.tags._tags)
)
Expand All @@ -447,6 +454,7 @@ class Document internal constructor(
cardArt = editor.cardArt,
issuerLogo = editor.issuerLogo,
authorizationData = editor.authorizationData,
mpzPassId = editor.mpzPassId,
metadata = editor.metadata?.serialize(),
tagsData = newTagsData?.let { ByteString(Cbor.encode(it)) }
)
Expand All @@ -467,6 +475,17 @@ class Document internal constructor(

/**
* An interface to edit [Document] metadata
*
* @property provisioned Whether the document is provisioned, i.e. issuer is ready to provide credentials.
* @property created The time the document was created.
* @property displayName User-facing name of this specific [Document] instance, e.g. "John's Passport".
* @property typeDisplayName User-facing name of this document type, e.g. "Utopia Passport".
* @property cardArt An image that represents this document to the user in the UI.
* @property issuerLogo An image that represents the issuer of the document in the UI.
* @property authorizationData Saved authorization data to refresh credentials, possibly without requiring user to re-authorize.
* @property mpzPassId The unique identifier if this document is imported from a [org.multipaz.mpzpass.MpzPass].
* @property metadata A [AbstractDocumentMetadata] for storing application-specific data.
* @property tags A [Tags] for storing application-specific data.
*/
class Editor internal constructor(
var provisioned: Boolean,
Expand All @@ -476,6 +495,7 @@ class Document internal constructor(
var cardArt: ByteString?,
var issuerLogo: ByteString?,
var authorizationData: ByteString?,
var mpzPassId: String?,
var metadata: AbstractDocumentMetadata?,
val tags: Tags.Editor
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal data class DocumentData(
val cardArt: ByteString? = null,
val issuerLogo: ByteString? = null,
val authorizationData: ByteString? = null,
val mpzPassId: String? = null,
val metadata: ByteString? = null, // serialized AbstractDocumentMetadata
val tagsData: ByteString? = null // serialized Tags
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,18 +272,20 @@ class DocumentStore private constructor(
}

/**
* Imports an [MpzPass] into a [DocumentStore].
* Imports a [MpzPass] into a [DocumentStore].
*
* Reconstructs the [Document] and internal credential objects (either ISO mDoc, SD-JWT VC, or both)
* using the extracted data and keys in the passed-in [mpzPass] for credentials.
* The returned document will have the [Document.provisioned] flag set to `true` and [Document.mpzPassId]
* will be set to [MpzPass.uniqueId].
*
* The returned document will have the [Document.provisioned] flag set to `true`.
* If the pass had been previously imported, the same [Document] will be returned and the credentials
* will be updated.
*
* @param mpzPass The []MpzPass] to import.
* @param mpzPass The [MpzPass] to import.
* @param isoMdocDomain The domain string to use when creating ISO mdoc credentials.
* @param sdJwtVcDomain The domain string to use when creating SD-JWT VC credentials.
* @param keylessSdJwtVcDomain the domain string to use when creating keyless SD-JWT VC credentials.
* @return The newly created [Document] containing the provisioned credentials.
* @return An existing [Document] if updating, otherwise a newly created [Document]. In both cases
* the returned document will have the credentials included in [mpzPass].
* @throws IllegalStateException if a SoftwareSecureArea implementation cannot be found in the repository.
* @throws ImportMpzPassException if credential creation or certification fails.
*/
Expand All @@ -300,11 +302,19 @@ class DocumentStore private constructor(
)

val document = try {
createDocument(
displayName = mpzPass.name,
typeDisplayName = mpzPass.typeName,
cardArt = mpzPass.cardArt,
)
val existingDocument = listDocuments().find { it.mpzPassId == mpzPass.uniqueId }
if (existingDocument != null) {
existingDocument.getCredentials().forEach { credential ->
credential.deleteCredential()
}
existingDocument
} else {
createDocument(
displayName = mpzPass.name,
typeDisplayName = mpzPass.typeName,
cardArt = mpzPass.cardArt,
)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
throw ImportMpzPassException("Failed to create document", e)
Expand Down Expand Up @@ -368,6 +378,7 @@ class DocumentStore private constructor(

document.edit {
provisioned = true
mpzPassId = mpzPass.uniqueId
}
return document
} catch (e: Exception) {
Expand Down
Loading
Loading