Skip to content

Commit 10fc6f6

Browse files
authored
Support for multi-signed requests. (#1781)
Multisigned for both OpenID4VP and ISO 18013 protocols. This required API change to be able to pass multiple verifier identities instead of a pair of key and client id. Test: added testing functionality to new verifier and testapp, new unit tests. Signed-off-by: Peter Sorotokin <sorotokin@gmail.com>
1 parent 4907a0c commit 10fc6f6

61 files changed

Lines changed: 1089 additions & 477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/presentment/Consent.kt

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import org.multipaz.presentment.ConsentData
126126
import org.multipaz.presentment.ConsentUseCase
127127
import org.multipaz.request.MdocRequestedClaim
128128
import org.multipaz.request.Requester
129+
import org.multipaz.request.TrustedRequesterIdentity
129130
import org.multipaz.trustmanagement.TrustMetadata
130131
import org.multipaz.util.Logger
131132
import kotlin.math.min
@@ -140,7 +141,7 @@ private const val TAG = "Consent"
140141
*
141142
* @param modifier a [Modifier].
142143
* @param requester the relying party which is requesting the data.
143-
* @param trustMetadata [TrustMetadata] conveying the level of trust in the requester, if any.
144+
* @param trustedRequesterIdentity conveys the level of trust in the requester, if any.
144145
* @param consentData the combinations of credentials and claims that the user can select.
145146
* @param preselectedDocuments the list of documents the user may have preselected earlier (for
146147
* example an OS-provided credential picker like Android's Credential Manager) or the empty list
@@ -156,7 +157,7 @@ private const val TAG = "Consent"
156157
fun Consent(
157158
modifier: Modifier = Modifier,
158159
requester: Requester,
159-
trustMetadata: TrustMetadata?,
160+
trustedRequesterIdentity: TrustedRequesterIdentity?,
160161
consentData: ConsentData,
161162
preselectedDocuments: List<Document>,
162163
imageLoader: ImageLoader?,
@@ -263,7 +264,7 @@ fun Consent(
263264
composable("main") {
264265
ConsentPage(
265266
requester = requester,
266-
trustMetadata = trustMetadata,
267+
trustedRequesterIdentity = trustedRequesterIdentity,
267268
appInfo = appInfo,
268269
imageLoader = imageLoader,
269270
consentData = consentData,
@@ -287,8 +288,7 @@ fun Consent(
287288

288289
composable("showRequesterInfo") {
289290
ShowRequesterInfoPage(
290-
requester = requester,
291-
trustMetadata = trustMetadata,
291+
trustedRequesterIdentity = trustedRequesterIdentity,
292292
onBackClicked = {
293293
navController.navigateUp()
294294
},
@@ -318,8 +318,7 @@ fun Consent(
318318

319319
@Composable
320320
private fun ShowRequesterInfoPage(
321-
requester: Requester,
322-
trustMetadata: TrustMetadata?,
321+
trustedRequesterIdentity: TrustedRequesterIdentity?,
323322
onBackClicked: () -> Unit,
324323
) {
325324
Column(
@@ -350,7 +349,7 @@ private fun ShowRequesterInfoPage(
350349
}
351350
}
352351

353-
requester.certChain?.let { certChain ->
352+
trustedRequesterIdentity?.identity?.certChain?.let { certChain ->
354353
Box(
355354
modifier = Modifier.fillMaxHeight()
356355
) {
@@ -420,7 +419,7 @@ private data class RequesterDisplayData(
420419
@Composable
421420
private fun ConsentPage(
422421
requester: Requester,
423-
trustMetadata: TrustMetadata?,
422+
trustedRequesterIdentity: TrustedRequesterIdentity?,
424423
appInfo: ApplicationInfo?,
425424
imageLoader: ImageLoader?,
426425
consentData: ConsentData,
@@ -433,6 +432,7 @@ private fun ConsentPage(
433432
) {
434433
val scrollState = rememberScrollState()
435434

435+
val trustMetadata = trustedRequesterIdentity?.trustMetadata
436436
val requesterDisplayData = if (trustMetadata != null) {
437437
RequesterDisplayData(
438438
name = trustMetadata.displayName,
@@ -457,9 +457,8 @@ private fun ConsentPage(
457457

458458
Column {
459459
RelyingPartySection(
460-
requester = requester,
460+
trustedRequesterIdentity = trustedRequesterIdentity,
461461
requesterDisplayData = requesterDisplayData,
462-
trustMetadata = trustMetadata,
463462
imageLoader = imageLoader,
464463
consentData = consentData,
465464
selections = selections,
@@ -500,7 +499,7 @@ private fun ConsentPage(
500499
FloatingItemContainer {
501500
RelyingPartyTrailer(
502501
requester = requester,
503-
trustMetadata = trustMetadata
502+
trustMetadata = trustMetadata,
504503
)
505504
}
506505

@@ -1061,7 +1060,7 @@ private fun RelyingPartyTrailer(
10611060
} else if (requester.appId != null) {
10621061
stringResource(Res.string.credential_presentment_warning_verifier_not_in_trust_list_app)
10631062
} else {
1064-
if (requester.certChain != null) {
1063+
if (requester.requesterIdentities.isNotEmpty()) {
10651064
stringResource(Res.string.credential_presentment_warning_verifier_not_in_trust_list)
10661065
} else {
10671066
stringResource(Res.string.credential_presentment_warning_verifier_not_in_trust_list_anonymous)
@@ -1209,9 +1208,8 @@ private fun ClaimsView(
12091208

12101209
@Composable
12111210
private fun RelyingPartySection(
1212-
requester: Requester,
12131211
requesterDisplayData: RequesterDisplayData,
1214-
trustMetadata: TrustMetadata?,
1212+
trustedRequesterIdentity: TrustedRequesterIdentity?,
12151213
imageLoader: ImageLoader?,
12161214
consentData: ConsentData,
12171215
selections: List<Int>,
@@ -1225,23 +1223,19 @@ private fun RelyingPartySection(
12251223
modifier = Modifier.fillMaxWidth(),
12261224
horizontalAlignment = Alignment.CenterHorizontally
12271225
) {
1228-
val requesterName = if (requesterDisplayData.name != null) {
1229-
requesterDisplayData.name
1230-
} else {
1231-
if (trustMetadata != null && requester.certChain != null) {
1232-
// If we have a trust point without `displayName` use the name in the root certificate.
1233-
requester.certChain!!.certificates.last().subject.name
1234-
} else {
1235-
// We could distinguish between anonymous and unknown request but that's already
1236-
// done in the warning text
1237-
stringResource(Res.string.credential_presentment_headline_share_with_unknown_requester)
1238-
}
1239-
}
1226+
val requesterName = requesterDisplayData.name
1227+
// If we have a trust point without `displayName` use the name in the root certificate.
1228+
?: trustedRequesterIdentity?.identity?.certChain?.certificates?.last()?.subject?.name
1229+
// We could distinguish between anonymous and unknown request but that's already
1230+
// done in the warning text
1231+
?: stringResource(Res.string.credential_presentment_headline_share_with_unknown_requester)
12401232

12411233
if (requesterDisplayData.icon != null) {
12421234
Icon(
12431235
modifier = Modifier.size(80.dp)
1244-
.clickable(enabled = requester.certChain != null) { onShowRequesterInfo() },
1236+
.clickable(enabled = trustedRequesterIdentity?.identity?.certChain != null) {
1237+
onShowRequesterInfo()
1238+
},
12451239
bitmap = requesterDisplayData.icon,
12461240
contentDescription = stringResource(Res.string.credential_presentment_verifier_icon_description),
12471241
tint = Color.Unspecified,
@@ -1250,7 +1244,9 @@ private fun RelyingPartySection(
12501244
} else if (requesterDisplayData.iconUrl != null && imageLoader != null) {
12511245
AsyncImage(
12521246
modifier = Modifier.size(80.dp)
1253-
.clickable(enabled = requester.certChain != null) { onShowRequesterInfo() },
1247+
.clickable(enabled = trustedRequesterIdentity?.identity?.certChain != null) {
1248+
onShowRequesterInfo()
1249+
},
12541250
model = requesterDisplayData.iconUrl,
12551251
imageLoader = imageLoader,
12561252
contentScale = ContentScale.Crop,
@@ -1260,7 +1256,9 @@ private fun RelyingPartySection(
12601256
Spacer(modifier = Modifier.height(8.dp))
12611257
Text(
12621258
modifier = Modifier
1263-
.clickable(enabled = requester.certChain != null) { onShowRequesterInfo() },
1259+
.clickable(enabled = trustedRequesterIdentity?.identity?.certChain != null) {
1260+
onShowRequesterInfo()
1261+
},
12641262
text = requesterName,
12651263
textAlign = TextAlign.Center,
12661264
style = MaterialTheme.typography.titleLarge,

multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/presentment/ConsentModalBottomSheet.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ import org.multipaz.document.Document
1616
import org.multipaz.presentment.CredentialSelection
1717
import org.multipaz.presentment.ConsentData
1818
import org.multipaz.request.Requester
19-
import org.multipaz.trustmanagement.TrustMetadata
19+
import org.multipaz.request.TrustedRequesterIdentity
2020

2121
/**
2222
* Bottom sheet used for obtaining consent when presenting one or more credentials.
2323
*
2424
* @param sheetState a [SheetState] for state.
2525
* @param requester the relying party which is requesting the data.
26-
* @param trustMetadata [TrustMetadata] conveying the level of trust in the requester, if any.
26+
* @param trustedRequesterIdentity conveys the level of trust in the requester, if any.
2727
* @param consentData the combinations of credentials and claims that the user can select.
2828
* @param preselectedDocuments the list of documents the user may have preselected earlier (for
2929
* example an OS-provided credential picker like Android's Credential Manager) or the empty list
@@ -40,7 +40,7 @@ import org.multipaz.trustmanagement.TrustMetadata
4040
fun ConsentModalBottomSheet(
4141
sheetState: SheetState,
4242
requester: Requester,
43-
trustMetadata: TrustMetadata?,
43+
trustedRequesterIdentity: TrustedRequesterIdentity?,
4444
consentData: ConsentData,
4545
preselectedDocuments: List<Document>,
4646
imageLoader: ImageLoader?,
@@ -60,7 +60,7 @@ fun ConsentModalBottomSheet(
6060
modifier = (if (maxHeight != null) Modifier.heightIn(max = maxHeight) else Modifier)
6161
.animateContentSize(),
6262
requester = requester,
63-
trustMetadata = trustMetadata,
63+
trustedRequesterIdentity = trustedRequesterIdentity,
6464
consentData = consentData,
6565
preselectedDocuments = preselectedDocuments,
6666
imageLoader = imageLoader,

multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/prompt/ConsentPromptDialog.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ fun ConsentPromptDialog(
3232
ConsentModalBottomSheet(
3333
sheetState = sheetState,
3434
requester = dialogParameters.requester,
35-
trustMetadata = dialogParameters.trustMetadata,
35+
trustedRequesterIdentity = dialogParameters.trustedRequesterIdentity,
3636
consentData = dialogParameters.consentData,
3737
preselectedDocuments = dialogParameters.preselectedDocuments,
3838
imageLoader = imageLoader,

multipaz-dcapi/src/androidInstrumentedTest/kotlin/org/multipaz/presentment/MatcherDcqlQueryExecuteTest.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import org.multipaz.digitalcredentials.getDefault
2424
import org.multipaz.mdoc.util.MdocUtil
2525
import org.multipaz.openid.OpenID4VP
2626
import org.multipaz.util.toBase64Url
27+
import org.multipaz.verification.VerifierIdentity
2728
import kotlin.random.Random
2829

2930
// This is like the tests in org.multipaz.openid.dcql.* in commonTest but this time for
@@ -59,7 +60,7 @@ class MatcherDcqlQueryExecuteTest {
5960
harness.initialize()
6061
harnessInitializer(harness)
6162

62-
val nonce = Random.Default.nextBytes(16).toBase64Url()
63+
val nonce = Random.nextBytes(16).toBase64Url()
6364

6465
val readerAuthKey = if (signRequest) {
6566
val key = Crypto.createEcPrivateKey(EcCurve.P256)
@@ -84,10 +85,11 @@ class MatcherDcqlQueryExecuteTest {
8485
val requestData = OpenID4VP.generateRequest(
8586
version = version,
8687
origin = ORIGIN,
87-
clientId = CLIENT_ID,
8888
nonce = nonce,
8989
responseEncryptionKey = encryptionKey?.publicKey,
90-
requestSigningKey = readerAuthKey,
90+
verifierIdentities = buildList {
91+
readerAuthKey?.let { add(VerifierIdentity(it, CLIENT_ID)) }
92+
},
9193
responseMode = OpenID4VP.ResponseMode.DC_API,
9294
responseUri = null,
9395
dcqlQuery = Json.decodeFromString(JsonObject.serializer(), dcql)

multipaz-dcapi/src/androidInstrumentedTest/kotlin/org/multipaz/presentment/MatcherTest.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import org.multipaz.mdoc.request.buildDeviceRequestFromDcql
3939
import org.multipaz.openid.OpenID4VP
4040
import org.multipaz.util.Logger
4141
import org.multipaz.util.toBase64Url
42+
import org.multipaz.verification.VerifierIdentity
4243
import kotlin.random.Random
4344

4445
// Tests for the matcher in multipaz-models/src/androidMain/matcher ...
@@ -94,10 +95,11 @@ class MatcherTest {
9495
val requestData = OpenID4VP.generateRequest(
9596
version = version,
9697
origin = ORIGIN,
97-
clientId = CLIENT_ID,
9898
nonce = nonce,
9999
responseEncryptionKey = encryptionKey?.publicKey,
100-
requestSigningKey = readerAuthKey,
100+
verifierIdentities = buildList {
101+
readerAuthKey?.let { add(VerifierIdentity(it, CLIENT_ID)) }
102+
},
101103
responseMode = OpenID4VP.ResponseMode.DC_API,
102104
responseUri = null,
103105
dcqlQuery = Json.decodeFromString(JsonObject.serializer(), dcql)
Binary file not shown.

multipaz-dcapi/src/androidMain/kotlin/org/multipaz/digitalcredentials/DigitalCredentialsExt.android.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ internal actual val defaultSupportedProtocols: Set<String>
406406
private val supportedProtocols = setOf(
407407
"openid4vp-v1-signed",
408408
"openid4vp-v1-unsigned",
409+
"openid4vp-v1-multisigned",
409410
"org-iso-mdoc",
410411
"openid4vp",
411412
)

multipaz-dcapi/src/androidMain/matcher/Request.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,13 @@ std::unique_ptr<OpenID4VPRequest> OpenID4VPRequest::parseOpenID4VP(cJSON* dataJs
469469
std::string payloadBase64 = jwtStr.substr(firstDot + 1, secondDot - firstDot - 1);
470470
std::string payload = base64UrlDecode(payloadBase64);
471471
dataJson = cJSON_Parse(payload.c_str());
472+
} else {
473+
cJSON* payloadItem = cJSON_GetObjectItem(dataJson, "payload");
474+
if (payloadItem != nullptr) {
475+
std::string payloadBase64 = std::string(cJSON_GetStringValue(payloadItem));
476+
std::string payload = base64UrlDecode(payloadBase64);
477+
dataJson = cJSON_Parse(payload.c_str());
478+
}
472479
}
473480

474481
cJSON* query = cJSON_GetObjectItem(dataJson, "dcql_query");

multipaz-dcapi/src/androidMain/matcher/matcher.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ extern "C" void matcher(void) {
4141

4242
if (protocolValue == "openid4vp" ||
4343
protocolValue == "openid4vp-v1-unsigned" ||
44-
protocolValue == "openid4vp-v1-signed") {
44+
protocolValue == "openid4vp-v1-signed" ||
45+
protocolValue == "openid4vp-v1-multisigned") {
4546

4647
auto request = OpenID4VPRequest::parseOpenID4VP(protocolData, protocolValue);
4748
auto dcqlResponse = request->dclqQuery.execute(db, protocolValue);

multipaz-swiftui/src/iosMain/swift/Consent.swift

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -247,21 +247,21 @@ public struct Consent: View {
247247
let maxHeight: CGFloat
248248
let consentData: ConsentData
249249
let requester: Requester
250-
let trustMetadata: TrustMetadata?
250+
let trustedRequesterIdentity: TrustedRequesterIdentity?
251251
let onConfirm: (_: CredentialSelection) -> Void
252252
let onCancel: () -> Void
253253

254254
public init(
255255
consentData: ConsentData,
256256
requester: Requester,
257-
trustMetadata: TrustMetadata?,
257+
trustedRequesterIdentity: TrustedRequesterIdentity?,
258258
maxHeight: CGFloat = .infinity,
259259
onConfirm: @escaping (_: CredentialSelection) -> Void,
260260
onCancel: @escaping () -> Void
261261
) {
262262
self.consentData = consentData
263263
self.requester = requester
264-
self.trustMetadata = trustMetadata
264+
self.trustedRequesterIdentity = trustedRequesterIdentity
265265
self.maxHeight = maxHeight
266266
self.onConfirm = onConfirm
267267
self.onCancel = onCancel
@@ -313,7 +313,7 @@ public struct Consent: View {
313313
public var body: some View {
314314
let rpName = getRelyingPartyName(
315315
requester: requester,
316-
trustMetadata: trustMetadata
316+
trustMetadata: trustedRequesterIdentity?.trustMetadata
317317
)
318318
NavigationStack(path: $path) {
319319
VStack {
@@ -325,12 +325,12 @@ public struct Consent: View {
325325
consentData: consentData,
326326
rpName: rpName,
327327
requester: requester,
328-
trustMetadata: trustMetadata,
328+
trustMetadata: trustedRequesterIdentity?.trustMetadata,
329329
selections: selections,
330330
sheetHeight: $sheetHeight,
331331
isActive: path.isEmpty,
332332
onRequesterClicked: {
333-
if requester.certChain != nil {
333+
if trustedRequesterIdentity != nil {
334334
path.append(ConsentDestinations.showRequesterInfo)
335335
}
336336
},
@@ -359,7 +359,8 @@ public struct Consent: View {
359359
case .showRequesterInfo:
360360
ShowRequesterInfo(
361361
maxHeight: maxHeight,
362-
requester: requester
362+
requester: requester,
363+
trustedRequesterIdentity: trustedRequesterIdentity!
363364
)
364365
case .pickSolution(let useCaseIndex):
365366
PickSolutionView(
@@ -382,13 +383,14 @@ public struct Consent: View {
382383
private struct ShowRequesterInfo: View {
383384
let maxHeight: CGFloat
384385
let requester: Requester
386+
let trustedRequesterIdentity: TrustedRequesterIdentity
385387
@State private var currentPage: Int = 0
386388
@State private var pageHeights: [Int: CGFloat] = [:]
387389

388390
var body: some View {
389391
SmartSheet(maxHeight: maxHeight, updateDetents: false) {
390392
} content: {
391-
let certificates = requester.certChain!.certificates
393+
let certificates = trustedRequesterIdentity.identity.certChain.certificates
392394
VStack {
393395
TabView(selection: $currentPage) {
394396
ForEach(0..<certificates.count, id: \.self) { index in
@@ -405,7 +407,7 @@ private struct ShowRequesterInfo: View {
405407
}
406408
}
407409
} footer: { isAtBottom, scrollDown in
408-
let certificates = requester.certChain!.certificates
410+
let certificates = trustedRequesterIdentity.identity.certChain.certificates
409411
if certificates.count > 1 {
410412
HStack(spacing: 4) {
411413
ForEach(0..<certificates.count, id: \.self) { index in

0 commit comments

Comments
 (0)