Skip to content

Commit ab468e6

Browse files
committed
fix(WAL-752): harden Digital Credentials platform integration
1 parent 79d744d commit ab468e6

20 files changed

Lines changed: 697 additions & 257 deletions

File tree

waltid-applications/waltid-wallet-demo-compose/androidApp/src/main/java/id/walt/walletdemo/compose/android/DigitalCredentialProviderActivity.kt

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import id.walt.wallet2.mobile.MobileWalletConfig
1414
import id.walt.wallet2.mobile.MobileWalletDigitalCredentialProtocols
1515
import id.walt.wallet2.mobile.MobileWalletDigitalCredentialPreview
1616
import id.walt.wallet2.mobile.MobileWalletFactory
17+
import id.walt.wallet2.mobile.MobileWalletPresentationCredentialOption
1718
import id.walt.wallet2.mobile.MobileWalletPresentationCredentialSelection
1819
import id.walt.wallet2.data.WalletX509TrustConfig
1920
import kotlinx.coroutines.CoroutineScope
@@ -63,10 +64,21 @@ class DigitalCredentialProviderActivity : ComponentActivity() {
6364
encryptionInfoBase64Url = encryptionInfo,
6465
)
6566
)
66-
showAnnexCConsent(wallet, preview, deviceRequest, encryptionInfo)
67+
selectCredentials(preview.credentialOptions) { selections, selectedOptions ->
68+
showAnnexCConsent(
69+
wallet,
70+
preview,
71+
deviceRequest,
72+
encryptionInfo,
73+
selections,
74+
selectedOptions,
75+
)
76+
}
6777
} else {
6878
val preview = wallet.previewDigitalCredentialPresentation(input.request)
69-
showConsent(wallet, preview)
79+
selectCredentials(preview.credentialOptions) { selections, selectedOptions ->
80+
showConsent(wallet, preview, selections, selectedOptions)
81+
}
7082
}
7183
}.onFailure {
7284
reportFailure(it)
@@ -79,23 +91,27 @@ class DigitalCredentialProviderActivity : ComponentActivity() {
7991
preview: MobileWalletAnnexCPreview,
8092
deviceRequest: String,
8193
encryptionInfo: String,
94+
selections: List<MobileWalletPresentationCredentialSelection>,
95+
selectedOptions: List<MobileWalletPresentationCredentialOption>,
8296
) {
8397
val trust = when (val value = preview.readerTrust) {
8498
is id.walt.wallet2.mobile.MobileWalletReaderTrust.Trusted -> "Trusted reader: ${value.certificateSubject}"
8599
is id.walt.wallet2.mobile.MobileWalletReaderTrust.Unverified -> "Unverified reader: ${value.reason}"
86100
id.walt.wallet2.mobile.MobileWalletReaderTrust.NotApplicable -> "Reader trust not applicable"
87101
}
88-
val claimLines = preview.credentialOptions.flatMap { option ->
102+
val claimLines = selectedOptions.flatMap { option ->
89103
option.disclosures.map { disclosure ->
90104
"${disclosure.name ?: disclosure.path}: ${disclosure.displayValue ?: disclosure.valueJson}"
91105
}
92106
}.distinct()
107+
val credentialLines = selectedOptions.map(::credentialTitle).distinct()
93108
AlertDialog.Builder(this)
94109
.setTitle("Share mobile document?")
95110
.setMessage(
96111
buildString {
97112
append("Requester: ${preview.verifiedOrigin}\n")
98113
append("$trust\n")
114+
append("\nCredential${if (credentialLines.size == 1) "" else "s"}:\n${credentialLines.joinToString("\n")}\n")
99115
if (claimLines.isNotEmpty()) append("\nData to share:\n${claimLines.joinToString("\n")}")
100116
}
101117
)
@@ -107,11 +123,6 @@ class DigitalCredentialProviderActivity : ComponentActivity() {
107123
.setPositiveButton("Share") { _, _ ->
108124
scope.launch {
109125
runCatching {
110-
val selections = preview.credentialOptions
111-
.groupBy { it.queryId }
112-
.map { (queryId, options) ->
113-
MobileWalletPresentationCredentialSelection(queryId, options.first().credentialId)
114-
}
115126
wallet.submitAnnexCPresentation(
116127
MobileWalletAnnexCSubmission(
117128
requestId = preview.requestId,
@@ -132,16 +143,23 @@ class DigitalCredentialProviderActivity : ComponentActivity() {
132143
.show()
133144
}
134145

135-
private fun showConsent(wallet: MobileWallet, preview: MobileWalletDigitalCredentialPreview) {
136-
val claimLines = preview.credentialOptions.flatMap { option ->
137-
option.disclosures.map { disclosure ->
146+
private fun showConsent(
147+
wallet: MobileWallet,
148+
preview: MobileWalletDigitalCredentialPreview,
149+
selections: List<MobileWalletPresentationCredentialSelection>,
150+
selectedOptions: List<MobileWalletPresentationCredentialOption>,
151+
) {
152+
val claimLines = selectedOptions.flatMap { option ->
153+
option.disclosures.filter { it.required || !it.selectable }.map { disclosure ->
138154
"${disclosure.name ?: disclosure.path}: ${disclosure.displayValue ?: disclosure.valueJson}"
139155
}
140156
}.distinct()
157+
val credentialLines = selectedOptions.map(::credentialTitle).distinct()
141158
val message = buildString {
142159
append("Requester: ${preview.request.verifierName ?: preview.verifiedOrigin}\n")
143160
append("Protocol: ${preview.protocol}\n")
144161
append("Response encryption: ${if (preview.encryption.isRequired) "required" else "not requested"}\n")
162+
append("\nCredential${if (credentialLines.size == 1) "" else "s"}:\n${credentialLines.joinToString("\n")}\n")
145163
if (claimLines.isNotEmpty()) append("\nData to share:\n${claimLines.joinToString("\n")}")
146164
}
147165
AlertDialog.Builder(this)
@@ -152,18 +170,17 @@ class DigitalCredentialProviderActivity : ComponentActivity() {
152170
AndroidDigitalCredentialProvider.setCancellation(resultIntent)
153171
finishProviderResult()
154172
}
155-
.setPositiveButton("Share") { _, _ -> submitAfterConsent(wallet, preview) }
173+
.setPositiveButton("Share") { _, _ -> submitAfterConsent(wallet, preview, selections) }
156174
.show()
157175
}
158176

159-
private fun submitAfterConsent(wallet: MobileWallet, preview: MobileWalletDigitalCredentialPreview) {
177+
private fun submitAfterConsent(
178+
wallet: MobileWallet,
179+
preview: MobileWalletDigitalCredentialPreview,
180+
selections: List<MobileWalletPresentationCredentialSelection>,
181+
) {
160182
scope.launch {
161183
runCatching {
162-
val selections = preview.credentialOptions
163-
.groupBy { it.queryId }
164-
.map { (queryId, options) ->
165-
MobileWalletPresentationCredentialSelection(queryId, options.first().credentialId)
166-
}
167184
wallet.submitDigitalCredentialPresentation(
168185
requestId = preview.requestId,
169186
selectedCredentialOptions = selections,
@@ -178,8 +195,65 @@ class DigitalCredentialProviderActivity : ComponentActivity() {
178195
}
179196
}
180197

198+
private fun selectCredentials(
199+
options: List<MobileWalletPresentationCredentialOption>,
200+
onSelected: (
201+
List<MobileWalletPresentationCredentialSelection>,
202+
List<MobileWalletPresentationCredentialOption>,
203+
) -> Unit,
204+
) {
205+
val groups = options.groupBy { it.queryId }.toSortedMap().toList()
206+
require(groups.isNotEmpty()) { "No credential satisfies this presentation request" }
207+
selectCredential(groups, 0, mutableListOf(), onSelected)
208+
}
209+
210+
private fun selectCredential(
211+
groups: List<Pair<String, List<MobileWalletPresentationCredentialOption>>>,
212+
index: Int,
213+
selected: MutableList<MobileWalletPresentationCredentialOption>,
214+
onSelected: (
215+
List<MobileWalletPresentationCredentialSelection>,
216+
List<MobileWalletPresentationCredentialOption>,
217+
) -> Unit,
218+
) {
219+
if (index == groups.size) {
220+
onSelected(
221+
selected.map { MobileWalletPresentationCredentialSelection(it.queryId, it.credentialId) },
222+
selected.toList(),
223+
)
224+
return
225+
}
226+
227+
val options = groups[index].second
228+
require(options.isNotEmpty()) { "No credential satisfies one of the presentation queries" }
229+
if (options.size == 1) {
230+
selected += options.single()
231+
selectCredential(groups, index + 1, selected, onSelected)
232+
return
233+
}
234+
235+
AlertDialog.Builder(this)
236+
.setTitle("Choose a credential")
237+
.setItems(options.map(::credentialTitle).toTypedArray()) { _, selectedIndex ->
238+
selected += options[selectedIndex]
239+
selectCredential(groups, index + 1, selected, onSelected)
240+
}
241+
.setCancelable(false)
242+
.setNegativeButton("Cancel") { _, _ ->
243+
AndroidDigitalCredentialProvider.setCancellation(resultIntent)
244+
finishProviderResult()
245+
}
246+
.show()
247+
}
248+
249+
private fun credentialTitle(option: MobileWalletPresentationCredentialOption): String =
250+
listOfNotNull(option.label, option.issuer, option.subject)
251+
.distinct()
252+
.joinToString(" · ")
253+
.ifEmpty { option.credentialId }
254+
181255
private fun reportFailure(error: Throwable) {
182-
Log.e(TAG, "Digital credential presentation failed (${error::class.simpleName}): ${error.message}")
256+
Log.e(TAG, "Digital credential presentation failed (${error::class.simpleName})")
183257
AndroidDigitalCredentialProvider.setFailure(resultIntent)
184258
finishProviderResult()
185259
}

waltid-applications/waltid-wallet-demo-ios/iosApp/IdentityDocumentProvider/IdentityDocumentProvider.swift

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,10 @@ import SwiftUI
66
import WalletSDK
77

88
private enum ProviderConfiguration {
9-
static let appGroupIdentifier = "group.id.walt.wallet.demo"
10-
static let documentTypesKey = "id.walt.wallet.identity-document-types"
11-
static let registryIDKey = "id.walt.wallet.identity-document-registry-id"
12-
static let registrationIDsKey = "id.walt.wallet.identity-document-registration-ids"
9+
static let appGroupIdentifier = IdentityDocumentSharedConfiguration.appGroupIdentifier
1310

1411
static var keychainAccessGroup: String {
15-
guard let value = Bundle.main.object(forInfoDictionaryKey: "WALTKeychainAccessGroup") as? String,
12+
guard let value = IdentityDocumentSharedConfiguration.keychainAccessGroup,
1613
!value.isEmpty else {
1714
preconditionFailure("The document provider requires a resolved shared Keychain access group")
1815
}
@@ -38,31 +35,8 @@ struct WaltIdentityDocumentProvider: IdentityDocumentProvider {
3835
}
3936

4037
func performRegistrationUpdates() async {
41-
guard let defaults = UserDefaults(suiteName: ProviderConfiguration.appGroupIdentifier) else { return }
42-
let store = IdentityDocumentProviderRegistrationStore()
43-
guard await store.status == .authorized else { return }
44-
do {
45-
for identifier in defaults.stringArray(forKey: ProviderConfiguration.registrationIDsKey) ?? [] {
46-
try await store.removeRegistration(forDocumentIdentifier: identifier)
47-
}
48-
let registryID = defaults.string(forKey: ProviderConfiguration.registryIDKey) ?? "empty"
49-
let documentTypes = (defaults.stringArray(forKey: ProviderConfiguration.documentTypesKey) ?? []).sorted()
50-
let identifiers = documentTypes.map {
51-
"id.walt.wallet.\(registryID).\(Data($0.utf8).base64EncodedString())"
52-
}
53-
for (documentType, identifier) in zip(documentTypes, identifiers) {
54-
try await store.addRegistration(
55-
MobileDocumentRegistration(
56-
mobileDocumentType: documentType,
57-
supportedAuthorityKeyIdentifiers: [],
58-
documentIdentifier: identifier
59-
)
60-
)
61-
}
62-
defaults.set(identifiers, forKey: ProviderConfiguration.registrationIDsKey)
63-
} catch {
64-
// IdentityDocumentServices invokes this method again when registration state changes.
65-
}
38+
// IdentityDocumentServices invokes this method again when registration state changes.
39+
try? await IdentityDocumentRegistrationCoordinator.update()
6640
}
6741
}
6842

@@ -73,6 +47,7 @@ private struct AnnexCConsentView: View {
7347
@State private var wallet: Wallet?
7448
@State private var failure: String?
7549
@State private var isSubmitting = false
50+
@State private var selectedCredentialIDsByQuery: [String: String] = [:]
7651

7752
var body: some View {
7853
NavigationStack {
@@ -85,7 +60,7 @@ private struct AnnexCConsentView: View {
8560
Text(preview.verifiedOrigin)
8661
}
8762
Section("Requested information") {
88-
ForEach(preview.parsedRequest.documents, id: \.documentType) { document in
63+
ForEach(Array(preview.parsedRequest.documents.enumerated()), id: \.offset) { _, document in
8964
VStack(alignment: .leading, spacing: 4) {
9065
Text(document.documentType).font(.headline)
9166
Text(document.namespaces.values.flatMap { $0 }.sorted().joined(separator: ", "))
@@ -94,6 +69,29 @@ private struct AnnexCConsentView: View {
9469
}
9570
}
9671
}
72+
Section("Credentials") {
73+
ForEach(queryIDs(in: preview), id: \.self) { queryID in
74+
let options = credentialOptions(for: queryID, in: preview)
75+
Picker(
76+
"Credential",
77+
selection: Binding<String?>(
78+
get: { selectedCredentialIDsByQuery[queryID] },
79+
set: { selectedCredentialIDsByQuery[queryID] = $0 }
80+
)
81+
) {
82+
Text("Choose a credential").tag(nil as String?)
83+
ForEach(options) { option in
84+
Text(credentialTitle(option)).tag(option.credentialID as String?)
85+
}
86+
}
87+
if let credentialID = selectedCredentialIDsByQuery[queryID],
88+
let selected = options.first(where: { $0.credentialID == credentialID }) {
89+
Text(credentialDetail(selected))
90+
.font(.caption)
91+
.foregroundStyle(.secondary)
92+
}
93+
}
94+
}
9795
Section("Reader trust") {
9896
Text(readerTrustDescription(preview.readerTrust))
9997
}
@@ -109,7 +107,7 @@ private struct AnnexCConsentView: View {
109107
}
110108
ToolbarItem(placement: .confirmationAction) {
111109
Button("Share") { submit() }
112-
.disabled(preview == nil || isSubmitting)
110+
.disabled(preview == nil || !hasCompleteSelection || isSubmitting)
113111
}
114112
}
115113
.task { await prepare() }
@@ -126,26 +124,40 @@ private struct AnnexCConsentView: View {
126124
let wallet = try await Wallet(configuration: ProviderConfiguration.walletConfiguration)
127125
_ = try await wallet.bootstrap()
128126
self.wallet = wallet
129-
preview = try await wallet.previewAnnexCPresentation(
127+
let preparedPreview = try await wallet.previewAnnexCPresentation(
130128
parsedRequest: parsed,
131129
verifiedOrigin: try canonicalOrigin(origin)
132130
)
131+
preview = preparedPreview
132+
selectedCredentialIDsByQuery = Dictionary(
133+
uniqueKeysWithValues: queryIDs(in: preparedPreview).compactMap { queryID in
134+
let options = credentialOptions(for: queryID, in: preparedPreview)
135+
return options.count == 1 ? (queryID, options[0].credentialID) : nil
136+
}
137+
)
133138
} catch {
134139
failure = error.localizedDescription
135140
}
136141
}
137142

138143
private func submit() {
139144
guard let wallet, let preview else { return }
145+
let selections = queryIDs(in: preview).compactMap { queryID -> PresentationCredentialSelection? in
146+
guard let credentialID = selectedCredentialIDsByQuery[queryID],
147+
let option = credentialOptions(for: queryID, in: preview).first(where: {
148+
$0.credentialID == credentialID
149+
}) else { return nil }
150+
return option.selection
151+
}
152+
guard selections.count == queryIDs(in: preview).count else {
153+
failure = ProviderFailure.missingCredentialSelection.localizedDescription
154+
return
155+
}
140156
isSubmitting = true
141157
Task {
142158
do {
143159
try await context.sendResponse { rawRequest in
144160
let raw = try RawAnnexCRequest(data: rawRequest.requestData)
145-
let selections = preview.credentialOptions.reduce(into: [PresentationCredentialSelection]()) { result, option in
146-
guard !result.contains(where: { $0.queryID == option.queryID }) else { return }
147-
result.append(option.selection)
148-
}
149161
let response = try await wallet.submitAnnexCPresentation(
150162
requestID: preview.requestID,
151163
verifiedOrigin: preview.verifiedOrigin,
@@ -169,6 +181,35 @@ private struct AnnexCConsentView: View {
169181
}
170182
}
171183

184+
private var hasCompleteSelection: Bool {
185+
guard let preview else { return false }
186+
return queryIDs(in: preview).allSatisfy { queryID in
187+
guard let credentialID = selectedCredentialIDsByQuery[queryID] else { return false }
188+
return credentialOptions(for: queryID, in: preview).contains { $0.credentialID == credentialID }
189+
}
190+
}
191+
192+
private func queryIDs(in preview: AnnexCPresentationPreview) -> [String] {
193+
Array(Set(preview.credentialOptions.map(\.queryID))).sorted()
194+
}
195+
196+
private func credentialOptions(
197+
for queryID: String,
198+
in preview: AnnexCPresentationPreview
199+
) -> [PresentationCredentialOption] {
200+
preview.credentialOptions.filter { $0.queryID == queryID }
201+
}
202+
203+
private func credentialTitle(_ option: PresentationCredentialOption) -> String {
204+
option.label ?? option.issuer ?? "Credential"
205+
}
206+
207+
private func credentialDetail(_ option: PresentationCredentialOption) -> String {
208+
[option.issuer, option.subject, option.credentialID]
209+
.compactMap { $0 }
210+
.joined(separator: " · ")
211+
}
212+
172213
private func parse(_ request: ISO18013MobileDocumentRequest) throws -> AnnexCParsedRequest {
173214
var documents: [AnnexCDocumentRequest] = []
174215
for presentment in request.presentmentRequests {
@@ -238,13 +279,15 @@ private enum ProviderFailure: LocalizedError {
238279
case alternativeRequestSetsUnsupported
239280
case emptyRequest
240281
case invalidResponseEncoding
282+
case missingCredentialSelection
241283
case missingVerifiedOrigin
242284

243285
var errorDescription: String? {
244286
switch self {
245287
case .alternativeRequestSetsUnsupported: return "Alternative document request sets are not supported"
246288
case .emptyRequest: return "The request does not contain any documents"
247289
case .invalidResponseEncoding: return "The encrypted response could not be encoded"
290+
case .missingCredentialSelection: return "Choose one credential for every requested document"
248291
case .missingVerifiedOrigin: return "IdentityDocumentServices did not assert a website origin"
249292
}
250293
}

0 commit comments

Comments
 (0)