From b0936bf82d8311d09778d317adff04b7ebc9366b Mon Sep 17 00:00:00 2001 From: David Zeuthen Date: Sat, 5 Sep 2026 06:23:45 -0400 Subject: [PATCH] Rework transaction data architecture across protocols and formats. Previously, transaction data handling attempted a one-size-fits-all model largely derived from early OpenID4VP transaction concepts. This approach struggled to accommodate the divergent transport, data encoding, and cryptographic requirements between presentation protocols and credential formats, and did not align with the ISO/IEC 18013-5 Second Edition draft. Refactor transaction data architecture by decoupling lifecycles across all four combinations of (ISO/IEC 18013-5 vs OpenID4VP) x (ISO mdoc vs SD-JWT VC): - ISO/IEC 18013-5 with ISO mdoc: Standardize requests and device-signed responses under the "org.iso.transactiondata" namespace per the Second Edition specification, nesting responses by transaction identifier and validating element-level device key authorizations in the MSO. - OpenID4VP with ISO mdoc: Handle transaction requests via DCQL and produce flat device-signed elements under dedicated OpenID4VP response namespaces. - ISO/IEC 18013-5 with SD-JWT VC: Embed transaction hashes and inputs within the Key Binding JWT (KB-JWT) keyed by document request identifiers. - OpenID4VP with SD-JWT VC: Bind transaction hashes and user inputs directly into top-level claims of the KB-JWT. Refine device key authorizations and query matching: - Treat device key authorizations as issuer-defined security policies in the MSO rather than static schema properties of document types, scoping authorizations to specific elements to prevent unauthorized transactions. - Support claims-less credential queries across DCQL and presentment sources for transaction-only or authentication presentment. - Update the Android Credman matcher engine to parse IEEE 754 floating-point numbers in CBOR, resolve device-signed claims without displaying them in the picker, and match transaction claims for SD-JWT credentials. Modernize consent prompt user interfaces in Compose and SwiftUI: - Move transaction details below requested claims and remove container boxes. - Add contextual approval headings based on the presence of claims. - Use platform icons (Material Outlined in Compose, SF Symbols in SwiftUI). - Display currency amounts with in-place tip breakdowns and replace dropdown menus with adaptive filter chips. Additionally, support client-provided origins in verifier endpoints and add payment transaction demonstration scenarios across sample apps. Fixes #1980. Test: Manually tested on Android and iOS. Test: Ran ./gradlew :multipaz:jvmTest Test: Ran ./gradlew :multipaz-compose:assemble Test: Ran ./gradlew detekt Test: Ran xcodebuild -project samples/SwiftTestApp/SwiftTestApp.xcodeproj -scheme SwiftTestApp -sdk iphonesimulator build Signed-off-by: David Zeuthen --- .../multipaz/compose/presentment/Consent.kt | 263 +++-- .../org/multipaz/presentment/MatcherTest.kt | 412 ++++++++ .../assets/identitycredentialmatcher.wasm | Bin 329320 -> 346442 bytes .../DigitalCredentialsExt.android.kt | 30 +- .../matcher/CredentialDatabase.cpp | 85 +- .../androidMain/matcher/CredentialDatabase.h | 63 +- .../src/androidMain/matcher/cppbor.cpp | 10 +- .../src/androidMain/matcher/cppbor.h | 146 ++- .../src/androidMain/matcher/cppbor_parse.cpp | 73 +- .../src/androidMain/matcher/dcql.cpp | 9 +- .../presentment/DocumentStoreTestHarness.kt | 10 +- .../knowntypes/PaymentTransaction.kt | 144 ++- .../knowntypes/PaymentTransactionTest.kt | 79 ++ ...edentialFactoryDigitalPaymentCredential.kt | 11 +- .../credential/CredentialFactoryMdl.kt | 8 +- .../credential/CredentialFactoryMdocPid.kt | 8 +- .../CredentialFactoryUtopiaLoyalty.kt | 6 +- .../src/iosMain/swift/Consent.swift | 217 +++- .../knowntypes/DigitalPaymentCredential.kt | 6 + .../knowntypes/DocumentTypeRepositoryExt.kt | 2 +- .../utopia/knowntypes/PingTransaction.kt | 143 +-- .../verifier/request/verifyCredentials.kt | 5 +- .../resources/www/verify_credentials.js | 3 + .../documenttype/CannedTransactionData.kt | 4 +- .../org/multipaz/documenttype/DocumentType.kt | 9 +- .../documenttype/DocumentTypeRepository.kt | 3 +- .../multipaz/documenttype/MdocDocumentType.kt | 5 +- .../multipaz/documenttype/TransactionType.kt | 293 ++++-- .../documenttype/TransactionUserInput.kt | 10 +- .../multipaz/mdoc/request/DeviceRequest.kt | 106 +- .../org/multipaz/mdoc/request/DocRequest.kt | 10 +- .../multipaz/mdoc/request/DocRequestInfo.kt | 22 +- .../multipaz/mdoc/response/DeviceResponse.kt | 7 +- .../multipaz/mdoc/response/MdocDocument.kt | 23 +- .../kotlin/org/multipaz/openid/OpenID4VP.kt | 51 +- .../org/multipaz/openid/dcql/DcqlQuery.kt | 73 +- .../{MdocResponse.kt => Iso18013Response.kt} | 4 +- .../multipaz/presentment/PresentmentSource.kt | 2 + .../presentment/SimplePresentmentSource.kt | 73 +- .../multipaz/presentment/TransactionData.kt | 95 +- .../multipaz/presentment/mdocPresentment.kt | 63 +- .../kotlin/org/multipaz/sdjwt/SdJwtKb.kt | 61 +- .../multipaz/verification/VerificationUtil.kt | 31 +- .../presentment/CredentialQueryResultTest.kt | 278 +++++ .../DigitalCredentialsPresentmentTest.kt | 995 ++++++++++++++++-- .../verification/VerificationSessionTest.kt | 21 +- .../payment_card_art.imageset/Contents.json | 21 + .../payment_card_art.png | Bin 0 -> 24627 bytes .../SwiftTestApp/ConsentPromptScreen.swift | 71 +- .../multipaz/testapp/TestAppSettingsModel.kt | 2 + .../org/multipaz/testapp/TestAppUtils.kt | 51 +- .../testapp/ui/ConsentPromptScreen.kt | 43 +- .../testapp/ui/CredentialViewerScreen.kt | 42 + .../multipaz/testapp/ui/DcRequestScreen.kt | 18 +- 54 files changed, 3504 insertions(+), 716 deletions(-) create mode 100644 multipaz-doctypes/src/commonTest/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransactionTest.kt rename multipaz/src/commonMain/kotlin/org/multipaz/presentment/{MdocResponse.kt => Iso18013Response.kt} (93%) create mode 100644 samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/Contents.json create mode 100644 samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/payment_card_art.png diff --git a/multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/presentment/Consent.kt b/multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/presentment/Consent.kt index f5d767c5e3..c70cb94f68 100644 --- a/multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/presentment/Consent.kt +++ b/multipaz-compose/src/commonMain/kotlin/org/multipaz/compose/presentment/Consent.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -35,19 +37,17 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Payment +import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -140,7 +140,10 @@ import org.multipaz.trustmanagement.TrustMetadata import org.multipaz.util.Logger import org.multipaz.util.toBase64Url import org.multipaz.utopia.knowntypes.PingTransaction +import kotlin.math.abs +import kotlin.math.ceil import kotlin.math.min +import kotlin.math.round private val PAGER_INDICATOR_HEIGHT = 30.dp private val PAGER_INDICATOR_PADDING = 8.dp @@ -597,11 +600,13 @@ private fun UseCaseViewer( if (!isSelected) { CredentialViewerNotSelected( typeDisplayName = credential.match.credential.document.typeDisplayName - ?: when (credential.match.source) { + ?: when (val source = credential.match.source) { is CredentialMatchSourceIso18013 -> - (credential.match.source as CredentialMatchSourceIso18013).docRequest.docType + source.docRequest.docType is CredentialMatchSourceOpenID4VP -> - (credential.match.source as CredentialMatchSourceOpenID4VP).credentialQuery.vctValues!!.first() + source.credentialQuery.mdocDocType + ?: source.credentialQuery.vctValues?.firstOrNull() + ?: source.credentialQuery.id }, showChevron = true, onChevronClicked = { onNavigateToPickSolution() } @@ -644,34 +649,9 @@ private fun UseCaseViewer( encryptionTargetTrustMetadata = credential.encryptionTargetTrustMetadata ) - if (credential.match.transactionData.isNotEmpty()) { - Column( - modifier = Modifier - .padding(8.dp) - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(8.dp) - ) - .padding(8.dp) - .fillMaxWidth() - ) { - for (data in credential.match.transactionData) { - DisplayTransactionData( - transactionData = data, - userInput = transactionUserInput[data.type.identifier], - onUserInputChanged = { userInput -> - onTransactionUserInputChanged.invoke( - data.type.identifier, - userInput - ) - } - ) - } - } - } - - if (storedClaims.isEmpty()) { + if (storedClaims.isEmpty() && notStoredClaims.isEmpty()) { + // No claims to display + } else if (storedClaims.isEmpty()) { SharedStoredText(text = sharedWithText) ClaimsGridView(claims = notStoredClaims, useColumns = true) } else if (notStoredClaims.isEmpty()) { @@ -683,6 +663,23 @@ private fun UseCaseViewer( SharedStoredText(text = sharedWithAndStoredByText) ClaimsGridView(claims = storedClaims, useColumns = true) } + + if (credential.match.transactionData.isNotEmpty()) { + val hasClaims = storedClaims.isNotEmpty() || notStoredClaims.isNotEmpty() + for (data in credential.match.transactionData) { + DisplayTransactionData( + transactionData = data, + hasClaims = hasClaims, + userInput = transactionUserInput[data.type.identifier], + onUserInputChanged = { userInput -> + onTransactionUserInputChanged.invoke( + data.type.identifier, + userInput + ) + } + ) + } + } } } } @@ -695,87 +692,175 @@ private fun UseCaseViewer( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun DisplayTransactionData( transactionData: TransactionData<*>, + hasClaims: Boolean, userInput: TransactionUserInput?, onUserInputChanged: (userInput: TransactionUserInput) -> Unit ) { when (val type = transactionData.type) { PingTransaction -> { val payload = transactionData.payload as PingTransaction.Payload - Text("Test \"ping\" transaction") - payload.string?.let { Text("String value: '$it'") } - payload.blob?.let { Text("Blob value: '${it.toByteArray().toBase64Url()}") } + val headerText = if (hasClaims) { + "This test \"ping\" transaction will also be approved:" + } else { + "This test \"ping\" transaction will be approved:" + } + SharedStoredText(text = headerText) + payload.string?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "String: $it", + fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.bodySmall + ) + } + } + payload.blob?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Blob: ${it.toByteArray().toBase64Url()}", + fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.bodySmall + ) + } + } } PaymentTransaction -> { val payload = transactionData.payload as PaymentTransaction.Payload val tipPercent = (userInput as? PaymentTransaction.UserInput)?.tipPercent ?: 0.0 - Text("Payment transaction") - Text("Amount: ${payload.amount} ${payload.currency}") - if (payload.tipRequested == true) { - Row { - // 2. Track the expanded state and the currently selected item - var isExpanded by remember { mutableStateOf(false) } - var selectedOption by remember { mutableStateOf( - if (tipPercent == 0.0) { - tipOptions.first() - } else { - "$tipPercent%" - } - ) } - ExposedDropdownMenuBox( - expanded = isExpanded, - onExpandedChange = { isExpanded = !isExpanded }, - ) { - OutlinedTextField( - value = selectedOption, - onValueChange = {}, - readOnly = true, // Prevents keyboard from appearing - label = { Text("Add tip") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) }, - colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), - modifier = Modifier - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) - .fillMaxWidth() - ) + val headerText = if (hasClaims) { + "This payment will also be approved:" + } else { + "This payment will be approved:" + } + SharedStoredText(text = headerText) - ExposedDropdownMenu( - expanded = isExpanded, - onDismissRequest = { isExpanded = false } - ) { - tipOptions.forEach { option -> - DropdownMenuItem( - text = { Text(text = option) }, - onClick = { - selectedOption = option // Update selection truth - val percent = if (option.endsWith("%")) { - option.take(option.lastIndex).toDouble() - } else { - 0.0 - } - onUserInputChanged(PaymentTransaction.UserInput(percent)) - isExpanded = false // Close menu - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + if (payload.payee.name.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Storefront, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Payee: ${payload.payee.name}", + fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.bodySmall + ) + } + } + + val amountText = if (payload.tipRequested == true && tipPercent > 0.0) { + val tipAmount = ceil(payload.amount * tipPercent) / 100.0 + val totalAmount = payload.amount + tipAmount + "Amount: ${formatAmount(totalAmount)} ${payload.currency} (tip: ${formatAmount(tipAmount)} ${payload.currency})" + } else { + "Amount: ${formatAmount(payload.amount)} ${payload.currency}" + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Payment, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = amountText, + fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.bodySmall + ) + } + + if (payload.tipRequested == true) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + text = "Add tip", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + tipOptions.forEach { (percent, label) -> + val isSelected = (tipPercent == percent) + FilterChip( + selected = isSelected, + onClick = { + onUserInputChanged(PaymentTransaction.UserInput(percent)) + }, + label = { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal ) } - } + ) } } } } else -> { - Text("Unknown transaction type '${type.displayName}'") + val headerText = if (hasClaims) { + "This ${type.displayName} transaction will also be approved:" + } else { + "This ${type.displayName} transaction will be approved:" + } + SharedStoredText(text = headerText) } } } -private val tipOptions = listOf("No tip", "10%", "15%", "20%", "25%") +private val tipOptions = listOf( + 0.0 to "No tip", + 10.0 to "10%", + 15.0 to "15%", + 20.0 to "20%", + 25.0 to "25%" +) + +private fun formatAmount(amount: Double): String { + val roundedCents = round(amount * 100.0).toLong() + val dollars = roundedCents / 100 + val cents = abs(roundedCents % 100) + return "$dollars.${cents.toString().padStart(2, '0')}" +} @Composable private fun calcSharedWithText( diff --git a/multipaz-dcapi/src/androidInstrumentedTest/kotlin/org/multipaz/presentment/MatcherTest.kt b/multipaz-dcapi/src/androidInstrumentedTest/kotlin/org/multipaz/presentment/MatcherTest.kt index 5bbbea7b0e..2317439f3a 100644 --- a/multipaz-dcapi/src/androidInstrumentedTest/kotlin/org/multipaz/presentment/MatcherTest.kt +++ b/multipaz-dcapi/src/androidInstrumentedTest/kotlin/org/multipaz/presentment/MatcherTest.kt @@ -20,6 +20,7 @@ import org.multipaz.cbor.Tstr import org.multipaz.cbor.addCborArray import org.multipaz.cbor.addCborMap import org.multipaz.cbor.buildCborArray +import org.multipaz.cbor.buildCborMap import org.multipaz.cbor.toDataItem import org.multipaz.cbor.toDataItemFullDate import org.multipaz.crypto.Algorithm @@ -33,11 +34,15 @@ import org.multipaz.crypto.X509KeyUsage import org.multipaz.crypto.buildX509Cert import kotlinx.io.bytestring.ByteString import org.multipaz.cbor.DataItem +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.knowntypes.DrivingLicense import org.multipaz.documenttype.knowntypes.EUPersonalID +import org.multipaz.utopia.knowntypes.PingTransaction import org.multipaz.utopia.knowntypes.UtopiaMovieTicket import org.multipaz.mdoc.request.DeviceRequest import org.multipaz.mdoc.request.DocRequestInfo +import org.multipaz.mdoc.request.TransactionsInfo +import org.multipaz.utopia.knowntypes.DigitalPaymentCredential import org.multipaz.mdoc.util.MdocUtil import org.multipaz.digitalcredentials.DigitalCredentials import org.multipaz.digitalcredentials.calculateCredentialDatabase @@ -46,6 +51,7 @@ import org.multipaz.document.setAndroidCredmanExchangeProtocols import org.multipaz.mdoc.request.buildDeviceRequestFromDcql import org.multipaz.openid.OpenID4VP import org.multipaz.util.Logger +import org.multipaz.util.fromHex import org.multipaz.util.toBase64Url import org.multipaz.verification.VerifierIdentity import kotlin.random.Random @@ -3055,6 +3061,48 @@ class MatcherTest { Assert.assertEquals("", matcherResult) } + @Test + fun testMatcher_Iso18013_sdjwt_ping_transaction() = runTest { + val matcherResult = testMatcherIso18013( + harnessInitializer = { harness -> harness.provisionStandardDocuments() }, + deviceRequestBuilder = { harness, sessionTranscript -> + DeviceRequest.Builder(sessionTranscript) + .addDocRequest( + docType = EUPersonalID.EUPID_VCT, + nameSpaces = mapOf( + "_" to mapOf( + "sdjwtkb_family_name" to false, + "sdjwtkb_given_name" to false, + "sdjwtkb_birthdate" to false, + ), + ISO_18013_TRANSACTION_DATA_NAMESPACE to mapOf( + PingTransaction.identifier to true + ) + ), + docRequestInfo = DocRequestInfo( + docFormat = "dc+sd-jwt", + dataElementIdentifierMapping = mapOf( + "sdjwtkb_family_name" to Json.decodeFromString("""["family_name"]"""), + "sdjwtkb_given_name" to Json.decodeFromString("""["given_name"]"""), + "sdjwtkb_birthdate" to Json.decodeFromString("""["birthdate"]"""), + ), + transactionData = TransactionsInfo( + mapOf( + PingTransaction.identifier to buildCborMap { + put("string", "string data") + put("blob", byteArrayOf(1, 2, 3).toDataItem()) + } + ) + ) + ) + ) + .build() + } + ) + Assert.assertTrue(matcherResult.contains("__EU PID__")) + Assert.assertFalse(matcherResult.contains("org.multipaz.transaction.ping")) + } + @Test fun testMatcher_OpenID4VP_mDL_trustedAuthorities_matching() = runTest { val matcherResult = testMatcherDcql( @@ -4076,4 +4124,368 @@ class MatcherTest { Assert.assertTrue(unsignedResult.contains("__mDL-Public__")) Assert.assertFalse(unsignedResult.contains("__mDL-OtherReader__")) } + + @Test + fun testMatcher_Iso18013_keyAuthorizations_namespace_matching() = runTest { + val matcherResult = testMatcherIso18013( + harnessInitializer = { harness -> + harness.provisionMdoc( + displayName = "mDL-with-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ), + keyAuthorizedNamespaces = listOf(ISO_18013_TRANSACTION_DATA_NAMESPACE) + ) + harness.provisionMdoc( + displayName = "mDL-no-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ), + keyAuthorizedNamespaces = emptyList() + ) + }, + deviceRequestBuilder = { _, sessionTranscript -> + DeviceRequest.Builder(sessionTranscript) + .addDocRequest( + docType = DrivingLicense.MDL_DOCTYPE, + nameSpaces = mapOf( + DrivingLicense.MDL_NAMESPACE to mapOf( + "given_name" to false, + ), + ISO_18013_TRANSACTION_DATA_NAMESPACE to mapOf( + "payment_transaction" to true, + ) + ) + ) + .build() + } + ) + // Only mDL-with-auth matches, NOT mDL-no-auth + Assert.assertTrue(matcherResult.contains("__mDL-with-auth__")) + Assert.assertFalse(matcherResult.contains("__mDL-no-auth__")) + // Device-signed / transaction claims must not be displayed in Credman field entries + Assert.assertFalse(matcherResult.contains("payment_transaction")) + Assert.assertTrue(matcherResult.contains("Given names: Erika")) + } + + @Test + fun testMatcher_Iso18013_keyAuthorizations_dataElements_matching() = runTest { + val matcherResult = testMatcherIso18013( + harnessInitializer = { harness -> + harness.provisionMdoc( + displayName = "mDL-payment-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ), + keyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf("payment_transaction") + ) + ) + harness.provisionMdoc( + displayName = "mDL-ping-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ), + keyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf("ping_transaction") + ) + ) + }, + deviceRequestBuilder = { _, sessionTranscript -> + DeviceRequest.Builder(sessionTranscript) + .addDocRequest( + docType = DrivingLicense.MDL_DOCTYPE, + nameSpaces = mapOf( + DrivingLicense.MDL_NAMESPACE to mapOf( + "given_name" to false, + ), + ISO_18013_TRANSACTION_DATA_NAMESPACE to mapOf( + "payment_transaction" to true, + ) + ) + ) + .build() + } + ) + // Only mDL-payment-auth matches, NOT mDL-ping-auth + Assert.assertTrue(matcherResult.contains("__mDL-payment-auth__")) + Assert.assertFalse(matcherResult.contains("__mDL-ping-auth__")) + Assert.assertFalse(matcherResult.contains("payment_transaction")) + } + + @Test + fun testMatcher_Iso18013_keyAuthorizations_only_transaction_data() = runTest { + val matcherResult = testMatcherIso18013( + harnessInitializer = { harness -> + harness.provisionMdoc( + displayName = "PaymentCard", + docType = "org.multipaz.payment.sca.1", + data = mapOf( + "org.multipaz.payment.sca.1" to listOf( + "issuer_name" to Tstr("Utopia Bank"), + ) + ), + keyAuthorizedNamespaces = listOf(ISO_18013_TRANSACTION_DATA_NAMESPACE) + ) + }, + deviceRequestBuilder = { _, sessionTranscript -> + DeviceRequest.Builder(sessionTranscript) + .addDocRequest( + docType = "org.multipaz.payment.sca.1", + nameSpaces = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to mapOf( + "payment_transaction" to true, + ) + ) + ) + .build() + } + ) + // Entry is offered without any field items (since only device-signed data was requested) + Assert.assertTrue(matcherResult.contains("__PaymentCard__")) + Assert.assertFalse(matcherResult.contains("payment_transaction")) + } + + @Test + fun testMatcher_Iso18013_keyAuthorizations_unauthorized_fails() = runTest { + val matcherResult = testMatcherIso18013( + harnessInitializer = { harness -> + harness.provisionMdoc( + displayName = "mDL-no-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ) + ) + }, + deviceRequestBuilder = { _, sessionTranscript -> + DeviceRequest.Builder(sessionTranscript) + .addDocRequest( + docType = DrivingLicense.MDL_DOCTYPE, + nameSpaces = mapOf( + DrivingLicense.MDL_NAMESPACE to mapOf( + "given_name" to false, + ), + ISO_18013_TRANSACTION_DATA_NAMESPACE to mapOf( + "payment_transaction" to true, + ) + ) + ) + .build() + } + ) + // Credential does not match because payment_transaction is unauthorized + Assert.assertEquals("", matcherResult) + } + + @Test + fun testMatcher_Dcql_keyAuthorizations_matching() = runTest { + val dcql = """ + { + "credentials": [ + { + "id": "cred1", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.18013.5.1.mDL" + }, + "claims": [ + {"path": ["org.iso.18013.5.1", "given_name"]}, + {"path": ["org.example.devicesigned", "device_attestation"]} + ] + } + ] + } + """.trimIndent() + + val result = testMatcherDcql( + version = OpenID4VP.Version.DRAFT_29, + signRequest = false, + encryptionKey = null, + harnessInitializer = { harness -> + harness.provisionMdoc( + displayName = "mDL-with-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ), + keyAuthorizedNamespaces = listOf("org.example.devicesigned") + ) + harness.provisionMdoc( + displayName = "mDL-no-auth", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("Erika"), + ) + ), + keyAuthorizedNamespaces = emptyList() + ) + }, + dcql = dcql + ) + Assert.assertTrue(result.contains("__mDL-with-auth__")) + Assert.assertFalse(result.contains("__mDL-no-auth__")) + Assert.assertFalse(result.contains("device_attestation")) + } + + @Test + fun testMatcher_Iso18013_paymentSca_reproduce_user_case() = runTest { + val harness = DocumentStoreTestHarness() + harness.initialize() + harness.documentTypeRepository.addDocumentType(DigitalPaymentCredential.getDocumentType()) + harness.provisionMdoc( + displayName = "Erika's Payment Card Credential", + docType = "org.multipaz.payment.sca.1", + data = mapOf( + "org.multipaz.payment.sca.1" to listOf( + "issuer_name" to Tstr("Utopia Bank"), + "payment_instrument_id" to Tstr("pi-77AABBCC"), + "masked_account_reference" to Tstr("****1234"), + "holder_name" to Tstr("Erika Mustermann"), + "issue_date" to LocalDate.parse("2018-08-09").toDataItemFullDate(), + "expiry_date" to LocalDate.parse("2028-08-09").toDataItemFullDate(), + ) + ), + keyAuthorizedNamespaces = listOf(ISO_18013_TRANSACTION_DATA_NAMESPACE) + ) + + val certPem = """ + -----BEGIN CERTIFICATE----- + MIICNzCCAb6gAwIBAgIRAJOb6d0HEjTDBzbBCXrggQAwCgYIKoZIzj0EAwMwKzEpMCcGA1UEAwwg + T1dGIE11bHRpcGF6IFRlc3RBcHAgUmVhZGVyIFJvb3QwHhcNMjQxMjAxMDAwMDAwWhcNMzQxMjAx + MDAwMDAwWjArMSkwJwYDVQQDDCBPV0YgTXVsdGlwYXogVGVzdEFwcCBSZWFkZXIgQ2VydDBZMBMG + ByqGSM49AgEGCCqGSM49AwEHA0IABO0B+FZdNKysCNn0M4xtFiwVNQpjEZTYTchA/rUJ7IPhN2RQ + fVh/89cL5bPH0MZzMvQrzfqwZSunyz1thGXXE12jgcIwgb8wHwYDVR0jBBgwFoAUq2Ub4FbCkFPx + 3X9s5Ie+aN5gyfUwDgYDVR0PAQH/BAQDAgeAMBUGA1UdJQEB/wQLMAkGByiBjF0FAQYwVgYDVR0f + BE8wTTBLoEmgR4ZFaHR0cHM6Ly9naXRodWIuY29tL29wZW53YWxsZXQtZm91bmRhdGlvbi1sYWJz + L2lkZW50aXR5LWNyZWRlbnRpYWwvY3JsMB0GA1UdDgQWBBRZxxCijOoawu7s4peLtCElWPnNkjAK + BggqhkjOPQQDAwNnADBkAjAPvNx3CiNFWHr3VekrOYlUz4iCzEHcEzpoIegW/ClpSRHhpG5VNiMo + GTlcvbRIRiMCMGFYQ8MNpj5nJMd8OmEys4mxxZMbHK2QdnNPsENkYtHvi6YB5ShPY6gO5ARvEU2B + UA== + -----END CERTIFICATE----- + """.trimIndent() + val cert = org.multipaz.crypto.X509Cert.fromPem(certPem) + + val itemsRequest = buildCborMap { + put("docType", "org.multipaz.payment.sca.1") + put("nameSpaces", buildCborMap { + put("org.multipaz.payment.sca.1", buildCborMap { + put("issuer_name", false) + put("payment_instrument_id", false) + put("masked_account_reference", false) + put("holder_name", false) + put("issue_date", false) + put("expiry_date", false) + }) + put(ISO_18013_TRANSACTION_DATA_NAMESPACE, buildCborMap { + put("urn:eudi:sca:payment:1", true) + }) + }) + put("requestInfo", buildCborMap { + put("transactionData", buildCborMap { + put("urn:eudi:sca:payment:1", buildCborMap { + put("payload", buildCborMap { + put("transactionId", "3AD99006-6E0D-4D07-AE75-5DAEF0FE21D9") + put("currency", "USD") + put("amount", 123.25) + put("payee", buildCborMap { + put("name", "Linux Foundation") + put("id", "01234") + }) + put("tipRequested", true) + }) + }) + }) + }) + } + + val deviceRequestCbor = buildCborMap { + put("version", "1.1") + put("docRequests", buildCborArray { + add(buildCborMap { + put("itemsRequest", org.multipaz.cbor.Tagged(24, org.multipaz.cbor.Bstr(Cbor.encode(itemsRequest)))) + }) + }) + val drInfo = buildCborMap { + put("useCases", buildCborArray { + add(buildCborMap { + put("mandatory", true) + put("documentSets", buildCborArray { + add(buildCborArray { + add(0) + }) + }) + }) + }) + } + put("deviceRequestInfo", org.multipaz.cbor.Tagged(24, org.multipaz.cbor.Bstr(Cbor.encode(drInfo)))) + put("readerAuthAll", buildCborArray { + add(buildCborArray { + add(org.multipaz.cbor.Bstr(Cbor.encode(buildCborMap { + put(1, -7) + }))) + add(buildCborMap { + put(33, cert.encoded.toByteArray()) + }) + add(Simple.NULL) + add("014943a50387c150da7de3b517d8800efcf52f62b0e81cdc333e4a2d971a9e4e04c82c6ac214189586d6689b8e6028ff7a6c5dff6d3fbcb7eaec727f62bf89f3".fromHex()) + }) + }) + } + + val deviceRequest = DeviceRequest.fromDataItem(deviceRequestCbor) + val base64DeviceRequest = Cbor.encode(deviceRequest.toDataItem()).toBase64Url() + + val encryptionKey = Crypto.createEcPrivateKey(EcCurve.P256) + val nonce = Random.nextBytes(16).toBase64Url() + val encryptionInfo = buildCborArray { + add("dcapi") + addCborMap { + put("nonce", nonce.toByteArray()) + put("recipientPublicKey", encryptionKey.toCoseKey().toDataItem()) + } + } + val base64EncryptionInfo = Cbor.encode(encryptionInfo).toBase64Url() + + val credentialDatabase = calculateCredentialDatabase( + appName = "Test App", + documentStore = harness.documentStore, + documentTypeRepository = harness.documentTypeRepository, + selectedProtocols = DigitalCredentials.getDefault().supportedProtocols, + ) + + var result = runMatcher( + request = buildJsonObject { + putJsonArray("requests") { + addJsonObject { + put("protocol", "org-iso-mdoc") + putJsonObject("data") { + put("deviceRequest", base64DeviceRequest) + put("encryptionInfo", base64EncryptionInfo) + } + } + } + }.toString().encodeToByteArray(), + credentialDatabase = Cbor.encode(credentialDatabase) + ) + println("Matcher result: '$result'") + Assert.assertTrue("Expected match but got: '$result'", result.isNotEmpty()) + } } \ No newline at end of file diff --git a/multipaz-dcapi/src/androidMain/assets/identitycredentialmatcher.wasm b/multipaz-dcapi/src/androidMain/assets/identitycredentialmatcher.wasm index 887bacf8af5ea256d799eac5541a63319eb4141c..1b1898ec2302c32c20c751cf103e14c253baf951 100755 GIT binary patch delta 57132 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 gHQRZ{h-BEa&Vni_@% diff --git a/multipaz-dcapi/src/androidMain/kotlin/org/multipaz/digitalcredentials/DigitalCredentialsExt.android.kt b/multipaz-dcapi/src/androidMain/kotlin/org/multipaz/digitalcredentials/DigitalCredentialsExt.android.kt index 105df0b677..8762a68e44 100644 --- a/multipaz-dcapi/src/androidMain/kotlin/org/multipaz/digitalcredentials/DigitalCredentialsExt.android.kt +++ b/multipaz-dcapi/src/androidMain/kotlin/org/multipaz/digitalcredentials/DigitalCredentialsExt.android.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import org.multipaz.cbor.Cbor import org.multipaz.cbor.DataItem +import org.multipaz.cbor.DiagnosticOption import org.multipaz.cbor.buildCborMap import org.multipaz.cbor.putCborArray import org.multipaz.cbor.putCborMap @@ -107,14 +108,6 @@ private suspend fun updateCredmanUnlocked( documentTypeRepository = documentTypeRepository, selectedProtocols = selectedProtocols ) - /* - Logger.i(TAG, "credentialDatabase: " + - Cbor.toDiagnostics( - item = credentialDatabase, - options = setOf(DiagnosticOption.EMBEDDED_CBOR, DiagnosticOption.PRETTY_PRINT, DiagnosticOption.BSTR_PRINT_LENGTH) - ) - ) - */ val credentialDatabaseCbor = Cbor.encode(credentialDatabase) @@ -145,6 +138,7 @@ private suspend fun updateCredmanUnlocked( set(CREDMAN_DB_SHA256_KEY, credDbSha256) } } + Logger.dCbor(TAG, "credentialDatabase", credentialDatabase) val documents = documentStore.listDocuments(sort = true) for (document in documents) { @@ -262,6 +256,26 @@ private suspend fun exportMdocCredential( document.readerIdentifiers.forEach { add(it.toByteArray()) } } } + if (credential.mso.deviceKeyAuthorizedNamespaces.isNotEmpty() || + credential.mso.deviceKeyAuthorizedDataElements.isNotEmpty() + ) { + putCborMap("keyAuthorizations") { + if (credential.mso.deviceKeyAuthorizedNamespaces.isNotEmpty()) { + putCborArray("nameSpaces") { + credential.mso.deviceKeyAuthorizedNamespaces.forEach { add(it) } + } + } + if (credential.mso.deviceKeyAuthorizedDataElements.isNotEmpty()) { + putCborMap("dataElements") { + credential.mso.deviceKeyAuthorizedDataElements.forEach { (namespace, dataElementList) -> + putCborArray(namespace) { + dataElementList.forEach { add(it) } + } + } + } + } + } + } putCborMap("namespaces") { for ((namespace, claimsInNamespace) in claims.organizeByNamespace()) { putCborMap(namespace) { diff --git a/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.cpp b/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.cpp index 23a567d8e5..7d2d3e724d 100644 --- a/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.cpp +++ b/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.cpp @@ -38,6 +38,8 @@ CredentialDatabase::CredentialDatabase(const uint8_t* encodedDatabase, size_t en std::vector docProtocols = topProtocols; std::vector> issuerIdentifiers; std::vector> readerIdentifiers; + std::vector keyAuthorizedNamespaces; + std::map> keyAuthorizedDataElements; std::map resultingClaims = std::map(); auto& docProtocolsPtr = cred->get("protocols"); @@ -77,6 +79,35 @@ CredentialDatabase::CredentialDatabase(const uint8_t* encodedDatabase, size_t en } } + const auto& keyAuthPtr = mdoc->get("keyAuthorizations"); + if (keyAuthPtr != nullptr && keyAuthPtr->asMap() != nullptr) { + auto keyAuthMap = keyAuthPtr->asMap(); + const auto& nsArrPtr = keyAuthMap->get("nameSpaces"); + if (nsArrPtr != nullptr && nsArrPtr->asArray() != nullptr) { + auto arr = nsArrPtr->asArray(); + for (auto it = arr->begin(); it != arr->end(); ++it) { + if ((*it)->asTstr() != nullptr) { + keyAuthorizedNamespaces.push_back((*it)->asTstr()->value()); + } + } + } + const auto& deMapPtr = keyAuthMap->get("dataElements"); + if (deMapPtr != nullptr && deMapPtr->asMap() != nullptr) { + auto deMap = deMapPtr->asMap(); + for (auto it = deMap->begin(); it != deMap->end(); ++it) { + std::string nsName = it->first->asTstr()->value(); + auto deList = it->second->asArray(); + if (deList != nullptr) { + for (auto deIt = deList->begin(); deIt != deList->end(); ++deIt) { + if ((*deIt)->asTstr() != nullptr) { + keyAuthorizedDataElements[nsName].push_back((*deIt)->asTstr()->value()); + } + } + } + } + } + } + auto namespaces = mdoc->get("namespaces")->asMap(); for (auto j = namespaces->begin(); j != namespaces->end(); ++j) { auto namespaceName = j->first->asTstr()->value(); @@ -142,6 +173,8 @@ CredentialDatabase::CredentialDatabase(const uint8_t* encodedDatabase, size_t en docProtocols, issuerIdentifiers, readerIdentifiers, + keyAuthorizedNamespaces, + keyAuthorizedDataElements, resultingClaims ) ); @@ -160,17 +193,52 @@ bool Credential::supportsProtocol(const std::string& protocol) { Claim* Credential::findMatchingClaim(const DcqlRequestedClaim& requestedClaim) { auto joinedPath = requestedClaim.joinPath(); auto ret = claims.find(joinedPath); - if (ret == claims.end()) { - return nullptr; + if (ret != claims.end()) { + // Perform value matching, if requested + if (!requestedClaim.values.empty()) { + const std::vector& values = requestedClaim.values; + if (std::find(values.begin(), values.end(), ret->second.matchValue) == values.end()) { + return nullptr; + } + } + return &(ret->second); } - // Perform value matching, if requested + if (!requestedClaim.values.empty()) { - const std::vector& values = requestedClaim.values; - if (std::find(values.begin(), values.end(), ret->second.matchValue) == values.end()) { - return nullptr; + return nullptr; + } + + // Check KeyAuthorizations for device-signed data elements + if (requestedClaim.path.size() == 2) { + const std::string& ns = requestedClaim.path[0]; + const std::string& elem = requestedClaim.path[1]; + bool authorized = false; + if (!vcVct.empty() && ns == "org.iso.transactiondata") { + authorized = true; + } else if (std::find(keyAuthorizedNamespaces.begin(), keyAuthorizedNamespaces.end(), ns) != keyAuthorizedNamespaces.end()) { + authorized = true; + } else { + auto it = keyAuthorizedDataElements.find(ns); + if (it != keyAuthorizedDataElements.end()) { + if (std::find(it->second.begin(), it->second.end(), elem) != it->second.end()) { + authorized = true; + } + } + } + if (authorized) { + auto dynIt = dynamicDeviceClaims.find(joinedPath); + if (dynIt == dynamicDeviceClaims.end()) { + auto [newIt, _] = dynamicDeviceClaims.emplace( + joinedPath, + Claim(joinedPath, "", "", "", /* isDeviceSigned = */ true) + ); + return &(newIt->second); + } + return &(dynIt->second); } } - return &(ret->second); + + return nullptr; } void Combination::addToCredmanPicker(const Request& request) const { @@ -225,6 +293,9 @@ void Combination::addToCredmanPicker(const Request& request) const { } for (const auto &claim: match.claims) { + if (claim->isDeviceSigned) { + continue; + } if (credmanRuntimeVersion >= 2) { ::AddFieldToEntrySet(entryId, strdup(claim->displayName.c_str()), diff --git a/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.h b/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.h index 0f7a83bc12..91e9c35621 100644 --- a/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.h +++ b/multipaz-dcapi/src/androidMain/matcher/CredentialDatabase.h @@ -9,10 +9,26 @@ //#include "Request.h" struct Request; -struct Claim; - struct DcqlRequestedClaim; +struct Claim { + Claim() = default; + Claim(std::string claimName_, std::string displayName_, std::string value_, std::string matchValue_, bool isDeviceSigned_ = false) + : claimName(std::move(claimName_)), + displayName(std::move(displayName_)), + value(std::move(value_)), + matchValue(std::move(matchValue_)), + isDeviceSigned(isDeviceSigned_) {} + ~Claim() {} + // For Json-based credentials the claimName is the concatenation of all paths, using "." and for + // Mdoc-based credentials it's namespaceName.dataElementName + std::string claimName; + std::string displayName; + std::string value; + std::string matchValue; + bool isDeviceSigned = false; +}; + struct Credential { std::string title; std::string subtitle; @@ -35,9 +51,42 @@ struct Credential { // Reader identifiers (AuthorityKeyIdentifiers) std::vector> readerIdentifiers; + // Key authorizations (for mdoc device-signed data elements) + std::vector keyAuthorizedNamespaces; + std::map> keyAuthorizedDataElements; + // Maps from claimName to Claim. std::map claims; + // Claims dynamically created for authorized device-signed data elements + mutable std::map dynamicDeviceClaims; + + Credential( + std::string title_, + std::string subtitle_, + std::vector bitmap_, + std::string documentId_, + std::string mdocDocType_, + std::string vcVct_, + std::vector protocols_, + std::vector> issuerIdentifiers_, + std::vector> readerIdentifiers_, + std::vector keyAuthorizedNamespaces_, + std::map> keyAuthorizedDataElements_, + std::map claims_ + ) : title(std::move(title_)), + subtitle(std::move(subtitle_)), + bitmap(std::move(bitmap_)), + documentId(std::move(documentId_)), + mdocDocType(std::move(mdocDocType_)), + vcVct(std::move(vcVct_)), + protocols(std::move(protocols_)), + issuerIdentifiers(std::move(issuerIdentifiers_)), + readerIdentifiers(std::move(readerIdentifiers_)), + keyAuthorizedNamespaces(std::move(keyAuthorizedNamespaces_)), + keyAuthorizedDataElements(std::move(keyAuthorizedDataElements_)), + claims(std::move(claims_)) {} + Claim* findMatchingClaim(const DcqlRequestedClaim& claim); bool supportsProtocol(const std::string& protocol); @@ -47,16 +96,6 @@ struct Credential { void addCredentialToPicker(const Request& request); }; -struct Claim { - ~Claim() {} - // For Json-based credentials the claimName is the concatenation of all paths, using "." and for - // Mdoc-based credentials it's namespaceName.dataElementName - std::string claimName; - std::string displayName; - std::string value; - std::string matchValue; -}; - struct CredentialDatabase { CredentialDatabase(const uint8_t* encodedDatabase, size_t encodedDatabaseLength); //std::vector protocols; diff --git a/multipaz-dcapi/src/androidMain/matcher/cppbor.cpp b/multipaz-dcapi/src/androidMain/matcher/cppbor.cpp index b1cf48db12..66a097f35b 100644 --- a/multipaz-dcapi/src/androidMain/matcher/cppbor.cpp +++ b/multipaz-dcapi/src/androidMain/matcher/cppbor.cpp @@ -235,12 +235,15 @@ bool prettyPrintInternal(const Item* item, string& out, size_t indent, size_t ma case SIMPLE: const Bool* asBool = item->asSimple()->asBool(); const Null* asNull = item->asSimple()->asNull(); + const Double* asDouble = item->asSimple()->asDouble(); if (asBool != nullptr) { out.append(asBool->value() ? "true" : "false"); } else if (asNull != nullptr) { out.append("null"); + } else if (asDouble != nullptr) { + out.append(std::to_string(asDouble->value())); } else { - return false; + out.append("simple"); } break; } @@ -364,7 +367,12 @@ bool Simple::operator==(const Simple& other) const& { case BOOLEAN: return *asBool() == *(other.asBool()); case NULL_T: + case UNDEFINED_T: return true; + case DOUBLE_T: + return asDouble()->value() == other.asDouble()->value(); + case SIMPLE_VALUE_T: + return asSimpleValue()->value() == other.asSimpleValue()->value(); default: CHECK(false); // Impossible to get here. return false; diff --git a/multipaz-dcapi/src/androidMain/matcher/cppbor.h b/multipaz-dcapi/src/androidMain/matcher/cppbor.h index 0589e0c784..e813a4bba5 100644 --- a/multipaz-dcapi/src/androidMain/matcher/cppbor.h +++ b/multipaz-dcapi/src/androidMain/matcher/cppbor.h @@ -18,9 +18,12 @@ #include #include +#include #include +#include #include #include +#include #include #include #include @@ -45,7 +48,7 @@ namespace cppbor { enum MajorType : uint8_t { - UINT = 0 << 5, + UINT = 0, NINT = 1 << 5, BSTR = 2 << 5, TSTR = 3 << 5, @@ -57,7 +60,10 @@ enum MajorType : uint8_t { enum SimpleType { BOOLEAN, - NULL_T, // Only two supported, as yet. + NULL_T, + DOUBLE_T, + UNDEFINED_T, + SIMPLE_VALUE_T, }; enum SpecialAddlInfoValues : uint8_t { @@ -82,6 +88,9 @@ class Bool; class Array; class Map; class Null; +class Double; +class Undefined; +class SimpleValue; class SemanticTag; class EncodedItem; class ViewTstr; @@ -149,6 +158,12 @@ class Item { const Bool* asBool() const { return const_cast(this)->asBool(); } virtual Null* asNull() { return nullptr; } const Null* asNull() const { return const_cast(this)->asNull(); } + virtual Double* asDouble() { return nullptr; } + const Double* asDouble() const { return const_cast(this)->asDouble(); } + virtual Undefined* asUndefined() { return nullptr; } + const Undefined* asUndefined() const { return const_cast(this)->asUndefined(); } + virtual SimpleValue* asSimpleValue() { return nullptr; } + const SimpleValue* asSimpleValue() const { return const_cast(this)->asSimpleValue(); } virtual Map* asMap() { return nullptr; } const Map* asMap() const { return const_cast(this)->asMap(); } @@ -834,6 +849,16 @@ class SemanticTag : public Item { Bstr* asBstr() override { return mTaggedItem->asBstr(); } using Item::asSimple; Simple* asSimple() override { return mTaggedItem->asSimple(); } + using Item::asBool; + Bool* asBool() override { return mTaggedItem->asBool(); } + using Item::asNull; + Null* asNull() override { return mTaggedItem->asNull(); } + using Item::asDouble; + Double* asDouble() override { return mTaggedItem->asDouble(); } + using Item::asUndefined; + Undefined* asUndefined() override { return mTaggedItem->asUndefined(); } + using Item::asSimpleValue; + SimpleValue* asSimpleValue() override { return mTaggedItem->asSimpleValue(); } using Item::asMap; Map* asMap() override { return mTaggedItem->asMap(); } using Item::asArray; @@ -929,6 +954,123 @@ class Null : public Simple { std::unique_ptr clone() const override { return std::make_unique(); } }; +/** + * Double is a concrete type that implements CBOR major type 7 floating-point values. + */ +class Double : public Simple { + public: + static constexpr SimpleType kSimpleType = DOUBLE_T; + + explicit Double(double v) : mValue(v) {} + + bool operator==(const Double& other) const& { return mValue == other.mValue; } + + SimpleType simpleType() const override { return kSimpleType; } + Double* asDouble() override { return this; } + + size_t encodedSize() const override { return 9; } + + using Item::encode; + uint8_t* encode(uint8_t* pos, const uint8_t* end) const override { + if (end - pos < 9) return nullptr; + *pos++ = (7 << 5) | EIGHT_BYTE_LENGTH; + uint64_t bits; + memcpy(&bits, &mValue, sizeof(double)); + for (int i = 7; i >= 0; --i) { + *pos++ = (bits >> (i * 8)) & 0xFF; + } + return pos; + } + void encode(EncodeCallback encodeCallback) const override { + encodeCallback((7 << 5) | EIGHT_BYTE_LENGTH); + uint64_t bits; + memcpy(&bits, &mValue, sizeof(double)); + for (int i = 7; i >= 0; --i) { + encodeCallback((bits >> (i * 8)) & 0xFF); + } + } + + double value() const { return mValue; } + + std::unique_ptr clone() const override { return std::make_unique(mValue); } + + private: + double mValue; +}; + +/** + * Undefined is a concrete type that implements CBOR major type 7, item value 23. + */ +class Undefined : public Simple { + public: + static constexpr SimpleType kSimpleType = UNDEFINED_T; + + explicit Undefined() {} + + SimpleType simpleType() const override { return kSimpleType; } + Undefined* asUndefined() override { return this; } + + size_t encodedSize() const override { return 1; } + + using Item::encode; + uint8_t* encode(uint8_t* pos, const uint8_t* end) const override { + if (pos == end) return nullptr; + *pos++ = (7 << 5) | 23; + return pos; + } + void encode(EncodeCallback encodeCallback) const override { + encodeCallback((7 << 5) | 23); + } + + std::unique_ptr clone() const override { return std::make_unique(); } +}; + +/** + * SimpleValue is a concrete type that implements unassigned/other CBOR major type 7 simple values. + */ +class SimpleValue : public Simple { + public: + static constexpr SimpleType kSimpleType = SIMPLE_VALUE_T; + + explicit SimpleValue(uint32_t val) : mValue(val) {} + + bool operator==(const SimpleValue& other) const& { return mValue == other.mValue; } + + SimpleType simpleType() const override { return kSimpleType; } + SimpleValue* asSimpleValue() override { return this; } + + uint32_t value() const { return mValue; } + + size_t encodedSize() const override { return mValue < 24 ? 1 : 2; } + + using Item::encode; + uint8_t* encode(uint8_t* pos, const uint8_t* end) const override { + if (mValue < 24) { + if (pos == end) return nullptr; + *pos++ = (7 << 5) | mValue; + return pos; + } else { + if (end - pos < 2) return nullptr; + *pos++ = (7 << 5) | ONE_BYTE_LENGTH; + *pos++ = mValue & 0xFF; + return pos; + } + } + void encode(EncodeCallback encodeCallback) const override { + if (mValue < 24) { + encodeCallback((7 << 5) | mValue); + } else { + encodeCallback((7 << 5) | ONE_BYTE_LENGTH); + encodeCallback(mValue & 0xFF); + } + } + + std::unique_ptr clone() const override { return std::make_unique(mValue); } + + private: + uint32_t mValue; +}; + /** * Returns pretty-printed CBOR for |item| * diff --git a/multipaz-dcapi/src/androidMain/matcher/cppbor_parse.cpp b/multipaz-dcapi/src/androidMain/matcher/cppbor_parse.cpp index 2c6836042f..9d6b19f673 100644 --- a/multipaz-dcapi/src/androidMain/matcher/cppbor_parse.cpp +++ b/multipaz-dcapi/src/androidMain/matcher/cppbor_parse.cpp @@ -93,6 +93,54 @@ std::tuple handleNull(const uint8_t* hdrBegin, con parseClient->item(item, hdrBegin, hdrEnd /* valueBegin */, hdrEnd /* itemEnd */)}; } +static double halfToDouble(uint16_t half) { + uint32_t sign = (half >> 15) & 0x0001; + uint32_t exp = (half >> 10) & 0x001F; + uint32_t mant = half & 0x03FF; + + if (exp == 0) { + if (mant == 0) { + return sign ? -0.0 : 0.0; + } else { + double val = ldexp((double)mant, -24); + return sign ? -val : val; + } + } else if (exp == 31) { + if (mant == 0) { + return sign ? -std::numeric_limits::infinity() : std::numeric_limits::infinity(); + } else { + return std::numeric_limits::quiet_NaN(); + } + } + + double val = ldexp((double)(mant | 0x0400), exp - 25); + return sign ? -val : val; +} + +std::tuple handleDouble(double value, const uint8_t* hdrBegin, + const uint8_t* hdrEnd, + ParseClient* parseClient) { + std::unique_ptr item = std::make_unique(value); + return {hdrEnd, + parseClient->item(item, hdrBegin, hdrEnd /* valueBegin */, hdrEnd /* itemEnd */)}; +} + +std::tuple handleUndefined(const uint8_t* hdrBegin, + const uint8_t* hdrEnd, + ParseClient* parseClient) { + std::unique_ptr item = std::make_unique(); + return {hdrEnd, + parseClient->item(item, hdrBegin, hdrEnd /* valueBegin */, hdrEnd /* itemEnd */)}; +} + +std::tuple handleSimpleValue(uint32_t value, const uint8_t* hdrBegin, + const uint8_t* hdrEnd, + ParseClient* parseClient) { + std::unique_ptr item = std::make_unique(value); + return {hdrEnd, + parseClient->item(item, hdrBegin, hdrEnd /* valueBegin */, hdrEnd /* itemEnd */)}; +} + template std::tuple handleString(uint64_t length, const uint8_t* hdrBegin, const uint8_t* valueBegin, const uint8_t* end, @@ -338,15 +386,32 @@ std::tuple parseRecursively(const uint8_t* begin, end, "semantic", emitViews, parseClient, depth); case SIMPLE: - switch (addlData) { + switch (tagInt) { case TRUE: case FALSE: - return handleBool(addlData, begin, pos, parseClient); + return handleBool(tagInt, begin, pos, parseClient); case NULL_V: return handleNull(begin, pos, parseClient); + case 23: + return handleUndefined(begin, pos, parseClient); + case TWO_BYTE_LENGTH: { + double d = halfToDouble(static_cast(addlData)); + return handleDouble(d, begin, pos, parseClient); + } + case FOUR_BYTE_LENGTH: { + uint32_t u32 = static_cast(addlData); + float f; + memcpy(&f, &u32, sizeof(float)); + return handleDouble(static_cast(f), begin, pos, parseClient); + } + case EIGHT_BYTE_LENGTH: { + uint64_t u64 = addlData; + double d; + memcpy(&d, &u64, sizeof(double)); + return handleDouble(d, begin, pos, parseClient); + } default: - parseClient->error(begin, "Unsupported floating-point or simple value."); - return {begin, nullptr}; + return handleSimpleValue(static_cast(addlData), begin, pos, parseClient); } } CHECK(false); // Impossible to get here. diff --git a/multipaz-dcapi/src/androidMain/matcher/dcql.cpp b/multipaz-dcapi/src/androidMain/matcher/dcql.cpp index 61da4ef18f..ff6622d45a 100644 --- a/multipaz-dcapi/src/androidMain/matcher/dcql.cpp +++ b/multipaz-dcapi/src/androidMain/matcher/dcql.cpp @@ -285,15 +285,20 @@ std::optional DcqlQuery::execute(CredentialDatabase* credentialDat for (auto& cred : credsSatifyingMeta) { if (query.claimSets.size() == 0) { if (query.lenientClaimMatching) { + bool failedRequiredClaim = false; auto matchingClaimValues = std::vector(); for (auto& claim : query.requestedClaims) { Claim* matchingCredentialClaim = cred->findMatchingClaim(claim); if (matchingCredentialClaim != nullptr) { matchingClaimValues.push_back(matchingCredentialClaim); + } else if (claim.path.size() >= 1 && claim.path[0] == "org.iso.transactiondata") { + // Transaction data claims cannot be omitted even under lenient matching + failedRequiredClaim = true; + break; } } - if (!matchingClaimValues.empty()) { - // At least one claim matched, we have a candidate + if (!failedRequiredClaim && (!matchingClaimValues.empty() || query.requestedClaims.empty())) { + // At least one claim matched, or no claims requested, we have a candidate matches.push_back(DcqlResponseCredentialSetOptionMemberMatch( cred, matchingClaimValues diff --git a/multipaz-dcapi/src/commonTest/kotlin/org/multipaz/presentment/DocumentStoreTestHarness.kt b/multipaz-dcapi/src/commonTest/kotlin/org/multipaz/presentment/DocumentStoreTestHarness.kt index f85d036258..aff6a698c8 100644 --- a/multipaz-dcapi/src/commonTest/kotlin/org/multipaz/presentment/DocumentStoreTestHarness.kt +++ b/multipaz-dcapi/src/commonTest/kotlin/org/multipaz/presentment/DocumentStoreTestHarness.kt @@ -259,6 +259,8 @@ class DocumentStoreTestHarness( data: Map>>, dsKey: AsymmetricKey.X509Certified? = null, readerIdentifiers: List = emptyList(), + keyAuthorizedNamespaces: List = emptyList(), + keyAuthorizedDataElements: Map> = emptyMap(), ): Document { initialize() val effectiveDsKey = dsKey ?: this.dsKey @@ -283,6 +285,8 @@ class DocumentStoreTestHarness( validFrom = validFrom, validUntil = validUntil, dsKey = effectiveDsKey, + keyAuthorizedNamespaces = keyAuthorizedNamespaces, + keyAuthorizedDataElements = keyAuthorizedDataElements, ) return document } @@ -478,7 +482,7 @@ class DocumentStoreTestHarness( signedAt = signedAt, validFrom = validFrom, validUntil = validUntil, - dsKey = dsKey, + dsKey = dsKey ) } @@ -490,6 +494,8 @@ class DocumentStoreTestHarness( validFrom: Instant, validUntil: Instant, dsKey: AsymmetricKey.X509Certified, + keyAuthorizedNamespaces: List = emptyList(), + keyAuthorizedDataElements: Map> = emptyMap(), ) { // Create authentication keys... val mdocCredential = MdocCredential.create( @@ -512,6 +518,8 @@ class DocumentStoreTestHarness( digestAlgorithm = Algorithm.SHA256, valueDigests = issuerNamespaces.getValueDigests(Algorithm.SHA256), deviceKey = mdocCredential.getAttestation().publicKey, + deviceKeyAuthorizedNamespaces = keyAuthorizedNamespaces, + deviceKeyAuthorizedDataElements = keyAuthorizedDataElements, ) val taggedEncodedMso = Cbor.encode( Tagged( diff --git a/multipaz-doctypes/src/commonMain/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransaction.kt b/multipaz-doctypes/src/commonMain/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransaction.kt index e3f08726da..97d737d426 100644 --- a/multipaz-doctypes/src/commonMain/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransaction.kt +++ b/multipaz-doctypes/src/commonMain/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransaction.kt @@ -9,8 +9,11 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.multipaz.cbor.Cbor import org.multipaz.cbor.DataItem -import org.multipaz.cbor.Tagged import org.multipaz.cbor.annotation.CborSerializable import org.multipaz.cbor.toDataItem import org.multipaz.credential.Credential @@ -20,6 +23,8 @@ import org.multipaz.documenttype.TransactionType import org.multipaz.documenttype.TransactionUserInput import org.multipaz.mdoc.credential.MdocCredential import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol +import org.multipaz.sdjwt.credential.KeyBoundSdJwtVcCredential import org.multipaz.util.fromBase64Url import kotlin.math.ceil import kotlin.time.Instant @@ -53,24 +58,6 @@ object PaymentTransaction: TransactionType( val payload: Payload ) - /** - * Represents the wrapper envelope for a CBOR-serialized payment transaction. - * - * This serves as a binary alternative to [JsonData], optimizing performance - * and payload size by mapping algorithm identifiers to numeric values. - * - * @property transactionDataHashesAlg An optional list of cryptographic hash algorithms - * represented as CBOR integer identifiers (`List`). - * @property payload The core [Payload] containing transaction-specific details. - */ - @CborSerializable - data class CborData( - val transactionDataHashesAlg: List?, - val payload: Payload - ) { - companion object - } - /** * The core detail schema of a payment transaction. * @@ -202,7 +189,7 @@ object PaymentTransaction: TransactionType( data class UserInput( val tipPercent: Double ): TransactionUserInput() { - override fun applyCbor( + override fun generateMdocResponseElements( transactionData: TransactionData<*>, credential: Credential ): Map = buildMap { @@ -211,7 +198,7 @@ object PaymentTransaction: TransactionType( put("tipAmount", amount.toDataItem()) } - override fun applyJson( + override fun generateSdJwtResponseClaims( transactionData: TransactionData<*>, credential: Credential ): Map = buildMap { @@ -251,19 +238,13 @@ object PaymentTransaction: TransactionType( TYEA } - override fun serializeCbor( - payload: Payload, - hashAlgorithms: List? - ): DataItem = - Tagged( - tagNumber = Tagged.ENCODED_CBOR, - taggedItem = CborData( - transactionDataHashesAlg = coseHashAlgorithms(hashAlgorithms), - payload = payload - ).toCbor().toDataItem() - ) + override fun serializeIso18013Request(payload: Payload): DataItem = + payload.toDataItem() - override fun serializeJson( + override fun parseIso18013Request(dataItem: DataItem): Payload = + Payload.fromDataItem(dataItem) + + override fun serializeOpenId4VpRequest( payload: Payload, credentialIds: List, hashAlgorithms: List? @@ -275,34 +256,105 @@ object PaymentTransaction: TransactionType( payload = payload )) + override fun parseOpenId4VpRequest(jsonString: String): Payload = + jsonFormat.decodeFromString(jsonString).payload + + override fun parseJson(serialized: ByteString): TransactionData { val jsonString = serialized.decodeToString().fromBase64Url().decodeToString() val data = jsonFormat.decodeFromString(jsonString) return TransactionData( type = this, - serialized = serialized, - hashAlgorithms = parseJoseHashAlgorithms(data.transactionDataHashesAlg), payload = data.payload, + protocol = TransactionProtocol.OPENID4VP, + rawBytes = serialized, + hashAlgorithms = parseJoseHashAlgorithms(data.transactionDataHashesAlg), ) } - override fun parseCbor(serialized: DataItem): TransactionData { - val data = CborData.fromDataItem(serialized.asTaggedEncodedCbor) - return TransactionData( - type = this, - serialized = ByteString(serialized.asTagged.asBstr), - hashAlgorithms = parseCoseHashAlgorithms(data.transactionDataHashesAlg), - payload = data.payload, - ) + + override suspend fun generateMdocResponseElements( + transactionData: TransactionData, + credential: Credential, + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { + putAll(super.generateMdocResponseElements(transactionData, credential, userInput, docRequestId)) + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + put("amount", transactionData.payload.amount.toDataItem()) + put("currency", transactionData.payload.currency.toDataItem()) + } + } + + override suspend fun generateSdJwtResponseClaims( + transactionData: TransactionData, + credential: Credential, + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { + putAll(super.generateSdJwtResponseClaims(transactionData, credential, userInput, docRequestId)) + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + put("amount", JsonPrimitive(transactionData.payload.amount)) + put("currency", JsonPrimitive(transactionData.payload.currency)) + } + } + + override suspend fun verifyMdocResponse( + transactionData: TransactionData, + responseElements: Map + ) { + super.verifyMdocResponse(transactionData, responseElements) + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + val amount = responseElements["amount"]?.asDouble + ?: throw IllegalStateException("Missing 'amount' in transaction response") + if (amount != transactionData.payload.amount) { + throw IllegalStateException( + "Amount mismatch in transaction response: expected ${transactionData.payload.amount}, got $amount" + ) + } + val currency = responseElements["currency"]?.asTstr + ?: throw IllegalStateException("Missing 'currency' in transaction response") + if (currency != transactionData.payload.currency) { + throw IllegalStateException( + "Currency mismatch in transaction response: expected ${transactionData.payload.currency}, got $currency" + ) + } + } + } + + override suspend fun verifySdJwtResponse( + transactionData: TransactionData, + responseClaims: Map + ) { + super.verifySdJwtResponse(transactionData, responseClaims) + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + val amount = responseClaims["amount"]?.jsonPrimitive?.doubleOrNull + ?: throw IllegalStateException("Missing 'amount' in transaction response") + if (amount != transactionData.payload.amount) { + throw IllegalStateException( + "Amount mismatch in transaction response: expected ${transactionData.payload.amount}, got $amount" + ) + } + val currency = responseClaims["currency"]?.jsonPrimitive?.contentOrNull + ?: throw IllegalStateException("Missing 'currency' in transaction response") + if (currency != transactionData.payload.currency) { + throw IllegalStateException( + "Currency mismatch in transaction response: expected ${transactionData.payload.currency}, got $currency" + ) + } + } } override suspend fun isApplicable( transactionData: TransactionData, credential: Credential ): Boolean { - return credential is MdocCredential - && credential.docType == "org.multipaz.payment.sca.1" - && super.isApplicable(transactionData, credential) + val matchesType = when (credential) { + is MdocCredential -> credential.docType == "org.multipaz.payment.sca.1" + is KeyBoundSdJwtVcCredential -> credential.vct == "org.multipaz.payment.sca.1" + else -> false + } + return matchesType && super.isApplicable(transactionData, credential) } /** Sample transaction data for this transaction type */ diff --git a/multipaz-doctypes/src/commonTest/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransactionTest.kt b/multipaz-doctypes/src/commonTest/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransactionTest.kt new file mode 100644 index 0000000000..59a9d466ca --- /dev/null +++ b/multipaz-doctypes/src/commonTest/kotlin/org/multipaz/documenttype/knowntypes/PaymentTransactionTest.kt @@ -0,0 +1,79 @@ +package org.multipaz.documenttype.knowntypes + +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString +import org.multipaz.cbor.Cbor +import org.multipaz.cbor.CborDouble +import org.multipaz.cbor.Tstr +import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class PaymentTransactionTest { + + @Test + fun testVerifyMdocResponseAmountMismatch() = runTest { + val payload = PaymentTransaction.sampleData.payload.copy(amount = 123.25, currency = "USD") + val dataItem = PaymentTransaction.serializeIso18013Request(payload) + val transactionData = TransactionData( + type = PaymentTransaction, + payload = payload, + protocol = TransactionProtocol.ISO_18013_5, + rawBytes = ByteString(Cbor.encode(dataItem)), + ) + + assertFailsWith(IllegalStateException::class) { + PaymentTransaction.verifyMdocResponse( + transactionData = transactionData, + responseElements = mapOf( + "amount" to CborDouble(123.26), + "currency" to Tstr("USD"), + ) + ) + } + } + + @Test + fun testVerifyMdocResponseCurrencyMismatch() = runTest { + val payload = PaymentTransaction.sampleData.payload.copy(amount = 123.25, currency = "USD") + val dataItem = PaymentTransaction.serializeIso18013Request(payload) + val transactionData = TransactionData( + type = PaymentTransaction, + payload = payload, + protocol = TransactionProtocol.ISO_18013_5, + rawBytes = ByteString(Cbor.encode(dataItem)), + ) + + assertFailsWith(IllegalStateException::class) { + PaymentTransaction.verifyMdocResponse( + transactionData = transactionData, + responseElements = mapOf( + "amount" to CborDouble(123.25), + "currency" to Tstr("EUR"), + ) + ) + } + } + + @Test + fun testVerifyMdocResponseMissingAmount() = runTest { + val payload = PaymentTransaction.sampleData.payload.copy(amount = 123.25, currency = "USD") + val dataItem = PaymentTransaction.serializeIso18013Request(payload) + val transactionData = TransactionData( + type = PaymentTransaction, + payload = payload, + protocol = TransactionProtocol.ISO_18013_5, + rawBytes = ByteString(Cbor.encode(dataItem)), + ) + + assertFailsWith(IllegalStateException::class) { + PaymentTransaction.verifyMdocResponse( + transactionData = transactionData, + responseElements = mapOf( + "currency" to Tstr("USD"), + ) + ) + } + } +} diff --git a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryDigitalPaymentCredential.kt b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryDigitalPaymentCredential.kt index 0d1578da95..22ae1cf308 100644 --- a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryDigitalPaymentCredential.kt +++ b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryDigitalPaymentCredential.kt @@ -14,6 +14,7 @@ import org.multipaz.cose.CoseLabel import org.multipaz.cose.CoseNumberLabel import org.multipaz.crypto.Algorithm import org.multipaz.crypto.EcPublicKey +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.knowntypes.PaymentTransaction import org.multipaz.utopia.knowntypes.DigitalPaymentCredential import org.multipaz.mdoc.issuersigned.buildIssuerNamespaces @@ -101,8 +102,14 @@ class CredentialFactoryDigitalPaymentCredential : CredentialFactory { deviceKey = authenticationKey!!, revocationStatus = revocationStatus, deviceKeyAuthorizedNamespaces = listOf( - PaymentTransaction.mdocResponseNamespace, - PingTransaction.mdocResponseNamespace + PaymentTransaction.openId4VpMdocResponseNamespace, + PingTransaction.openId4VpMdocResponseNamespace, + ), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf( + PaymentTransaction.identifier, + PingTransaction.identifier, + ) ) ) val taggedEncodedMso = Cbor.encode( diff --git a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdl.kt b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdl.kt index 37b5edebc4..e096643034 100644 --- a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdl.kt +++ b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdl.kt @@ -36,6 +36,7 @@ import org.multipaz.provisioning.CredentialFormat import org.multipaz.server.common.getBaseUrl import org.multipaz.util.Logger import org.multipaz.util.truncateToWholeSeconds +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.utopia.knowntypes.PingTransaction import kotlin.time.Duration.Companion.days @@ -238,7 +239,12 @@ class CredentialFactoryMdl : CredentialFactory { deviceKey = authenticationKey!!, revocationStatus = revocationStatus, deviceKeyAuthorizedNamespaces = listOf( - PingTransaction.mdocResponseNamespace + PingTransaction.openId4VpMdocResponseNamespace + ), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf( + PingTransaction.identifier + ) ) ) val taggedEncodedMso = Cbor.encode(Tagged( diff --git a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdocPid.kt b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdocPid.kt index 114350f319..fda3ce390d 100644 --- a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdocPid.kt +++ b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryMdocPid.kt @@ -34,6 +34,7 @@ import org.multipaz.rpc.backend.BackendEnvironment import org.multipaz.rpc.backend.Resources import org.multipaz.server.common.getBaseUrl import org.multipaz.util.Logger +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.utopia.knowntypes.PingTransaction import kotlin.collections.component1 import kotlin.collections.component2 @@ -178,7 +179,12 @@ class CredentialFactoryMdocPid : CredentialFactory { deviceKey = authenticationKey!!, revocationStatus = revocationStatus, deviceKeyAuthorizedNamespaces = listOf( - PingTransaction.mdocResponseNamespace + PingTransaction.openId4VpMdocResponseNamespace + ), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf( + PingTransaction.identifier + ) ) ) val taggedEncodedMso = Cbor.encode(Tagged( diff --git a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryUtopiaLoyalty.kt b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryUtopiaLoyalty.kt index 7c4a73530f..0a31d71f20 100644 --- a/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryUtopiaLoyalty.kt +++ b/multipaz-openid4vci/src/main/java/org/multipaz/openid4vci/credential/CredentialFactoryUtopiaLoyalty.kt @@ -25,7 +25,6 @@ import org.multipaz.rpc.backend.BackendEnvironment import org.multipaz.rpc.backend.Resources import org.multipaz.server.common.getBaseUrl import org.multipaz.util.toBase64Url -import org.multipaz.utopia.knowntypes.PingTransaction import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration.Companion.days @@ -139,10 +138,7 @@ class CredentialFactoryUtopiaLoyalty : CredentialFactory { digestAlgorithm = Algorithm.SHA256, valueDigests = issuerNamespaces.getValueDigests(Algorithm.SHA256), deviceKey = authenticationKey!!, - revocationStatus = revocationStatus, - deviceKeyAuthorizedNamespaces = listOf( - PingTransaction.mdocResponseNamespace - ) + revocationStatus = revocationStatus ) val taggedEncodedMso = Cbor.encode(Tagged( Tagged.ENCODED_CBOR, diff --git a/multipaz-swiftui/src/iosMain/swift/Consent.swift b/multipaz-swiftui/src/iosMain/swift/Consent.swift index e074134560..2808ae8028 100644 --- a/multipaz-swiftui/src/iosMain/swift/Consent.swift +++ b/multipaz-swiftui/src/iosMain/swift/Consent.swift @@ -61,10 +61,70 @@ private struct ClaimsSection : View { } } -private let tipOptions = ["No tip", "10%", "15%", "20%", "25%"] +private let tipOptions: [(percent: Double, label: String)] = [ + (0.0, "No tip"), + (10.0, "10%"), + (15.0, "15%"), + (20.0, "20%"), + (25.0, "25%") +] + +private func formatAmount(_ amount: Double) -> String { + let roundedCents = Int64((amount * 100.0).rounded()) + let dollars = roundedCents / 100 + let cents = abs(roundedCents % 100) + return String(format: "%lld.%02lld", dollars, cents) +} + +private struct ChipFlowLayout: Layout { + var spacing: CGFloat = 8 + var lineSpacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var maxRowWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX + size.width > maxWidth && currentX > 0 { + maxRowWidth = max(maxRowWidth, currentX - spacing) + currentX = 0 + currentY += lineHeight + lineSpacing + lineHeight = 0 + } + currentX += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + maxRowWidth = max(maxRowWidth, max(0, currentX - spacing)) + let totalHeight = currentY + lineHeight + return CGSize(width: min(maxWidth, maxRowWidth), height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var currentX: CGFloat = bounds.minX + var currentY: CGFloat = bounds.minY + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX + size.width > bounds.maxX && currentX > bounds.minX { + currentX = bounds.minX + currentY += lineHeight + lineSpacing + lineHeight = 0 + } + subview.place(at: CGPoint(x: currentX, y: currentY), proposal: ProposedViewSize(size)) + currentX += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + } +} private struct DisplayTransactionData: View { let transactionData: TransactionData + let hasClaims: Bool let userInput: TransactionUserInput? let onUserInputChanged: (TransactionUserInput) -> Void @@ -72,39 +132,65 @@ private struct DisplayTransactionData: View { if transactionData.type == PaymentTransaction.shared || transactionData.type.identifier == PaymentTransaction.shared.identifier { if let payload = transactionData.payload as? PaymentTransaction.Payload { let tipPercent = (userInput as? PaymentTransaction.UserInput)?.tipPercent ?? 0.0 + let headerText = hasClaims ? "This payment will also be approved:" : "This payment will be approved:" VStack(alignment: .leading, spacing: 6) { - Text("Payment transaction") - .font(.system(size: 15, weight: .semibold)) - Text("Amount: \(String(format: "%.2f", payload.amount)) \(payload.currency)") - .font(.system(size: 14)) - if payload.tipRequested?.boolValue == true { - HStack { - Text("Add tip:") + Text(headerText) + .font(.system(size: 14, weight: .bold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + if !payload.payee.name.isEmpty { + HStack(spacing: 8) { + Image(systemName: "storefront") + .imageScale(.small) + Text("Payee: \(payload.payee.name)") .font(.system(size: 14)) - Spacer() - Picker("Add tip", selection: Binding( - get: { - if tipPercent == 0.0 { - return "No tip" - } else { - return "\(Int(tipPercent))%" - } - }, - set: { (option: String) in - let percent: Double - if option.hasSuffix("%") { - percent = Double(option.dropLast()) ?? 0.0 - } else { - percent = 0.0 - } - onUserInputChanged(PaymentTransaction.UserInput(tipPercent: percent)) - } - )) { - ForEach(tipOptions, id: \.self) { option in - Text(option).tag(option) + } + } + + let amountText: String = { + if payload.tipRequested?.boolValue == true && tipPercent > 0.0 { + let tipAmount = ceil(payload.amount * tipPercent) / 100.0 + let totalAmount = payload.amount + tipAmount + return "Amount: \(formatAmount(totalAmount)) \(payload.currency) (tip: \(formatAmount(tipAmount)) \(payload.currency))" + } else { + return "Amount: \(formatAmount(payload.amount)) \(payload.currency)" + } + }() + + HStack(spacing: 8) { + Image(systemName: "creditcard") + .imageScale(.small) + Text(amountText) + .font(.system(size: 14)) + } + + if payload.tipRequested?.boolValue == true { + Text("Add tip") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .padding(.top, 4) + + ChipFlowLayout(spacing: 8, lineSpacing: 6) { + ForEach(tipOptions, id: \.percent) { option in + let isSelected = (tipPercent == option.percent) + Button(action: { + onUserInputChanged(PaymentTransaction.UserInput(tipPercent: option.percent)) + }) { + Text(option.label) + .font(.system(size: 13, weight: isSelected ? .bold : .regular)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor.opacity(0.15) : Color(uiColor: .secondarySystemBackground)) + .foregroundColor(isSelected ? Color.accentColor : Color.primary) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) } + .buttonStyle(.plain) } - .pickerStyle(.menu) } } } @@ -112,24 +198,41 @@ private struct DisplayTransactionData: View { } } else if transactionData.type == PingTransaction.shared || transactionData.type.identifier == PingTransaction.shared.identifier { if let payload = transactionData.payload as? PingTransaction.Payload { - VStack(alignment: .leading, spacing: 4) { - Text("Test \"ping\" transaction") - .font(.system(size: 15, weight: .semibold)) + let headerText = hasClaims ? "This test \"ping\" transaction will also be approved:" : "This test \"ping\" transaction will be approved:" + VStack(alignment: .leading, spacing: 6) { + Text(headerText) + .font(.system(size: 14, weight: .bold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) if let str = payload.string { - Text("String value: '\(str)'") - .font(.system(size: 14)) + HStack(spacing: 8) { + Image(systemName: "info.circle") + .imageScale(.small) + Text("String: \(str)") + .font(.system(size: 14)) + } } if let blob = payload.blob { let byteArray = blob.toByteArray(startIndex: 0, endIndex: blob.size) - Text("Blob value: '\(byteArray.toBase64Url())'") - .font(.system(size: 14)) + HStack(spacing: 8) { + Image(systemName: "info.circle") + .imageScale(.small) + Text("Blob: \(byteArray.toBase64Url())") + .font(.system(size: 14)) + } } } .frame(maxWidth: .infinity, alignment: .leading) } } else { - Text("Unknown transaction type '\(transactionData.type.displayName)'") - .font(.system(size: 14)) + let headerText = hasClaims ? "This \(transactionData.type.displayName) transaction will also be approved:" : "This \(transactionData.type.displayName) transaction will be approved:" + VStack(alignment: .leading, spacing: 6) { + Text(headerText) + .font(.system(size: 14, weight: .bold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -200,25 +303,7 @@ private struct RequestedDocumentSection : View { } } - if !transactionData.isEmpty { - VStack(alignment: .leading, spacing: 8) { - ForEach(0..( displayName = "Ping", identifier = "org.multipaz.transaction.ping", - mdocRequestInfoIdentifier = "org.multipaz.transaction.ping.mdoc_identifier", - mdocResponseNamespace = "org.multipaz.transaction.ping.mdoc_response", kbJwtResponseClaimName = "org.multipaz.transaction.ping.response" ) { @Serializable @@ -45,33 +46,22 @@ object PingTransaction: TransactionType( val blob: String? // use base64url-encoded String, could also write custom KSerializer ) - @CborSerializable - data class CborData( - val transactionDataHashesAlg: List?, - val string: String?, - val blob: ByteString? - ) { - companion object - } - data class Payload( val string: String?, val blob: ByteString? ) - override fun serializeCbor( - payload: Payload, - hashAlgorithms: List? - ): DataItem = Tagged( - tagNumber = Tagged.ENCODED_CBOR, - taggedItem = CborData( - transactionDataHashesAlg = coseHashAlgorithms(hashAlgorithms), - string = payload.string, - blob = payload.blob - ).toCbor().toDataItem() + override fun serializeIso18013Request(payload: Payload): DataItem = buildCborMap { + payload.string?.let { put("string", it) } + payload.blob?.let { put("blob", it.toByteArray().toDataItem()) } + } + + override fun parseIso18013Request(dataItem: DataItem): Payload = Payload( + string = dataItem.getOrNull("string")?.asTstr, + blob = dataItem.getOrNull("blob")?.asBstr?.let { ByteString(it) } ) - override fun serializeJson( + override fun serializeOpenId4VpRequest( payload: Payload, credentialIds: List, hashAlgorithms: List? @@ -85,32 +75,29 @@ object PingTransaction: TransactionType( ) ) + override fun parseOpenId4VpRequest(jsonString: String): Payload { + val data = jsonFormat.decodeFromString(jsonString) + return Payload( + string = data.string, + blob = data.blob?.fromBase64Url()?.let { ByteString(it) } + ) + } + override fun parseJson(serialized: ByteString): TransactionData { val jsonString = serialized.decodeToString().fromBase64Url().decodeToString() val data = jsonFormat.decodeFromString(jsonString) return TransactionData( type = this, - serialized = serialized, - hashAlgorithms = parseJoseHashAlgorithms(data.transactionDataHashesAlg), payload = Payload( string = data.string, blob = data.blob?.fromBase64Url()?.let { ByteString(it) } ), + protocol = TransactionProtocol.OPENID4VP, + rawBytes = serialized, + hashAlgorithms = parseJoseHashAlgorithms(data.transactionDataHashesAlg), ) } - override fun parseCbor(serialized: DataItem): TransactionData { - val data = CborData.fromDataItem(serialized.asTaggedEncodedCbor) - return TransactionData( - type = this, - serialized = ByteString(serialized.asTagged.asBstr), - hashAlgorithms = parseCoseHashAlgorithms(data.transactionDataHashesAlg), - payload = Payload( - string = data.string, - blob = data.blob - ) - ) - } override suspend fun isApplicable( transactionData: TransactionData, @@ -122,38 +109,74 @@ object PingTransaction: TransactionType( && super.isApplicable(transactionData, credential) } - override suspend fun applyJson( + override suspend fun generateMdocResponseElements( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): JsonElement = buildJsonObject { - userInput?.applyJson(transactionData, credential)?.let { claims -> - buildJsonObject { - for ((name, value) in claims) { - put(name, value) - } - } - } + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { + putAll(super.generateMdocResponseElements(transactionData, credential, userInput, docRequestId)) transactionData.payload.string?.let { - put("string", it) + put("string", it.toDataItem()) } transactionData.payload.blob?.let { - put("blob", it.toByteArray().toBase64Url()) + put("blob", it.toByteArray().toDataItem()) } } - override suspend fun applyCbor( + override suspend fun generateSdJwtResponseClaims( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): Map { - return buildMap { - putAll(super.applyCbor(transactionData, credential, userInput)) - transactionData.payload.string?.let { - put("string", it.toDataItem()) + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { + putAll(super.generateSdJwtResponseClaims(transactionData, credential, userInput, docRequestId)) + transactionData.payload.string?.let { + put("string", JsonPrimitive(it)) + } + transactionData.payload.blob?.let { + put("blob", JsonPrimitive(it.toByteArray().toBase64Url())) + } + } + + override suspend fun verifyMdocResponse( + transactionData: TransactionData, + responseElements: Map + ) { + super.verifyMdocResponse(transactionData, responseElements) + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + transactionData.payload.string?.let { expectedString -> + val actualString = responseElements["string"]?.asTstr + if (actualString != expectedString) { + throw IllegalStateException("String mismatch: expected $expectedString, got $actualString") + } + } + transactionData.payload.blob?.let { expectedBlob -> + val actualBlob = responseElements["blob"]?.asBstr + if (actualBlob == null || !ByteString(actualBlob).equals(expectedBlob)) { + throw IllegalStateException("Blob mismatch") + } } - transactionData.payload.blob?.let { - put("blob", it.toByteArray().toDataItem()) + } + } + + override suspend fun verifySdJwtResponse( + transactionData: TransactionData, + responseClaims: Map + ) { + super.verifySdJwtResponse(transactionData, responseClaims) + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + transactionData.payload.string?.let { expectedString -> + val actualString = responseClaims["string"]?.jsonPrimitive?.contentOrNull + if (actualString != expectedString) { + throw IllegalStateException("String mismatch: expected $expectedString, got $actualString") + } + } + transactionData.payload.blob?.let { expectedBlob -> + val actualBlob = responseClaims["blob"]?.jsonPrimitive?.contentOrNull?.fromBase64Url() + if (actualBlob == null || !ByteString(actualBlob).equals(expectedBlob)) { + throw IllegalStateException("Blob mismatch") + } } } } diff --git a/multipaz-verifier/src/main/java/org/multipaz/verifier/request/verifyCredentials.kt b/multipaz-verifier/src/main/java/org/multipaz/verifier/request/verifyCredentials.kt index 82f2ed5cfe..afec500178 100644 --- a/multipaz-verifier/src/main/java/org/multipaz/verifier/request/verifyCredentials.kt +++ b/multipaz-verifier/src/main/java/org/multipaz/verifier/request/verifyCredentials.kt @@ -123,12 +123,15 @@ suspend fun makeRequest(call: ApplicationCall) { val sessionId = Session.createSession() val encodedSessionId = encodeSessionId(sessionId) val transactions = transactionData?.map { it.toString() } + val origin = (request["origin"] as? JsonPrimitive)?.content + ?: call.request.headers["Origin"] + ?: BackendEnvironment.getDomain() val verificationSession = VerificationUtil.generateVerificationSessionForDcql( requestTypes = requestTypes, dcql = dcqlQueryToUse, transactionData = transactions, nonce = ByteString(nonce?.fromBase64Url() ?: Random.nextBytes(15)), - origin = BackendEnvironment.getDomain(), + origin = origin, responseUri = "$baseUrl/direct_post/$encodedSessionId", documentTypeRepository = BackendEnvironment.getInterface(DocumentTypeRepository::class)!!, verifierIdentities = verifierIdentities, diff --git a/multipaz-verifier/src/main/resources/resources/www/verify_credentials.js b/multipaz-verifier/src/main/resources/resources/www/verify_credentials.js index df79ca8442..59ab58ae4c 100644 --- a/multipaz-verifier/src/main/resources/resources/www/verify_credentials.js +++ b/multipaz-verifier/src/main/resources/resources/www/verify_credentials.js @@ -34,6 +34,9 @@ if (useUrlSchema) { adjustedRequest.protocols = []; } + if (!adjustedRequest.origin) { + adjustedRequest.origin = window.location.origin; + } const rq = await(await fetch(baseUrl + "make_request", { method: 'POST', headers: { diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/CannedTransactionData.kt b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/CannedTransactionData.kt index 85fff6fea0..ea4f8f877c 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/CannedTransactionData.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/CannedTransactionData.kt @@ -1,7 +1,5 @@ package org.multipaz.documenttype -import org.multipaz.util.fromBase64Url - /** * Sample data for request using a particular transaction data type. * @@ -19,5 +17,5 @@ class CannedTransactionData( * @return transaction data serialized as JSON (but **not** Base64Url-encoded) */ fun getSerializedJson(credentialIds: List): String = - transactionType.serializeJson(payload, credentialIds, hashAlgorithms = null) + transactionType.serializeOpenId4VpRequest(payload, credentialIds, hashAlgorithms = null) } \ No newline at end of file diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentType.kt b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentType.kt index 69eb2d542d..370ba12b03 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentType.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentType.kt @@ -88,8 +88,11 @@ class DocumentType private constructor( * Initialize the [mdocBuilder]. * * @param mdocDocType the DocType of the ISO mdoc. + * @return the builder. */ - fun addMdocDocumentType(mdocDocType: String) = apply { + fun addMdocDocumentType( + mdocDocType: String, + ) = apply { mdocBuilder = MdocDocumentType.Builder(mdocDocType) } @@ -396,6 +399,10 @@ class DocumentType private constructor( * @param validUntil the time at which the credential is valid until. * @param expectedUpdate the time at which to expect an update, or `null`. * @param domain the domain to use for the credential. + * @param randomProvider random number generator to use. + * @param includeElement predicate to filter which elements are included. + * @param deviceKeyAuthorizedNamespaces namespaces the device key is authorized to sign. + * @param deviceKeyAuthorizedDataElements data elements the device key is authorized to sign, keyed by namespace. * @return the [MdocCredential] that was added to [document]. */ suspend fun createMdocCredentialWithSampleData( diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentTypeRepository.kt b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentTypeRepository.kt index a4c6c6ea38..a337261959 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentTypeRepository.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/DocumentTypeRepository.kt @@ -76,8 +76,7 @@ class DocumentTypeRepository { for (existingType in transactionTypes) { check(existingType.identifier != transactionType.identifier) check(existingType.kbJwtResponseClaimName != transactionType.kbJwtResponseClaimName) - check(existingType.mdocResponseNamespace != transactionType.mdocResponseNamespace) - check(existingType.mdocRequestInfoIdentifier != transactionType.mdocRequestInfoIdentifier) + check(existingType.iso18013RequestInfoIdentifier != transactionType.iso18013RequestInfoIdentifier) } _transactionTypes.add(transactionType) } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/MdocDocumentType.kt b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/MdocDocumentType.kt index e3840660a4..6dc9333718 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/MdocDocumentType.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/MdocDocumentType.kt @@ -26,12 +26,13 @@ import org.multipaz.cbor.DataItem */ class MdocDocumentType private constructor( val docType: String, - val namespaces: Map + val namespaces: Map, ) { /** * Builder class for class [MdocDocumentType]. * * @param docType the docType of the ISO mdoc Document Type. + * @property namespaces the mutable map of namespaces being built. */ data class Builder( val docType: String, @@ -102,6 +103,8 @@ class MdocDocumentType private constructor( /** * Build the [MdocDocumentType]. + * + * @return the built [MdocDocumentType]. */ fun build() = MdocDocumentType(docType, namespaces.map { Pair(it.key, it.value.build()) }.toMap()) diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionType.kt b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionType.kt index f28712c0f5..ab3a36c0dd 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionType.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionType.kt @@ -1,48 +1,108 @@ package org.multipaz.documenttype import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.decodeToString import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.JsonPrimitive import org.multipaz.cbor.Bstr +import org.multipaz.cbor.Cbor import org.multipaz.cbor.DataItem -import org.multipaz.cbor.toDataItem import org.multipaz.cbor.Tagged +import org.multipaz.cbor.toDataItem import org.multipaz.credential.Credential import org.multipaz.crypto.Algorithm import org.multipaz.document.Document import org.multipaz.mdoc.credential.MdocCredential import org.multipaz.mdoc.mso.MobileSecurityObject import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol import org.multipaz.util.Logger +import org.multipaz.util.fromBase64Url /** - * An object that represents a particular transaction data type. + * Namespace defined by ISO/IEC 18013-5 for transaction data signing. + */ +const val ISO_18013_TRANSACTION_DATA_NAMESPACE = "org.iso.transactiondata" + +/** + * Represents a transaction data type used for dynamic linking and transaction authorization in + * digital credential presentations. + * + * Dynamic linking cryptographically binds a presentation to a specific transaction context + * (such as payment amount, currency, payee, or nonce) and optional user input (such as tip amount), + * ensuring the credential holder explicitly authorizes the transaction and protecting against + * relay, replay, and man-in-the-middle attacks. + * + * ### Supported Protocols and Credential Formats * - * All transaction types that are expected to be processed or rejected must be registered in a - * [DocumentTypeRepository] object. In OpenID4VP unregistered transaction types cause the whole - * request to be rejected. In ISO/IEC 18013-5:2021, unknown transaction types are not processed, - * which may or may not fail at verification time. + * Transaction processing supports both major presentment protocols and credential formats across four + * distinct combinations: * - * @param displayName human-readable transaction name + * 1. **ISO/IEC 18013-5 with ISO mdoc (`mso_mdoc`)**: The verifier sends transaction data inside the + * `requestInfo.transactionData` map of an ISO 18013-5 `DeviceRequest`. The wallet returns transaction + * response elements (e.g. `amount`, `currency`, and `tipAmount`) nested inside a CBOR map under the + * transaction [identifier] within the standard namespace ([ISO_18013_TRANSACTION_DATA_NAMESPACE]) in + * `DeviceSigned.nameSpaces`, bound to the request via `docRequestId`. + * + * 2. **ISO/IEC 18013-5 with SD-JWT VC (`dc+sd-jwt`)**: The verifier requests an SD-JWT VC within an + * ISO 18013-5 `DeviceRequest`. The wallet generates a Key Binding JWT (KB-JWT) where transaction + * processing response claims are placed under [kbJwtResponseClaimName] (along with `doc_request_id`). + * + * 3. **OpenID4VP with SD-JWT VC (`dc+sd-jwt`)**: The verifier supplies `transaction_data` in the + * OpenID4VP authorization request. The wallet hashes the transaction payload and inserts + * `transaction_data_hashes_alg` and `transaction_data_hashes` (along with user input claims) directly + * into the SD-JWT KB-JWT payload. + * + * 4. **OpenID4VP with ISO mdoc (`mso_mdoc`)**: The verifier supplies `transaction_data` in the + * OpenID4VP authorization request. The wallet places transaction evidence directly in + * `DeviceSigned.nameSpaces` under [openId4VpMdocResponseNamespace] (defaulting to [identifier]), + * returning top-level data elements for the computed hash (`transactionDataHash`), hash algorithm + * (`transactionDataHashAlg`), and user input (`tipAmount`). + * + * ### Lifecycle & Verification + * + * All transaction types expected to be processed or rejected must be registered in a + * [DocumentTypeRepository]. + * - **Applicability ([isApplicable])**: Validates that candidate credentials authorize the device key to + * sign under the protocol's designated namespace ([ISO_18013_TRANSACTION_DATA_NAMESPACE] for ISO 18013-5, or + * [openId4VpMdocResponseNamespace] for OpenID4VP) in the Mobile Security Object (MSO). + * - **Generation ([generateMdocResponseElements], [generateSdJwtResponseClaims])**: Invoked during wallet + * presentment to populate device-signed elements or KB-JWT claims based on the transaction payload and + * optional [TransactionUserInput]. + * - **Verification ([verifyMdocResponse], [verifySdJwtResponse])**: Invoked on the verifier side during + * document authentication to validate that returned amounts, currencies, user inputs, and transaction + * hashes match the requested transaction. + * + * @param PayloadT type of the transaction-specific payload data. + * @param displayName human-readable transaction name. * @param identifier unique transaction type identifier, corresponds to the `type` property in * transaction data in OpenID4VP; all [TransactionType] objects must have distinct identifiers. * @param kbJwtResponseClaimName if transaction processing results in any data, it will be inserted - * in key binding JWT using this claim name; all [TransactionType] objects must have distinct - * values. - * @param mdocRequestInfoIdentifier transaction type to use in `transactions` array in - * `requestInfo` map in ISO/IEC 18013-5:2021 document request to represent this transaction - * data; all [TransactionType] objects must have distinct values. - * @param mdocResponseNamespace namespace to use in `deviceSigned` namespace map in - * ISO/IEC 18013-5:2021 response to represent transaction hash and transaction processing - * results; all [TransactionType] objects must have distinct values. + * in key binding JWT using this claim name; all [TransactionType] objects must have distinct values. + * @param iso18013RequestInfoIdentifier transaction type to use in `transactionData` map in + * `requestInfo` map in ISO/IEC 18013-5 document request to represent this transaction data; + * all [TransactionType] objects must have distinct values. + * @param openId4VpMdocResponseNamespace namespace to use in `deviceSigned` namespace map in + * OpenID4VP response; defaults to [identifier]. + * @param defaultIntentToRetain default value for `intentToRetain` when requesting the transaction data + * element in the [ISO_18013_TRANSACTION_DATA_NAMESPACE] namespace in an ISO mdoc request. */ abstract class TransactionType( val displayName: String, val identifier: String, val kbJwtResponseClaimName: String = identifier, - val mdocRequestInfoIdentifier: String = identifier, - val mdocResponseNamespace: String = identifier, + val iso18013RequestInfoIdentifier: String = identifier, + val openId4VpMdocResponseNamespace: String = identifier, + val defaultIntentToRetain: Boolean = true, ) { + /** + * Returns the DeviceSigned namespace to use for the given presentment protocol. + */ + fun getMdocResponseNamespace(protocol: TransactionProtocol): String = when (protocol) { + TransactionProtocol.ISO_18013_5 -> ISO_18013_TRANSACTION_DATA_NAMESPACE + TransactionProtocol.OPENID4VP -> openId4VpMdocResponseNamespace + } + /** * Serializes transaction data for use in OpenID4VP protocol. * @@ -51,42 +111,81 @@ abstract class TransactionType( * @param hashAlgorithms optional list of hash algorithms that are accepted by the verifier * @return JSON-serialized (but **not** Base64Url-encoded!) transaction data */ - abstract fun serializeJson( + open fun serializeOpenId4VpRequest( payload: PayloadT, credentialIds: List, hashAlgorithms: List? = null - ): String + ): String = throw UnsupportedOperationException("serializeOpenId4VpRequest not implemented for '$identifier'") /** * Serializes transaction data for use in ISO/IEC 18013 protocols. * * @param payload transaction-specific data - * @param hashAlgorithms optional list of hash algorithms that are accepted by the verifier - * @return serialized transaction data + * @return serialized transaction data as a CBOR map (TransactionDataContent) */ - abstract fun serializeCbor( - payload: PayloadT, - hashAlgorithms: List? = null - ): DataItem + open fun serializeIso18013Request(payload: PayloadT): DataItem = + throw UnsupportedOperationException("serializeIso18013Request not implemented for '$identifier'") /** * Parses transaction data serialized for use in OpenID4VP protocol. * - * @param serialized serialized transaction data (Base64Url-encoded JSON) - * @return [TransactionData] object that holds serialized and parsed transaction data representations + * @param jsonString parsed JSON string from base64url-encoded OpenID4VP transaction_data + * @return transaction payload */ - abstract fun parseJson(serialized: ByteString): TransactionData + open fun parseOpenId4VpRequest(jsonString: String): PayloadT = + throw UnsupportedOperationException("parseOpenId4VpRequest not implemented for '$identifier'") /** * Parses transaction data serialized for use in ISO/IEC 18013 protocols. * - * @param serialized transaction data as it is represented in the request (specifically, - * value of the `data` field in the transaction object in the `transactions` array inside - * `requestInfo`); in many cases [serialized] is expected to be [Tagged] with - * [Tagged.tagNumber] equal to [Tagged.ENCODED_CBOR] - * @return [TransactionData] object that holds serialized and parsed transaction data representations + * @param dataItem value of the transaction data item in `transactionData` inside `requestInfo` + * @return transaction payload + */ + open fun parseIso18013Request(dataItem: DataItem): PayloadT = + throw UnsupportedOperationException("parseIso18013Request not implemented for '$identifier'") + + + /** + * Parses transaction data serialized for use in OpenID4VP protocol. + */ + open fun parseJson(serialized: ByteString): TransactionData { + val jsonString = serialized.decodeToString().fromBase64Url().decodeToString() + return TransactionData( + type = this, + payload = parseOpenId4VpRequest(jsonString), + protocol = TransactionProtocol.OPENID4VP, + rawBytes = serialized, + ) + } + + /** + * Parses transaction data serialized for use in ISO/IEC 18013-5 presentment. + * + * @param serialized the CBOR data item representing the transaction request. + * @param intentToRetain whether the verifier intends to retain the transaction data. + * @return transaction data wrapping the parsed payload. + */ + open fun parseCbor( + serialized: DataItem, + intentToRetain: Boolean + ): TransactionData { + return TransactionData( + type = this, + payload = parseIso18013Request(serialized), + protocol = TransactionProtocol.ISO_18013_5, + rawBytes = ByteString(Cbor.encode(serialized)), + intentToRetain = intentToRetain, + ) + } + + /** + * Parses transaction data serialized for use in ISO/IEC 18013-5 presentment using [defaultIntentToRetain]. + * + * @param serialized the CBOR data item representing the transaction request. + * @return transaction data wrapping the parsed payload. */ - abstract fun parseCbor(serialized: DataItem): TransactionData + open fun parseCbor(serialized: DataItem): TransactionData = + parseCbor(serialized, defaultIntentToRetain) /** * Determines if this transaction is applicable to the given credential. @@ -95,10 +194,9 @@ abstract class TransactionType( * set option from consideration. If other options are available, presentment still may * succeed. * - * For mdoc credentials this method must check [mdocResponseNamespace] against - * [MobileSecurityObject.deviceKeyAuthorizedNamespaces] and possibly - * [MobileSecurityObject.deviceKeyAuthorizedDataElements] to determine if the transaction is - * applicable for this specific credential. + * For mdoc credentials this method checks whether the MSO authorizes the device key for + * [ISO_18013_TRANSACTION_DATA_NAMESPACE] in [MobileSecurityObject.deviceKeyAuthorizedNamespaces] or + * for [identifier] in [MobileSecurityObject.deviceKeyAuthorizedDataElements]. * * @param transactionData transaction data being considered * @param credential one of the credentials in the [Document] being considered @@ -111,98 +209,103 @@ abstract class TransactionType( return if (credential is MdocCredential) { // For mdoc there is a per-credential KeyAuthorizations section. We need to check // it to determine if this transaction can be applied to this credential - credential.mso.deviceKeyAuthorizedNamespaces.contains(mdocResponseNamespace) + val expectedNamespace = getMdocResponseNamespace(transactionData.protocol) + when (transactionData.protocol) { + TransactionProtocol.ISO_18013_5 -> { + credential.mso.deviceKeyAuthorizedNamespaces.contains(expectedNamespace) || + credential.mso.deviceKeyAuthorizedDataElements[expectedNamespace]?.contains(identifier) == true + } + TransactionProtocol.OPENID4VP -> { + credential.mso.deviceKeyAuthorizedNamespaces.contains(expectedNamespace) || + credential.mso.deviceKeyAuthorizedDataElements[expectedNamespace] != null + } + } } else { true } } /** - * Applies transaction in the context of ISO mdoc presentment. - * - * Note: unlike OpenID4VP, ISO/IEC 18013-5:2021 does not impose a particular requirement on - * transaction response (e.g. responding at least with transaction data hash). Each transaction - * type should define its own **verifiable** response. This response then will be validated - * by the verifier the using [verifyCborResponse] method. + * Generates device-signed data elements for an Mdoc credential. * - * Default implementation computes transaction data hash, similar to how OpenID4VP does it. - * - * Note: one should not assume that [transactionData] will be in CBOR format. Transaction data - * is formatted according to the presentment protocol. + * Used for Case 1 (ISO 18013-5) and Case 4 (OpenID4VP). * * @param transactionData transaction data * @param credential credential being presented * @param userInput additional data specified by the user - * @return transaction-specific data that should be added to the presentment (in `deviceSigned` - * namespace map using [mdocResponseNamespace]), `null` if no extra data should be added. + * @param docRequestId document request index in ISO 18013-5, null in OpenID4VP + * @return map of data elements for `DeviceSigned.nameSpaces["org.iso.transactiondata"][identifier]` */ - open suspend fun applyCbor( + open suspend fun generateMdocResponseElements( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? + userInput: TransactionUserInput?, + docRequestId: Int? = null ): Map = buildMap { - userInput?.applyCbor(transactionData, credential)?.let { putAll(it) } - val alg = transactionData.hashAlgorithms?.first()?.also { - put("transactionDataHashAlg", it.coseAlgorithmIdentifier!!.toDataItem()) + userInput?.generateMdocResponseElements(transactionData, credential)?.let { putAll(it) } + if (transactionData.protocol == TransactionProtocol.OPENID4VP) { + val alg = transactionData.hashAlgorithms?.first()?.also { + put("transactionDataHashAlg", it.coseAlgorithmIdentifier!!.toDataItem()) + } + put("transactionDataHash", + transactionData.computeHash(alg ?: Algorithm.SHA256).toByteArray().toDataItem()) + } else { + docRequestId?.let { put("docRequestId", it.toDataItem()) } } - put("transactionDataHash", - transactionData.computeHash(alg ?: Algorithm.SHA256).toByteArray().toDataItem()) } /** - * Applies transaction in the context of IETF SD-JWT presentment. + * Generates Key Binding JWT claims for an SD-JWT credential. * - * Default implementation does not add any transaction-specific data. + * Used for Case 2 (ISO 18013-5) and Case 3 (OpenID4VP). * * @param transactionData transaction data * @param credential credential being presented * @param userInput additional data specified by the user - * @return transaction-specific data that should be added to the presentment (in key-binding - * JWT body using [kbJwtResponseClaimName]), `null` if no extra data should be added. + * @param docRequestId document request index in ISO 18013-5, null in OpenID4VP + * @return map of claims to include in the KB-JWT payload */ - open suspend fun applyJson( + open suspend fun generateSdJwtResponseClaims( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): JsonElement? = userInput?.applyJson(transactionData, credential)?.let { claims -> - buildJsonObject { - for ((name, value) in claims) { - put(name, value) - } + userInput: TransactionUserInput?, + docRequestId: Int? = null + ): Map = buildMap { + userInput?.generateSdJwtResponseClaims(transactionData, credential)?.let { putAll(it) } + if (transactionData.protocol == TransactionProtocol.ISO_18013_5) { + docRequestId?.let { put("doc_request_id", JsonPrimitive(it)) } } } /** - * Verify transaction response for mdoc presentment. - * - * Note: unlike OpenID4VP, ISO/IEC 18013-5:2021 does not impose a particular requirement on - * transaction response (e.g. responding at least with transaction data hash). Each transaction - * type should define its own **verifiable** response and implement verification in this - * method. - * - * Default implementation verifies transaction data hash computed by default implementation - * of [applyCbor]. - * - * @param transactionData transaction data - * @param transactionResponse key-value-map for values returned in [mdocResponseNamespace] - * namespace in the credential presentation - * @throws IllegalStateException if response does not pass verification + * Verifies transaction response returned in an Mdoc presentation. */ - open suspend fun verifyCborResponse( + open suspend fun verifyMdocResponse( transactionData: TransactionData, - transactionResponse: Map + responseElements: Map ) { - val hashAlg = transactionResponse["transactionDataHashAlg"]?.let { - Algorithm.fromCoseAlgorithmIdentifier(it.asNumber.toInt()) - } - val hash = transactionResponse["transactionDataHash"] as? Bstr - ?: throw IllegalStateException("Invalid response for transaction '$identifier'") - val expectedHash = transactionData.computeHash(hashAlg ?: Algorithm.SHA256) - if (ByteString(hash.asBstr) != expectedHash) { - throw IllegalStateException("Transaction hash failed to verify for '$identifier'") + if (transactionData.protocol == TransactionProtocol.OPENID4VP) { + val hashAlg = responseElements["transactionDataHashAlg"]?.let { + Algorithm.fromCoseAlgorithmIdentifier(it.asNumber.toInt()) + } + val hash = responseElements["transactionDataHash"] as? Bstr + ?: throw IllegalStateException("Invalid response for transaction '$identifier'") + val expectedHash = transactionData.computeHash(hashAlg ?: Algorithm.SHA256) + if (ByteString(hash.asBstr) != expectedHash) { + throw IllegalStateException("Transaction hash failed to verify for '$identifier'") + } } } + /** + * Verifies transaction response returned in an SD-JWT presentation. + */ + open suspend fun verifySdJwtResponse( + transactionData: TransactionData, + responseClaims: Map + ) { + } + companion object { private const val TAG = "TransactionType" @@ -229,7 +332,7 @@ abstract class TransactionType( */ fun joseHashAlgorithms(transactionDataHashesAlg: List?): List? = transactionDataHashesAlg - ?.mapNotNull { it.joseAlgorithmIdentifier } + ?.mapNotNull { it.hashAlgorithmName ?: it.joseAlgorithmIdentifier } ?.ifEmpty { throw IllegalArgumentException("No valid hash algorithms") } /** diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionUserInput.kt b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionUserInput.kt index 552d900c36..c68449938f 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionUserInput.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/documenttype/TransactionUserInput.kt @@ -14,15 +14,13 @@ import org.multipaz.presentment.TransactionData abstract class TransactionUserInput { /** - * Returns the list of claims to add to the transaction response in ISO mdoc presentment. - * - * Note: [TransactionType.applyCbor] may or may not call this method or override its result + * Returns the list of data elements to add to the transaction response in ISO mdoc presentment. * * @param transactionData transaction data * @param credential credential being presented * @return transaction-specific data that should be added to the presentment */ - abstract fun applyCbor( + abstract fun generateMdocResponseElements( transactionData: TransactionData<*>, credential: Credential ): Map @@ -30,13 +28,11 @@ abstract class TransactionUserInput { /** * Returns the list of claims to add to the transaction response in SD-JWT presentment. * - * Note: [TransactionType.applyJson] may or may not call this method or override its result - * * @param transactionData transaction data * @param credential credential being presented * @return transaction-specific data that should be added to the presentment */ - abstract fun applyJson( + abstract fun generateSdJwtResponseClaims( transactionData: TransactionData<*>, credential: Credential ): Map diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DeviceRequest.kt b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DeviceRequest.kt index 38e0d3c0b8..5ace1062d9 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DeviceRequest.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DeviceRequest.kt @@ -38,6 +38,7 @@ import org.multipaz.crypto.EcCurve import org.multipaz.crypto.SignatureVerificationException import org.multipaz.crypto.X509CertChain import org.multipaz.documenttype.DocumentTypeRepository +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.mdoc.credential.MdocCredential import org.multipaz.mdoc.response.Iso18015ResponseException import org.multipaz.mdoc.util.mdocVersionCompareTo @@ -294,9 +295,6 @@ data class DeviceRequest private constructor( private var deviceRequestInfo: DeviceRequestInfo? = null, private val version: String? = null, ) { - internal val isVersion10: Boolean - get() = version != null && version.mdocVersionCompareTo("1.1") < 0 - private val docRequests = mutableListOf() private val readerAuthAll = mutableListOf() @@ -316,10 +314,23 @@ data class DeviceRequest private constructor( check(readerAuthAll.isEmpty()) { "Cannot call addDocRequest() after addReaderAuthAll()" } + val effectiveNameSpaces = if (docRequestInfo?.transactionData != null) { + val txData = docRequestInfo.transactionData + val mutableNamespaces = nameSpaces.mapValues { it.value.toMutableMap() }.toMutableMap() + val txElements = mutableNamespaces.getOrPut(ISO_18013_TRANSACTION_DATA_NAMESPACE) { mutableMapOf() } + for ((typeId, _) in txData.data) { + if (!txElements.containsKey(typeId)) { + txElements[typeId] = true + } + } + mutableNamespaces + } else { + nameSpaces + } val itemsRequest = buildCborMap { put("docType", docType) putCborMap("nameSpaces") { - for ((namespaceName, dataElementMap) in nameSpaces) { + for ((namespaceName, dataElementMap) in effectiveNameSpaces) { putCborMap(namespaceName) { for ((dataElementName, intentToRetain) in dataElementMap) { put(dataElementName, intentToRetain) @@ -327,12 +338,10 @@ data class DeviceRequest private constructor( } } } - if (!isVersion10) { - docRequestInfo?.let { - val docRequestInfoDataItem = it.toDataItem() - if (docRequestInfoDataItem.asMap.isNotEmpty()) { - put("requestInfo", docRequestInfoDataItem) - } + docRequestInfo?.let { + val docRequestInfoDataItem = it.toDataItem() + if (docRequestInfoDataItem.asMap.isNotEmpty()) { + put("requestInfo", docRequestInfoDataItem) } } } @@ -340,8 +349,8 @@ data class DeviceRequest private constructor( docRequests.add( DocRequest( docType = docType, - nameSpaces = nameSpaces, - docRequestInfo = if (isVersion10) null else docRequestInfo, + nameSpaces = effectiveNameSpaces, + docRequestInfo = docRequestInfo, docRequestId = docRequests.size, readerAuth_ = null, itemsRequestBytes = itemsRequestBytes @@ -373,10 +382,23 @@ data class DeviceRequest private constructor( check(readerAuthAll.isEmpty()) { "Cannot call addDocRequest() after addReaderAuthAll()" } + val effectiveNameSpaces = if (docRequestInfo?.transactionData != null) { + val txData = docRequestInfo.transactionData + val mutableNamespaces = nameSpaces.mapValues { it.value.toMutableMap() }.toMutableMap() + val txElements = mutableNamespaces.getOrPut(ISO_18013_TRANSACTION_DATA_NAMESPACE) { mutableMapOf() } + for ((typeId, _) in txData.data) { + if (!txElements.containsKey(typeId)) { + txElements[typeId] = true + } + } + mutableNamespaces + } else { + nameSpaces + } val itemsRequest = buildCborMap { put("docType", docType) putCborMap("nameSpaces") { - for ((namespaceName, dataElementMap) in nameSpaces) { + for ((namespaceName, dataElementMap) in effectiveNameSpaces) { putCborMap(namespaceName) { for ((dataElementName, intentToRetain) in dataElementMap) { put(dataElementName, intentToRetain) @@ -384,12 +406,10 @@ data class DeviceRequest private constructor( } } } - if (!isVersion10) { - docRequestInfo?.let { - val docRequestInfoDataItem = it.toDataItem() - if (docRequestInfoDataItem.asMap.isNotEmpty()) { - put("requestInfo", docRequestInfoDataItem) - } + docRequestInfo?.let { + val docRequestInfoDataItem = it.toDataItem() + if (docRequestInfoDataItem.asMap.isNotEmpty()) { + put("requestInfo", docRequestInfoDataItem) } } } @@ -422,8 +442,8 @@ data class DeviceRequest private constructor( } docRequests.add(DocRequest( docType = docType, - nameSpaces = nameSpaces, - docRequestInfo = if (isVersion10) null else docRequestInfo, + nameSpaces = effectiveNameSpaces, + docRequestInfo = docRequestInfo, docRequestId = docRequests.size, readerAuth_ = readerAuth, itemsRequestBytes = itemsRequestBytes, @@ -819,6 +839,9 @@ data class DeviceRequest private constructor( val logicalRequirements = mutableListOf>>() docRequest.nameSpaces.forEach { (namespace, dataElements) -> + if (namespace == ISO_18013_TRANSACTION_DATA_NAMESPACE) { + return@forEach + } dataElements.forEach { (elementName, intentToRetain) -> // Base Option (Index 0) val baseClaim = MdocRequestedClaim( @@ -854,6 +877,7 @@ data class DeviceRequest private constructor( } } + val isVersion10 = version.mdocVersionCompareTo("1.1") < 0 if (isVersion10) { val matchingClaimValues = mutableMapOf() @@ -873,7 +897,8 @@ data class DeviceRequest private constructor( } // In ISO 18013-5:2021 (v1.0), the request is satisfied if at least one requested element is present - if (matchingClaimValues.isEmpty()) { + if ((logicalRequirements.isNotEmpty() && matchingClaimValues.isEmpty()) || + (logicalRequirements.isEmpty() && !docRequest.nameSpaces.containsKey(ISO_18013_TRANSACTION_DATA_NAMESPACE))) { val reason = if (missingElements.size == 1) { "missing data element ${missingElements[0]}" } else { @@ -888,7 +913,10 @@ data class DeviceRequest private constructor( ) for (transaction in transactionData) { if (!transaction.isApplicable(cred)) { - return ClaimMatchResult(null, null) + return ClaimMatchResult( + match = null, + failureReason = "transaction ${transaction.type.identifier} is not applicable" + ) } } @@ -936,6 +964,19 @@ data class DeviceRequest private constructor( return ClaimMatchResult(null, reason) } + val transactionData = extractTransactionData( + docRequest.docRequestInfo, + presentmentSource.documentTypeRepository + ) + for (transaction in transactionData) { + if (!transaction.isApplicable(cred)) { + return ClaimMatchResult( + match = null, + failureReason = "transaction ${transaction.type.identifier} is not applicable" + ) + } + } + // 2. Generate Permutations (Cartesian Product of Options) // Score = Sum of indices of chosen options. Lower is better. // Result is Pair, Score> @@ -986,17 +1027,6 @@ data class DeviceRequest private constructor( } } - val transactionData = extractTransactionData( - docRequest.docRequestInfo, - presentmentSource.documentTypeRepository - ) - for (transaction in transactionData) { - if (!transaction.isApplicable(cred)) { - didNotMatch = true - break - } - } - if (!didNotMatch) { // Success! Select the credential with these specific claims val selectedCred = presentmentSource.selectCredential( @@ -1025,12 +1055,12 @@ data class DeviceRequest private constructor( requestInfo: DocRequestInfo?, documentTypeRepository: DocumentTypeRepository? ): List> { - if (requestInfo == null || documentTypeRepository == null || requestInfo.transactions == null) { + if (requestInfo == null || documentTypeRepository == null || requestInfo.transactionData == null) { return emptyList() } - return requestInfo.transactions.data.map { (type, data) -> + return requestInfo.transactionData.data.map { (type, data) -> val knownType = documentTypeRepository.transactionTypes.find { - it.mdocRequestInfoIdentifier == type + it.iso18013RequestInfoIdentifier == type } ?: throw IllegalArgumentException("Unknown transaction type: '$type'") knownType.parseCbor(data) } @@ -1608,7 +1638,7 @@ internal fun deviceRequestAddQueries( zkRequest = zkRequest, docFormat = if (credQuery.format == "dc+sd-jwt") "dc+sd-jwt" else null, dataElementIdentifierMapping = dataElementIdentifierMapping, - transactions = docTransactions, + transactionData = docTransactions, issuerIdentifiers = credQuery.issuerIdentifiers ) } else { diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequest.kt b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequest.kt index e83251e175..a693ef42c1 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequest.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequest.kt @@ -1,5 +1,7 @@ package org.multipaz.mdoc.request +import kotlinx.io.bytestring.ByteString +import org.multipaz.cbor.Cbor import org.multipaz.cbor.DataItem import org.multipaz.cbor.buildCborMap import org.multipaz.cose.Cose @@ -9,7 +11,9 @@ import org.multipaz.cose.toCoseLabel import org.multipaz.crypto.Algorithm import org.multipaz.crypto.X509CertChain import org.multipaz.documenttype.DocumentTypeRepository +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol /** * Document request according to ISO 18013-5. @@ -100,8 +104,10 @@ data class DocRequest internal constructor( documentTypeRepository: DocumentTypeRepository ): List> = buildList { for (transactionType in documentTypeRepository.transactionTypes) { - docRequestInfo?.transactions?.data[transactionType.mdocRequestInfoIdentifier]?.let { data -> - add(transactionType.parseCbor(data)) + docRequestInfo?.transactionData?.data[transactionType.iso18013RequestInfoIdentifier]?.let { data -> + val intentToRetain = nameSpaces[ISO_18013_TRANSACTION_DATA_NAMESPACE]?.get(transactionType.iso18013RequestInfoIdentifier) + ?: transactionType.defaultIntentToRetain + add(transactionType.parseCbor(data, intentToRetain)) } } } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequestInfo.kt b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequestInfo.kt index 732e6bb1fa..bc3ee52dce 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequestInfo.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/request/DocRequestInfo.kt @@ -28,7 +28,7 @@ import org.multipaz.cbor.putCborMap * @property docResponseEncryption optional request for encrypting the response. * @property docFormat optional document format. * @property dataElementIdentifierMapping optional data element identifier mapping. - * @property transactions optional information about requested transactions. + * @property transactionData optional information about requested transaction data. * @property otherInfo other request info. */ data class DocRequestInfo( @@ -40,7 +40,7 @@ data class DocRequestInfo( val docResponseEncryption: EncryptionParameters? = null, val docFormat: String? = null, val dataElementIdentifierMapping: Map = emptyMap(), - val transactions: TransactionsInfo? = null, + val transactionData: TransactionsInfo? = null, val otherInfo: Map = emptyMap(), ) { internal fun toDataItem() = buildCborMap { @@ -99,13 +99,10 @@ data class DocRequestInfo( } } } - transactions?.let { - putCborArray("transactions") { + transactionData?.let { + putCborMap("transactionData") { for ((type, data) in it.data) { - addCborMap { - put("type", type) - put("data", data) - } + put(type, data) } } } @@ -124,7 +121,7 @@ data class DocRequestInfo( docResponseEncryption != null || docFormat != null || dataElementIdentifierMapping.isNotEmpty() || - transactions != null + transactionData != null } companion object { @@ -156,9 +153,9 @@ data class DocRequestInfo( }.let { JsonArray(it) } }.toMap() } ?: emptyMap() - val transactions = dataItem.getOrNull("transactions")?.let { + val transactionData = dataItem.getOrNull("transactionData")?.let { TransactionsInfo( - data = it.asArray.associate { item -> Pair(item["type"].asTstr, item["data"]) } + data = it.asMap.entries.associate { (k, v) -> Pair(k.asTstr, v) } ) } val otherInfo = mutableMapOf() @@ -172,6 +169,7 @@ data class DocRequestInfo( "docResponseEncryption", "docFormat", "dataElementIdentifierMapping", + "transactionData", "transactions" -> continue else -> otherInfo[otherKey] = otherValue } @@ -185,7 +183,7 @@ data class DocRequestInfo( docResponseEncryption = docResponseEncryption, docFormat = docFormat, dataElementIdentifierMapping = dataElementIdentifierMapping, - transactions = transactions, + transactionData = transactionData, otherInfo = otherInfo ) } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/DeviceResponse.kt b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/DeviceResponse.kt index d9df7d0de9..771963c701 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/DeviceResponse.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/DeviceResponse.kt @@ -12,6 +12,7 @@ import org.multipaz.cose.CoseSign1 import org.multipaz.crypto.AsymmetricKey import org.multipaz.crypto.EcPublicKey import org.multipaz.documenttype.DocumentTypeRepository +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.mdoc.credential.MdocCredential import org.multipaz.mdoc.devicesigned.DeviceNamespaces import org.multipaz.mdoc.devicesigned.buildDeviceNamespaces @@ -255,13 +256,13 @@ data class DeviceResponse internal constructor( var docRequestId: ULong? = null val data = doc.deviceNamespaces.data for (transactionType in documentTypeRepository.transactionTypes) { - val transactionResponse = data[transactionType.mdocResponseNamespace] ?: continue - val transactionDocRequestId = transactionResponse["docRequestId"] as? Uint + val transactionItem = data[ISO_18013_TRANSACTION_DATA_NAMESPACE]?.get(transactionType.identifier) ?: continue + val transactionDocRequestId = transactionItem.getOrNull("docRequestId") as? Uint ?: throw IllegalStateException( "'docRequestId' is missing or invalid for transaction '${transactionType.identifier}'") if (docRequestId == null) { docRequestId = transactionDocRequestId.value - } else if(docRequestId != transactionDocRequestId.value) { + } else if (docRequestId != transactionDocRequestId.value) { throw IllegalStateException("inconsistent 'docRequestId' values") } } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/MdocDocument.kt b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/MdocDocument.kt index 48aadb73db..ea92c8c299 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/MdocDocument.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/mdoc/response/MdocDocument.kt @@ -30,6 +30,7 @@ import org.multipaz.mdoc.issuersigned.IssuerSignedItem import org.multipaz.mdoc.issuersigned.buildIssuerNamespaces import org.multipaz.mdoc.mso.MobileSecurityObject import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol import org.multipaz.presentment.PresentmentUnlockReason import org.multipaz.request.MdocRequestedClaim import kotlin.time.Instant @@ -258,9 +259,25 @@ class MdocDocument( this.transactionData = transactionData transactionResponse = buildMap { for (transaction in transactionData) { - val response = deviceNamespaces.data[transaction.type.mdocResponseNamespace] - ?: throw IllegalStateException("No transaction response for '${transaction.type.identifier}'") - transaction.verifyCborResponse(response) + val response: Map = when (transaction.protocol) { + TransactionProtocol.ISO_18013_5 -> { + val ns = transaction.type.getMdocResponseNamespace(TransactionProtocol.ISO_18013_5) + val namespaceMap = deviceNamespaces.data[ns] + ?: throw IllegalStateException("No transaction response namespace '$ns'") + val responseItem = namespaceMap[transaction.type.identifier] + ?: throw IllegalStateException("No transaction response for '${transaction.type.identifier}'") + responseItem.asMap.entries.associate { (k, v) -> Pair(k.asTstr, v) } + } + TransactionProtocol.OPENID4VP -> { + val ns = transaction.type.getMdocResponseNamespace(TransactionProtocol.OPENID4VP) + val namespaceMap = deviceNamespaces.data[ns] + ?: throw IllegalStateException( + "No transaction response for '${transaction.type.identifier}' in namespace '$ns'" + ) + namespaceMap + } + } + transaction.verifyMdocResponse(response) put(transaction.type.identifier, response) } } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/openid/OpenID4VP.kt b/multipaz/src/commonMain/kotlin/org/multipaz/openid/OpenID4VP.kt index cc14d7b139..2c46aa098d 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/openid/OpenID4VP.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/openid/OpenID4VP.kt @@ -57,6 +57,7 @@ import org.multipaz.sdjwt.credential.SdJwtVcCredential import org.multipaz.presentment.PresentmentUnlockReason import org.multipaz.presentment.ConsentData import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol import org.multipaz.presentment.computeTransactionResponse import org.multipaz.request.OpenID4VPRequesterIdentity import org.multipaz.request.RequesterIdentity @@ -760,36 +761,36 @@ object OpenID4VP { ): Map { val transactionResponse = mutableMapOf() for (data in transactionData) { - val response = data.applyJson(credential as Credential, transactionUserInput[data.type.identifier]) - if (response != null || docRequestId != null) { - transactionResponse[data.type.kbJwtResponseClaimName] = if (docRequestId == null) { - response!! - } else { - buildJsonObject { - if (response != null) { - for ((name, value) in response.jsonObject) { - put(name, value) - } - } - put("doc_request_id", docRequestId) + val responseClaims = data.generateSdJwtResponseClaims( + credential as Credential, + transactionUserInput[data.type.identifier], + docRequestId + ) + if (responseClaims.isNotEmpty()) { + transactionResponse[data.type.kbJwtResponseClaimName] = buildJsonObject { + for ((name, value) in responseClaims) { + put(name, value) } } } } - val hashAlgorithm = transactionData.firstNotNullOfOrNull { it.hashAlgorithms?.first() } - if (hashAlgorithm != null) { - // Non-default hash algorithm; ensure all transaction data items are - // using the same one - transactionData.forEach { data -> - check(hashAlgorithm == (data.hashAlgorithms?.first() ?: Algorithm.SHA256)) + val isIso18013_5 = transactionData.any { it.protocol == TransactionProtocol.ISO_18013_5 } + if (!isIso18013_5) { + val hashAlgorithm = transactionData.firstNotNullOfOrNull { it.hashAlgorithms?.first() } + if (hashAlgorithm != null) { + // Non-default hash algorithm; ensure all transaction data items are + // using the same one + transactionData.forEach { data -> + check(hashAlgorithm == (data.hashAlgorithms?.first() ?: Algorithm.SHA256)) + } + transactionResponse["transaction_data_hashes_alg"] = + JsonPrimitive(hashAlgorithm.hashAlgorithmName) } - transactionResponse["transaction_data_hashes_alg"] = - JsonPrimitive(hashAlgorithm.hashAlgorithmName) - } - transactionResponse["transaction_data_hashes"] = buildJsonArray { - transactionData.forEach { - data -> add(data.computeHash( - hashAlgorithm ?: Algorithm.SHA256).toByteArray().toBase64Url()) + transactionResponse["transaction_data_hashes"] = buildJsonArray { + transactionData.forEach { data -> + add(data.computeHash( + hashAlgorithm ?: Algorithm.SHA256).toByteArray().toBase64Url()) + } } } return transactionResponse diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/openid/dcql/DcqlQuery.kt b/multipaz/src/commonMain/kotlin/org/multipaz/openid/dcql/DcqlQuery.kt index bf2aa959a1..025ede350f 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/openid/dcql/DcqlQuery.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/openid/dcql/DcqlQuery.kt @@ -238,7 +238,8 @@ data class DcqlQuery( val credential = presentmentSource.selectCredential( document = cred.document, requestedClaims = credentialQuery.claims, - keyAgreementPossible = effectiveKeyAgreementPossible + keyAgreementPossible = effectiveKeyAgreementPossible, + credential = cred, ) if (credential == null) { throw DcqlCredentialQueryException("Error selecting credential with id ${credentialQuery.id}") @@ -286,7 +287,8 @@ data class DcqlQuery( credential = presentmentSource.selectCredential( document = cred.document, requestedClaims = credentialQuery.claims, - keyAgreementPossible = effectiveKeyAgreementPossible + keyAgreementPossible = effectiveKeyAgreementPossible, + credential = cred, )!!, claims = matchingClaimValues, transactionData = transactionData @@ -459,35 +461,36 @@ data class DcqlQuery( val dcqlClaimIdToClaim = mutableMapOf() val dcqlClaimSets = mutableListOf() - val claims = c["claims"]!!.jsonArray - check(claims.isNotEmpty()) - for (claim in claims) { - val cl = claim.jsonObject - val claimId = cl["id"]?.jsonPrimitive?.content - val path = cl["path"]!!.jsonArray - val values = cl["values"]?.jsonArray - val mdocIntentToRetain = cl["intent_to_retain"]?.jsonPrimitive?.boolean - val requestedClaim = if (mdocDocType != null) { - require(path.size == 2) - MdocRequestedClaim( - id = claimId, - docType = mdocDocType, - namespaceName = path[0].jsonPrimitive.content, - dataElementName = path[1].jsonPrimitive.content, - intentToRetain = mdocIntentToRetain ?: false, - values = values - ) - } else { - JsonRequestedClaim( - id = claimId, - vctValues = vctValues!!, - claimPath = path, - values = values - ) - } - dcqlClaims.add(requestedClaim) - if (claimId != null) { - dcqlClaimIdToClaim[claimId] = requestedClaim + val claims = c["claims"]?.jsonArray + if (claims != null) { + for (claim in claims) { + val cl = claim.jsonObject + val claimId = cl["id"]?.jsonPrimitive?.content + val path = cl["path"]!!.jsonArray + val values = cl["values"]?.jsonArray + val mdocIntentToRetain = cl["intent_to_retain"]?.jsonPrimitive?.boolean + val requestedClaim = if (mdocDocType != null) { + require(path.size == 2) + MdocRequestedClaim( + id = claimId, + docType = mdocDocType, + namespaceName = path[0].jsonPrimitive.content, + dataElementName = path[1].jsonPrimitive.content, + intentToRetain = mdocIntentToRetain ?: false, + values = values + ) + } else { + JsonRequestedClaim( + id = claimId, + vctValues = vctValues!!, + claimPath = path, + values = values + ) + } + dcqlClaims.add(requestedClaim) + if (claimId != null) { + dcqlClaimIdToClaim[claimId] = requestedClaim + } } } @@ -590,9 +593,11 @@ private fun DcqlCredentialQuery.toJson(): JsonObject = buildJsonObject { } } } - putJsonArray("claims") { - claims.forEach { claim -> - add(claim.toJson()) + if (claims.isNotEmpty()) { + putJsonArray("claims") { + claims.forEach { claim -> + add(claim.toJson()) + } } } if (claimSets.isNotEmpty()) { diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/MdocResponse.kt b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/Iso18013Response.kt similarity index 93% rename from multipaz/src/commonMain/kotlin/org/multipaz/presentment/MdocResponse.kt rename to multipaz/src/commonMain/kotlin/org/multipaz/presentment/Iso18013Response.kt index b6278ca0b6..ddd4ff0256 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/MdocResponse.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/Iso18013Response.kt @@ -9,7 +9,7 @@ import org.multipaz.mdoc.response.DeviceResponse * @property deviceResponse a [org.multipaz.mdoc.response.DeviceResponse]. * @property eventData a [eventData] which can be used to log the presentment. */ -data class MdocResponse( +data class Iso18013Response( val deviceResponse: DeviceResponse, val eventData: EventPresentmentData -) \ No newline at end of file +) diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/PresentmentSource.kt b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/PresentmentSource.kt index 641c007369..8fe91588ac 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/PresentmentSource.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/PresentmentSource.kt @@ -98,12 +98,14 @@ abstract class PresentmentSource( * @param requestedClaims the requested claims. * @param keyAgreementPossible if non-empty, a credential using Key Agreement may be returned provided * its private key is one of the given curves. + * @param credential the candidate credential being considered, if known. * @return a [Credential] belonging to [document] that may be presented or `null`. */ abstract suspend fun selectCredential( document: Document, requestedClaims: List, keyAgreementPossible: List, + credential: Credential? = null, ): Credential? /** diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/SimplePresentmentSource.kt b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/SimplePresentmentSource.kt index c452ad2063..29f6ae6524 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/SimplePresentmentSource.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/SimplePresentmentSource.kt @@ -8,6 +8,7 @@ import org.multipaz.document.DocumentBadge import org.multipaz.document.DocumentStore import org.multipaz.documenttype.DocumentTypeRepository import org.multipaz.eventlogger.EventLogger +import org.multipaz.mdoc.credential.MdocCredential import org.multipaz.mdoc.zkp.ZkSystemRepository import org.multipaz.prompt.ShowConsentPromptFn import org.multipaz.prompt.promptModelRequestConsent @@ -16,6 +17,7 @@ import org.multipaz.request.MdocRequestedClaim import org.multipaz.request.RequestedClaim import org.multipaz.request.Requester import org.multipaz.request.TrustedRequesterIdentity +import org.multipaz.sdjwt.credential.KeyBoundSdJwtVcCredential import org.multipaz.sdjwt.credential.KeylessSdJwtVcCredential import kotlin.time.Clock import kotlin.time.Instant @@ -104,28 +106,79 @@ class SimplePresentmentSource( document: Document, requestedClaims: List, keyAgreementPossible: List, + credential: Credential?, ): Credential? { - check(requestedClaims.isNotEmpty()) val now = Clock.System.now() - val credsForPresentment = when (requestedClaims[0]) { - is MdocRequestedClaim -> { - CredentialForPresentment( - credential = document.findCredential(domains = domainsMdocSignature, now = now), - credentialKeyAgreement = document.findCredential(domains = domainsMdocKeyAgreement, now = now) - ) + val credsForPresentment = if (requestedClaims.isNotEmpty()) { + when (requestedClaims[0]) { + is MdocRequestedClaim -> { + CredentialForPresentment( + credential = document.findCredential(domains = domainsMdocSignature, now = now), + credentialKeyAgreement = document.findCredential(domains = domainsMdocKeyAgreement, now = now) + ) + } + is JsonRequestedClaim -> { + if (document.getCertifiedCredentials().firstOrNull() is KeylessSdJwtVcCredential) { + CredentialForPresentment( + credential = document.findCredential(domains = domainsKeylessSdJwt, now = now), + credentialKeyAgreement = null + ) + } else { + CredentialForPresentment( + credential = document.findCredential(domains = domainsKeyBoundSdJwt, now = now), + credentialKeyAgreement = null + ) + } + } } - is JsonRequestedClaim -> { - if (document.getCertifiedCredentials().firstOrNull() is KeylessSdJwtVcCredential) { + } else if (credential != null) { + when (credential) { + is MdocCredential -> { + CredentialForPresentment( + credential = document.findCredential(domains = domainsMdocSignature, now = now), + credentialKeyAgreement = document.findCredential(domains = domainsMdocKeyAgreement, now = now) + ) + } + is KeylessSdJwtVcCredential -> { CredentialForPresentment( credential = document.findCredential(domains = domainsKeylessSdJwt, now = now), credentialKeyAgreement = null ) - } else { + } + is KeyBoundSdJwtVcCredential -> { CredentialForPresentment( credential = document.findCredential(domains = domainsKeyBoundSdJwt, now = now), credentialKeyAgreement = null ) } + else -> { + CredentialForPresentment( + credential = document.findCredential(domains = domainsMdocSignature, now = now) + ?: document.findCredential(domains = domainsKeyBoundSdJwt, now = now) + ?: document.findCredential(domains = domainsKeylessSdJwt, now = now), + credentialKeyAgreement = null + ) + } + } + } else { + val certifiedCreds = document.getCertifiedCredentials() + val mdocCred = document.findCredential(domains = domainsMdocSignature, now = now) + val mdocKeyAgreementCred = document.findCredential(domains = domainsMdocKeyAgreement, now = now) + if (mdocCred != null || mdocKeyAgreementCred != null) { + CredentialForPresentment( + credential = mdocCred, + credentialKeyAgreement = mdocKeyAgreementCred + ) + } else if (certifiedCreds.any { it is KeylessSdJwtVcCredential }) { + CredentialForPresentment( + credential = document.findCredential(domains = domainsKeylessSdJwt, now = now), + credentialKeyAgreement = null + ) + } else { + CredentialForPresentment( + credential = document.findCredential(domains = domainsKeyBoundSdJwt, now = now), + credentialKeyAgreement = null + ) } } if (!preferSignatureToKeyAgreement && credsForPresentment.credentialKeyAgreement != null) { diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/TransactionData.kt b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/TransactionData.kt index fa015da2a5..4b594bfa5c 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/TransactionData.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/TransactionData.kt @@ -1,6 +1,7 @@ package org.multipaz.presentment import kotlinx.io.bytestring.ByteString +import kotlinx.serialization.json.JsonElement import org.multipaz.cbor.DataItem import org.multipaz.credential.Credential import org.multipaz.crypto.Algorithm @@ -9,95 +10,103 @@ import org.multipaz.document.Document import org.multipaz.documenttype.TransactionType import org.multipaz.documenttype.TransactionUserInput +/** + * Protocol through which transaction data was received. + */ +enum class TransactionProtocol { + /** Transaction data received via ISO/IEC 18013-5 presentment. */ + ISO_18013_5, + + /** Transaction data received via OpenID4VP presentment. */ + OPENID4VP +} + /** * An object that holds transaction data. * * Transaction data is held in two representation: serialized and parsed. Serialized representation * is raw sequence of bytes that reflects how transaction data is encoded in the verification - * protocol (transaction response includes hash of the serialized representation). Parsed - * representation includes transaction type that describes what kind of transaction this is, - * the list of hash algorithms that the verifier accepts for this transaction in the order of - * preference, and transaction payload which is transaction-type-specific data. + * protocol. Parsed representation includes transaction type that describes what kind of + * transaction this is, the list of hash algorithms that the verifier accepts for this transaction, + * and transaction payload which is transaction-type-specific data. * * @param type type of the transaction data item - * @param serialized serialized representation of the transaction data - * @param hashAlgorithms accepted hash algorithm override list for this transaction data in - * the order of preference * @param payload transaction payload + * @param protocol protocol context in which the transaction data was received + * @param rawBytes raw sequence of bytes representing the transaction data in the request + * @param hashAlgorithms accepted hash algorithm override list for this transaction data + * @param intentToRetain whether the reader intends to retain the transaction data */ class TransactionData( val type: TransactionType, - val serialized: ByteString, - val hashAlgorithms: List?, val payload: PayloadT, + val protocol: TransactionProtocol, + val rawBytes: ByteString, + val hashAlgorithms: List? = null, + val intentToRetain: Boolean = type.defaultIntentToRetain, ) { /** * Computes hash of the transaction data. * - * It is important that the verifier uses the same algorithm as the presenter (NB: the set - * of supported hash algorithms may differ!). - * * @return hash of the serialized transaction data */ suspend fun computeHash(algorithm: Algorithm = Algorithm.SHA256): ByteString = - ByteString(Crypto.digest(algorithm, serialized.toByteArray())) + ByteString(Crypto.digest(algorithm, rawBytes.toByteArray())) /** * Determines if this transaction is applicable to the given credential. * - * When transaction cannot be processed, it removes a particular "use case" or credential - * set option from consideration. If other options are available, presentment still may - * succeed. - * * @param credential one of the credentials in the [Document] being considered - * @return true if transaction can be processed false if it cannot + * @return true if transaction can be processed, false if it cannot */ suspend fun isApplicable(credential: Credential) = type.isApplicable(this, credential) /** - * Applies transaction in the context of OpenID4VP presentment. - * - * See [TransactionType.applyJson] + * Generates device-signed data elements for an Mdoc credential. * * @param credential credential being presented * @param userInput additional data specified by the user - * @return transaction-specific data that should be added to the presentment. + * @param docRequestId document request index in ISO 18013-5 presentment, null for OpenID4VP + * @return map of data elements for `DeviceSigned.nameSpaces` under `ISO_18013_TRANSACTION_DATA_NAMESPACE` */ - suspend fun applyJson( + suspend fun generateMdocResponseElements( credential: Credential, - userInput: TransactionUserInput? - ) = type.applyJson(this, credential, userInput) + userInput: TransactionUserInput?, + docRequestId: Int? = null + ): Map = type.generateMdocResponseElements(this, credential, userInput, docRequestId) /** - * Applies transaction in the context of ISO ISO/IEC 18013 presentment. - * - * See [TransactionType.applyCbor] + * Generates Key Binding JWT claims for an SD-JWT credential. * * @param credential credential being presented * @param userInput additional data specified by the user - * @return transaction-specific data that should be added to the presentment. + * @param docRequestId document request index in ISO 18013-5 presentment, null for OpenID4VP + * @return map of claims to include in the KB-JWT payload */ - suspend fun applyCbor( + suspend fun generateSdJwtResponseClaims( credential: Credential, - userInput: TransactionUserInput? - ) = type.applyCbor(this, credential, userInput) + userInput: TransactionUserInput?, + docRequestId: Int? = null + ): Map = type.generateSdJwtResponseClaims(this, credential, userInput, docRequestId) /** - * Creates equivalent transaction data for use in ISO ISO/IEC 18013 protocols. + * Verifies transaction response returned in an Mdoc presentation. * - * @return new [TransactionData] object that holds the same payload and hash algorithm, but - * its [TransactionData.serialized] is formatted for use in ISO ISO/IEC 18013 protocols. + * @param responseElements key-value-map for values returned in the mdoc presentation */ - fun convertToCbor(): DataItem = type.serializeCbor(payload, hashAlgorithms) + suspend fun verifyMdocResponse(responseElements: Map) = + type.verifyMdocResponse(this, responseElements) /** - * Verify transaction response for mdoc presentment. - * - * @see TransactionType.verifyCborResponse + * Verifies transaction response returned in an SD-JWT presentation. * - * @param transactionResponse key-value-map for values returned in the presentation - * @throws IllegalStateException if response does not pass verification + * @param responseClaims claims returned in the Key Binding JWT + */ + suspend fun verifySdJwtResponse(responseClaims: Map) = + type.verifySdJwtResponse(this, responseClaims) + + /** + * Creates equivalent transaction data for use in ISO 18013-5 protocols. */ - suspend fun verifyCborResponse(transactionResponse: Map) = - type.verifyCborResponse(this, transactionResponse) + fun serializeIso18013Request(): DataItem = type.serializeIso18013Request(payload) } \ No newline at end of file diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/mdocPresentment.kt b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/mdocPresentment.kt index 19f87e0a25..ea01563e11 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/presentment/mdocPresentment.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/presentment/mdocPresentment.kt @@ -7,6 +7,7 @@ import org.multipaz.cbor.Cbor import org.multipaz.cbor.DataItem import org.multipaz.cbor.Tagged import org.multipaz.cbor.buildCborArray +import org.multipaz.cbor.buildCborMap import org.multipaz.cbor.toDataItem import org.multipaz.credential.SecureAreaBoundCredential import org.multipaz.crypto.Algorithm @@ -15,6 +16,7 @@ import org.multipaz.crypto.Crypto import org.multipaz.crypto.EcCurve import org.multipaz.crypto.EcPublicKey import org.multipaz.document.Document +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.TransactionUserInput import org.multipaz.eventlogger.EventPresentmentData import org.multipaz.mdoc.credential.MdocCredential @@ -151,7 +153,7 @@ suspend fun mdocPresentmentAuthenticateUser( * @param requesterAppId the appId if an app is making the request or `null`. * @param requesterOrigin the origin or `null`. * @param creationTime the time to use for `creationTime` when presenting credentials such as SD-JWT+KB VCs. - * @return a [MdocResponse] containing [DeviceResponse] and [EventPresentmentData]. + * @return a [Iso18013Response] containing [DeviceResponse] and [EventPresentmentData]. */ @Throws( CancellationException::class, @@ -166,7 +168,7 @@ suspend fun mdocPresentmentGenerateResponse( requesterAppId: String? = null, requesterOrigin: String? = null, creationTime: Instant = Clock.System.now(), -): MdocResponse { +): Iso18013Response { val requester = Requester( requesterIdentities = deviceRequest.getRequesterIdentities(), appId = requesterAppId, @@ -365,7 +367,7 @@ suspend fun mdocPresentmentGenerateResponse( if (Logger.isDebugEnabled) { Logger.dCbor(TAG, "DeviceResponse", deviceResponse.toDataItem()) } - return MdocResponse( + return Iso18013Response( deviceResponse = deviceResponse, eventData = eventData ) @@ -406,7 +408,7 @@ suspend fun mdocPresentmentGenerateResponse( * @param onWaitingForUserInput called when waiting for input from the user (consent or authentication) * @param onDocumentsInFocus called with the documents currently selected for the user, including when * first shown. If the user selects a different set of documents in the prompt, this will be called again. - * @return a [MdocResponse] containing [DeviceResponse] and [EventPresentmentData]. + * @return a [Iso18013Response] containing [DeviceResponse] and [EventPresentmentData]. * @throws PresentmentCanceledException if the user canceled in a consent prompt. * @throws PresentmentCannotSatisfyRequestException if it's not possible to satisfy the request. */ @@ -430,7 +432,7 @@ suspend fun mdocPresentment( preselectedDocuments: List = emptyList(), onWaitingForUserInput: () -> Unit = {}, onDocumentsInFocus: (documents: List) -> Unit -): MdocResponse { +): Iso18013Response { val selection = mdocPresentmentObtainConsent( deviceRequest = deviceRequest, source = source, @@ -457,25 +459,48 @@ internal suspend fun computeTransactionResponse( match: CredentialPresentmentSetOptionMemberMatch, transactionUserInput: Map ): DeviceNamespaces { - val transactionResponseMap = match.transactionData.associate { transaction -> - Pair(transaction.type.mdocResponseNamespace, buildMap { - putAll(transaction.applyCbor( + if (match.transactionData.isEmpty()) { + return buildDeviceNamespaces {} + } + val isIso18013 = match.source is CredentialMatchSourceIso18013 + val docRequestId = (match.source as? CredentialMatchSourceIso18013)?.docRequest?.docRequestId + val groupedByNamespace = mutableMapOf>() + + if (isIso18013) { + for (transaction in match.transactionData) { + val responseMap = transaction.generateMdocResponseElements( + credential = match.credential, + userInput = transactionUserInput[transaction.type.identifier], + docRequestId = docRequestId + ) + val cborMap = buildCborMap { + for ((key, value) in responseMap) { + put(key, value) + } + } + val ns = ISO_18013_TRANSACTION_DATA_NAMESPACE + groupedByNamespace.getOrPut(ns) { mutableMapOf() }[transaction.type.identifier] = cborMap + } + } else { + for (transaction in match.transactionData) { + val responseMap = transaction.generateMdocResponseElements( credential = match.credential, - userInput = transactionUserInput[transaction.type.identifier] - )) - (match.source as? CredentialMatchSourceIso18013)?.let { source -> - // This is generally not available anywhere is the ISO 18013 response, - // but it is needed to verify the transaction, so we keep it in the - // transaction response. - put("docRequestId", source.docRequest.docRequestId.toDataItem()) + userInput = transactionUserInput[transaction.type.identifier], + docRequestId = null + ) + val ns = transaction.type.getMdocResponseNamespace(TransactionProtocol.OPENID4VP) + val nsMap = groupedByNamespace.getOrPut(ns) { mutableMapOf() } + for ((key, value) in responseMap) { + nsMap[key] = value } - }) + } } + return buildDeviceNamespaces { - for ((namespace, values) in transactionResponseMap) { + for ((namespace, elements) in groupedByNamespace) { addNamespace(namespace) { - for ((key, value) in values) { - addDataElement(key, value) + for ((elemName, elemValue) in elements) { + addDataElement(elemName, elemValue) } } } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/sdjwt/SdJwtKb.kt b/multipaz/src/commonMain/kotlin/org/multipaz/sdjwt/SdJwtKb.kt index ed21ccaaab..6f33398fa5 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/sdjwt/SdJwtKb.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/sdjwt/SdJwtKb.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.multipaz.crypto.Algorithm import org.multipaz.crypto.Crypto @@ -14,6 +15,7 @@ import org.multipaz.crypto.EcPublicKey import org.multipaz.crypto.JsonWebSignature import org.multipaz.crypto.SignatureVerificationException import org.multipaz.presentment.TransactionData +import org.multipaz.presentment.TransactionProtocol import org.multipaz.util.fromBase64Url import org.multipaz.util.toBase64Url @@ -93,35 +95,46 @@ class SdJwtKb private constructor( throw IllegalStateException("Failed verification of creationTime") } + val isIso18013_5 = transactionData.any { it.protocol == TransactionProtocol.ISO_18013_5 } val hashes = jwtBody["transaction_data_hashes"] - if (hashes == null) { - if (transactionData.isNotEmpty()) { - throw IllegalStateException("Transaction data was not processed") + if (isIso18013_5) { + if (hashes != null) { + throw IllegalStateException("Unexpected 'transaction_data_hashes' in ISO 18013-5 presentation") + } + for (transaction in transactionData) { + val responseClaims = jwtBody[transaction.type.kbJwtResponseClaimName]?.jsonObject ?: emptyMap() + transaction.verifySdJwtResponse(responseClaims) } } else { - hashes as? JsonArray - ?: throw IllegalStateException("Invalid 'transaction_data_hashes'") - if (hashes.size != transactionData.size) { - if (transactionData.isEmpty()) { - throw IllegalStateException("Unexpected 'transaction_data_hashes'") - } else { - throw IllegalStateException("Unexpected 'transaction_data_hashes' size") + if (hashes == null) { + if (transactionData.isNotEmpty()) { + throw IllegalStateException("Transaction data was not processed") } - } - val hashAlgorithm = try { - jwtBody["transaction_data_hashes_alg"]?.jsonPrimitive?.content?.let { - Algorithm.fromHashAlgorithmIdentifier(it) - } ?: Algorithm.SHA256 - } catch (err: Exception) { - throw IllegalStateException("Unknown or invalid transaction data hash algorithm", err) - } - transactionData.zip(hashes).forEach { (transaction, hash) -> - if (hash !is JsonPrimitive || !hash.isString) { - throw IllegalStateException("Invalid transaction data hash value") + } else { + hashes as? JsonArray + ?: throw IllegalStateException("Invalid 'transaction_data_hashes'") + if (hashes.size != transactionData.size) { + if (transactionData.isEmpty()) { + throw IllegalStateException("Unexpected 'transaction_data_hashes'") + } else { + throw IllegalStateException("Unexpected 'transaction_data_hashes' size") + } + } + val hashAlgorithm = try { + jwtBody["transaction_data_hashes_alg"]?.jsonPrimitive?.content?.let { + Algorithm.fromHashAlgorithmIdentifier(it) + } ?: Algorithm.SHA256 + } catch (err: Exception) { + throw IllegalStateException("Unknown or invalid transaction data hash algorithm", err) } - val responseHash = ByteString(hash.content.fromBase64Url()) - if (transaction.computeHash(hashAlgorithm) != responseHash) { - throw IllegalStateException("Transaction data hash mismatch") + transactionData.zip(hashes).forEach { (transaction, hash) -> + if (hash !is JsonPrimitive || !hash.isString) { + throw IllegalStateException("Invalid transaction data hash value") + } + val responseHash = ByteString(hash.content.fromBase64Url()) + if (transaction.computeHash(hashAlgorithm) != responseHash) { + throw IllegalStateException("Transaction data hash mismatch") + } } } } diff --git a/multipaz/src/commonMain/kotlin/org/multipaz/verification/VerificationUtil.kt b/multipaz/src/commonMain/kotlin/org/multipaz/verification/VerificationUtil.kt index a0dea979dd..16d6aa7ead 100644 --- a/multipaz/src/commonMain/kotlin/org/multipaz/verification/VerificationUtil.kt +++ b/multipaz/src/commonMain/kotlin/org/multipaz/verification/VerificationUtil.kt @@ -418,14 +418,25 @@ object VerificationUtil { addDocRequest( docType = docType, nameSpaces = itemsToRequest, - docRequestInfo = if (isVersion10) null else DocRequestInfo( - zkRequest = zkRequest, - docFormat = docFormat, - dataElementIdentifierMapping = dataElementIdentifierMapping, - transactions = cborTransactionData, - otherInfo = docRequestOtherInfo, - issuerIdentifiers = issuerIdentifiers - ), + docRequestInfo = if (isVersion10) { + if (cborTransactionData != null || docRequestOtherInfo.isNotEmpty()) { + DocRequestInfo( + transactionData = cborTransactionData, + otherInfo = docRequestOtherInfo + ) + } else { + null + } + } else { + DocRequestInfo( + zkRequest = zkRequest, + docFormat = docFormat, + dataElementIdentifierMapping = dataElementIdentifierMapping, + transactionData = cborTransactionData, + otherInfo = docRequestOtherInfo, + issuerIdentifiers = issuerIdentifiers + ) + }, readerKey = readerKey ) if (!isVersion10) { @@ -970,6 +981,8 @@ object VerificationUtil { } else { buildMap { for (transaction in transactionData) { + val responseClaims = sdJwtKb.jwtBody[transaction.type.kbJwtResponseClaimName]?.jsonObject ?: emptyMap() + transaction.verifySdJwtResponse(responseClaims) sdJwtKb.jwtBody[transaction.type.kbJwtResponseClaimName]?.let { put(transaction.type.identifier, it) } @@ -1304,7 +1317,7 @@ object VerificationUtil { }).mapValues { (_, transactionData) -> TransactionsInfo( data = transactionData.associate { data -> - data.type.mdocRequestInfoIdentifier to data.convertToCbor() + data.type.iso18013RequestInfoIdentifier to data.serializeIso18013Request() } ) } diff --git a/multipaz/src/commonTest/kotlin/org/multipaz/presentment/CredentialQueryResultTest.kt b/multipaz/src/commonTest/kotlin/org/multipaz/presentment/CredentialQueryResultTest.kt index c8c3b41c8f..209300d9ba 100644 --- a/multipaz/src/commonTest/kotlin/org/multipaz/presentment/CredentialQueryResultTest.kt +++ b/multipaz/src/commonTest/kotlin/org/multipaz/presentment/CredentialQueryResultTest.kt @@ -4,14 +4,27 @@ import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject +import kotlinx.io.bytestring.ByteString +import org.multipaz.cbor.DataItem import org.multipaz.cbor.Tstr +import org.multipaz.cbor.buildCborArray import org.multipaz.cbor.toDataItem import org.multipaz.cbor.toDataItemFullDate +import org.multipaz.crypto.Algorithm +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE +import org.multipaz.documenttype.TransactionType import org.multipaz.documenttype.knowntypes.DrivingLicense +import org.multipaz.documenttype.knowntypes.PaymentTransaction import org.multipaz.documenttype.knowntypes.PhotoID +import org.multipaz.mdoc.response.Iso18015ResponseException +import org.multipaz.mdoc.request.TransactionsInfo +import org.multipaz.mdoc.request.buildDeviceRequestFromDcql import org.multipaz.openid.dcql.DcqlQuery +import org.multipaz.utopia.knowntypes.DigitalPaymentCredential +import org.multipaz.utopia.knowntypes.PingTransaction import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class CredentialQueryResultTest { @@ -154,4 +167,269 @@ class CredentialQueryResultTest { selections.prettyPrint() ) } + + @Test + fun testTransactionApplicabilityFailure() = runTest { + val harness = DocumentStoreTestHarness() + harness.initialize() + // Provision mdoc without keyAuthorizedNamespaces + harness.provisionMdoc( + displayName = "my-mDL", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("David") + ) + ), + keyAuthorizedNamespaces = emptyList() + ) + + val pingTransactionData = PingTransaction.serializeIso18013Request(PingTransaction.Payload("hello", null)) + val deviceRequest = buildDeviceRequestFromDcql( + sessionTranscript = buildCborArray { add("session"); add("transcript") }, + dcqlString = """ + { + "credentials": [ + { + "id": "mdl", + "format": "mso_mdoc", + "meta": { + "doctype_value": "${DrivingLicense.MDL_DOCTYPE}" + }, + "claims": [ + {"id": "a", "path": ["${DrivingLicense.MDL_NAMESPACE}", "given_name"]} + ] + } + ] + } + """.trimIndent(), + transactions = mapOf( + "mdl" to TransactionsInfo(mapOf(PingTransaction.iso18013RequestInfoIdentifier to pingTransactionData)) + ) + ) + + val exception = assertFailsWith(Iso18015ResponseException::class) { + deviceRequest.execute(presentmentSource = harness.presentmentSource) + } + assertEquals( + "No credentials match required UseCase: transaction org.multipaz.transaction.ping is not applicable", + exception.message + ) + } + + @Test + fun testTransactionApplicabilitySuccess() = runTest { + val harness = DocumentStoreTestHarness() + harness.initialize() + // Provision mdoc with PingTransaction authorized + harness.provisionMdoc( + displayName = "my-mDL", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("David") + ) + ), + keyAuthorizedNamespaces = listOf(ISO_18013_TRANSACTION_DATA_NAMESPACE) + ) + + val pingTransactionData = PingTransaction.serializeIso18013Request(PingTransaction.Payload("hello", null)) + val deviceRequest = buildDeviceRequestFromDcql( + sessionTranscript = buildCborArray { add("session"); add("transcript") }, + dcqlString = """ + { + "credentials": [ + { + "id": "mdl", + "format": "mso_mdoc", + "meta": { + "doctype_value": "${DrivingLicense.MDL_DOCTYPE}" + }, + "claims": [ + {"id": "a", "path": ["${DrivingLicense.MDL_NAMESPACE}", "given_name"]} + ] + } + ] + } + """.trimIndent(), + transactions = mapOf( + "mdl" to TransactionsInfo(mapOf(PingTransaction.iso18013RequestInfoIdentifier to pingTransactionData)) + ) + ) + + val result = deviceRequest.execute(presentmentSource = harness.presentmentSource) + assertEquals(1, result.credentialSets.size) + assertEquals(1, result.credentialSets[0].options.size) + assertEquals(1, result.credentialSets[0].options[0].members.size) + val member = result.credentialSets[0].options[0].members[0] + assertEquals(1, member.matches.size) + assertEquals(1, member.matches[0].transactionData.size) + assertEquals(PingTransaction, member.matches[0].transactionData[0].type) + } + + private object DummyPaymentTransaction: TransactionType( + displayName = "Payment", + identifier = "payment_transaction", + ) { + override fun serializeOpenId4VpRequest(payload: String, credentialIds: List, hashAlgorithms: List?): String = "" + override fun serializeIso18013Request(payload: String): DataItem = Tstr(payload) + override fun parseOpenId4VpRequest(jsonString: String): String = throw NotImplementedError() + override fun parseIso18013Request(dataItem: DataItem): String = dataItem.asTstr + } + + @Test + fun testTransactionApplicabilityFailurePaymentTransactionIdentifier() = runTest { + val harness = DocumentStoreTestHarness() + harness.initialize() + harness.documentTypeRepository.addTransactionType(DummyPaymentTransaction) + // Provision mdoc without keyAuthorizedNamespaces + harness.provisionMdoc( + displayName = "my-mDL", + docType = DrivingLicense.MDL_DOCTYPE, + data = mapOf( + DrivingLicense.MDL_NAMESPACE to listOf( + "given_name" to Tstr("David") + ) + ), + keyAuthorizedNamespaces = emptyList() + ) + + val transactionData = DummyPaymentTransaction.serializeIso18013Request("hello") + val deviceRequest = buildDeviceRequestFromDcql( + sessionTranscript = buildCborArray { add("session"); add("transcript") }, + dcqlString = """ + { + "credentials": [ + { + "id": "mdl", + "format": "mso_mdoc", + "meta": { + "doctype_value": "${DrivingLicense.MDL_DOCTYPE}" + }, + "claims": [ + {"id": "a", "path": ["${DrivingLicense.MDL_NAMESPACE}", "given_name"]} + ] + } + ] + } + """.trimIndent(), + transactions = mapOf( + "mdl" to TransactionsInfo(mapOf(DummyPaymentTransaction.iso18013RequestInfoIdentifier to transactionData)) + ) + ) + + val exception = assertFailsWith(Iso18015ResponseException::class) { + deviceRequest.execute(presentmentSource = harness.presentmentSource) + } + assertEquals( + "No credentials match required UseCase: transaction payment_transaction is not applicable", + exception.message + ) + } + + @Test + fun testPaymentTransactionApplicabilityFailure() = runTest { + val harness = DocumentStoreTestHarness() + harness.initialize() + harness.documentTypeRepository.addDocumentType(DigitalPaymentCredential.getDocumentType()) + harness.documentTypeRepository.addTransactionType(PaymentTransaction) + + // Provision payment card mdoc without keyAuthorizedNamespaces + harness.provisionMdoc( + displayName = "Erika's Payment Card Credential", + docType = DigitalPaymentCredential.CARD_DOCTYPE, + data = mapOf( + DigitalPaymentCredential.CARD_NAMESPACE to listOf( + "card_number" to Tstr("1234567812345678") + ) + ), + keyAuthorizedNamespaces = emptyList() + ) + + val paymentTransactionData = PaymentTransaction.serializeIso18013Request( + PaymentTransaction.sampleData.payload + ) + val deviceRequest = buildDeviceRequestFromDcql( + sessionTranscript = buildCborArray { add("session"); add("transcript") }, + dcqlString = """ + { + "credentials": [ + { + "id": "card", + "format": "mso_mdoc", + "meta": { + "doctype_value": "${DigitalPaymentCredential.CARD_DOCTYPE}" + }, + "claims": [ + {"id": "a", "path": ["${DigitalPaymentCredential.CARD_NAMESPACE}", "card_number"]} + ] + } + ] + } + """.trimIndent(), + transactions = mapOf( + "card" to TransactionsInfo(mapOf(PaymentTransaction.iso18013RequestInfoIdentifier to paymentTransactionData)) + ) + ) + + val exception = assertFailsWith(Iso18015ResponseException::class) { + deviceRequest.execute(presentmentSource = harness.presentmentSource) + } + assertEquals( + "No credentials match required UseCase: transaction urn:eudi:sca:payment:1 is not applicable", + exception.message + ) + } + + @Test + fun testPaymentTransactionApplicabilitySuccess() = runTest { + val harness = DocumentStoreTestHarness() + harness.initialize() + harness.documentTypeRepository.addDocumentType(DigitalPaymentCredential.getDocumentType()) + harness.documentTypeRepository.addTransactionType(PaymentTransaction) + + harness.provisionMdoc( + displayName = "Erika's Payment Card Credential", + docType = DigitalPaymentCredential.CARD_DOCTYPE, + data = mapOf( + DigitalPaymentCredential.CARD_NAMESPACE to listOf( + "card_number" to Tstr("1234567812345678") + ) + ), + keyAuthorizedNamespaces = listOf(ISO_18013_TRANSACTION_DATA_NAMESPACE) + ) + + val paymentTransactionData = PaymentTransaction.serializeIso18013Request( + PaymentTransaction.sampleData.payload + ) + val deviceRequest = buildDeviceRequestFromDcql( + sessionTranscript = buildCborArray { add("session"); add("transcript") }, + dcqlString = """ + { + "credentials": [ + { + "id": "card", + "format": "mso_mdoc", + "meta": { + "doctype_value": "${DigitalPaymentCredential.CARD_DOCTYPE}" + }, + "claims": [ + {"id": "a", "path": ["${DigitalPaymentCredential.CARD_NAMESPACE}", "card_number"]} + ] + } + ] + } + """.trimIndent(), + transactions = mapOf( + "card" to TransactionsInfo(mapOf(PaymentTransaction.iso18013RequestInfoIdentifier to paymentTransactionData)) + ) + ) + + val result = deviceRequest.execute(presentmentSource = harness.presentmentSource) + assertEquals(1, result.credentialSets.size) + val member = result.credentialSets[0].options[0].members[0] + assertEquals(1, member.matches.size) + assertEquals(1, member.matches[0].transactionData.size) + assertEquals(PaymentTransaction, member.matches[0].transactionData[0].type) + } } \ No newline at end of file diff --git a/multipaz/src/commonTest/kotlin/org/multipaz/presentment/DigitalCredentialsPresentmentTest.kt b/multipaz/src/commonTest/kotlin/org/multipaz/presentment/DigitalCredentialsPresentmentTest.kt index 5d73eee9b3..df1898d410 100644 --- a/multipaz/src/commonTest/kotlin/org/multipaz/presentment/DigitalCredentialsPresentmentTest.kt +++ b/multipaz/src/commonTest/kotlin/org/multipaz/presentment/DigitalCredentialsPresentmentTest.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonArray @@ -18,12 +19,17 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.buildJsonArray import org.multipaz.asn1.ASN1Integer +import org.multipaz.cbor.ByteStringFormat import org.multipaz.cbor.Cbor +import org.multipaz.cbor.Cdn +import org.multipaz.cbor.CdnGeneratorOptions import org.multipaz.cbor.DataItem import org.multipaz.cbor.DiagnosticOption import org.multipaz.cbor.Simple import org.multipaz.cbor.Tagged +import org.multipaz.cbor.Tstr import org.multipaz.cbor.Uint import org.multipaz.cbor.addCborArray import org.multipaz.cbor.buildCborArray @@ -40,36 +46,51 @@ import org.multipaz.crypto.JsonWebEncryption import org.multipaz.crypto.X500Name import org.multipaz.crypto.X509CertChain import org.multipaz.document.Document +import org.multipaz.document.DocumentBadge +import org.multipaz.document.DocumentStore +import org.multipaz.documenttype.DocumentTypeRepository +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.TransactionType import org.multipaz.documenttype.TransactionUserInput +import org.multipaz.documenttype.knowntypes.PaymentTransaction +import org.multipaz.mdoc.devicesigned.DeviceAuth +import org.multipaz.mdoc.request.DocRequestInfo +import org.multipaz.mdoc.request.TransactionsInfo +import org.multipaz.mdoc.request.buildDeviceRequest import org.multipaz.mdoc.response.DeviceResponse import org.multipaz.mdoc.util.MdocUtil import org.multipaz.openid.OpenID4VP import org.multipaz.prompt.promptModelSilentConsent +import org.multipaz.request.RequestedClaim import org.multipaz.request.Requester +import org.multipaz.request.RequesterIdentity +import org.multipaz.request.TrustedRequesterIdentity import org.multipaz.sdjwt.SdJwtKb import org.multipaz.trustmanagement.TrustPoint import org.multipaz.util.Logger import org.multipaz.util.fromBase64Url import org.multipaz.util.toBase64Url +import org.multipaz.util.toHex +import org.multipaz.util.zlibInflate import org.multipaz.verification.VerifierIdentity import kotlin.collections.iterator import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.time.Clock class DigitalCredentialsPresentmentTest { internal abstract class BooleanTransaction( displayName: String, identifier: String, kbJwtResponseClaimName: String = identifier, - mdocResponseNamespace: String = identifier + openId4VpMdocResponseNamespace: String = identifier, ): TransactionType( displayName = displayName, identifier = identifier, kbJwtResponseClaimName = kbJwtResponseClaimName, - mdocResponseNamespace = mdocResponseNamespace + openId4VpMdocResponseNamespace = openId4VpMdocResponseNamespace, ) { @Serializable data class JsonData( @@ -79,26 +100,14 @@ class DigitalCredentialsPresentmentTest { val succeed: Boolean ) - override fun serializeCbor( - payload: Boolean, - hashAlgorithms: List? - ): DataItem = Tagged( - tagNumber = Tagged.ENCODED_CBOR, - taggedItem = Cbor.encode( - item = buildCborMap { - put("succeed", payload) - coseHashAlgorithms(hashAlgorithms)?.let { algs -> - putCborArray("transactionDataHashesAlg") { - for (alg in algs) { - add(alg) - } - } - } - } - ).toDataItem() - ) + override fun serializeIso18013Request(payload: Boolean): DataItem = buildCborMap { + put("succeed", payload) + } + + override fun parseIso18013Request(dataItem: DataItem): Boolean = + dataItem["succeed"].asBoolean - override fun serializeJson( + override fun serializeOpenId4VpRequest( payload: Boolean, credentialIds: List, hashAlgorithms: List? @@ -111,33 +120,22 @@ class DigitalCredentialsPresentmentTest { ) ) + override fun parseOpenId4VpRequest(jsonString: String): Boolean = + jsonFormat.decodeFromString(jsonString).succeed + + override fun parseJson(serialized: ByteString): TransactionData { val jsonString = serialized.decodeToString().fromBase64Url().decodeToString() val data = jsonFormat.decodeFromString(jsonString) return TransactionData( type = this, - serialized = serialized, - hashAlgorithms = parseJoseHashAlgorithms(data.transactionDataHashesAlg), payload = data.succeed, + protocol = TransactionProtocol.OPENID4VP, + rawBytes = serialized, + hashAlgorithms = parseJoseHashAlgorithms(data.transactionDataHashesAlg), ) } - override fun parseCbor(serialized: DataItem): TransactionData { - val data = serialized.asTaggedEncodedCbor - return TransactionData( - type = this, - serialized = ByteString(serialized.asTagged.asBstr), - hashAlgorithms = if (data.hasKey("transactionDataHashesAlg")) { - parseCoseHashAlgorithms( - data["transactionDataHashesAlg"].asArray.map { - it.asNumber - }) - } else { - null - }, - payload = data["succeed"].asBoolean, - ) - } override suspend fun isApplicable( transactionData: TransactionData, @@ -158,8 +156,7 @@ class DigitalCredentialsPresentmentTest { private object FooTransactionType: BooleanTransaction( displayName = "Foo", identifier = "foo", - kbJwtResponseClaimName = "kb_foo", - mdocResponseNamespace = "FooNS" + kbJwtResponseClaimName = "kb_foo" ) { override suspend fun isApplicable( transactionData: TransactionData, @@ -168,23 +165,26 @@ class DigitalCredentialsPresentmentTest { return transactionData.payload && super.isApplicable(transactionData, credential) } - override suspend fun applyJson( + override suspend fun generateMdocResponseElements( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): JsonElement = buildJsonObject { + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { check(userInput == null) - put("result", 42) + putAll(super.generateMdocResponseElements(transactionData, credential, userInput, docRequestId)) + put("result", Uint(42UL)) } - override suspend fun applyCbor( + override suspend fun generateSdJwtResponseClaims( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): Map = buildMap { + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { check(userInput == null) - putAll(super.applyCbor(transactionData, credential, userInput)) - put("result", Uint(42UL)) + putAll(super.generateSdJwtResponseClaims(transactionData, credential, userInput, docRequestId)) + put("result", JsonPrimitive(42)) } } @@ -192,23 +192,26 @@ class DigitalCredentialsPresentmentTest { displayName = "Bar", identifier = "bar" ) { - override suspend fun applyJson( + override suspend fun generateMdocResponseElements( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): JsonElement = buildJsonObject { + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { check(userInput == null) - put("result", 57) + putAll(super.generateMdocResponseElements(transactionData, credential, userInput, docRequestId)) + put("result", Uint(57UL)) } - override suspend fun applyCbor( + override suspend fun generateSdJwtResponseClaims( transactionData: TransactionData, credential: Credential, - userInput: TransactionUserInput? - ): Map = buildMap { + userInput: TransactionUserInput?, + docRequestId: Int? + ): Map = buildMap { check(userInput == null) - putAll(super.applyCbor(transactionData, credential, userInput)) - put("result", Uint(57UL)) + putAll(super.generateSdJwtResponseClaims(transactionData, credential, userInput, docRequestId)) + put("result", JsonPrimitive(57)) } } @@ -236,13 +239,86 @@ class DigitalCredentialsPresentmentTest { documentStoreTestHarness.initialize() documentStoreTestHarness.provisionStandardDocuments( keyAuthorizedNamespaces = listOf( - FooTransactionType.mdocResponseNamespace, - BarTransactionType.mdocResponseNamespace, - BuzTransactionType.mdocResponseNamespace + ISO_18013_TRANSACTION_DATA_NAMESPACE, + "foo", + "bar" ) ) documentStoreTestHarness.documentTypeRepository.addTransactionType(FooTransactionType) documentStoreTestHarness.documentTypeRepository.addTransactionType(BarTransactionType) + documentStoreTestHarness.documentTypeRepository.addTransactionType(PaymentTransaction) + documentStoreTestHarness.provisionMdoc( + displayName = "Payment Card Mdoc", + docType = "org.multipaz.payment.sca.1", + data = mapOf( + "org.multipaz.payment.sca.1" to listOf( + Pair("account_id", Tstr("acc-12345")) + ) + ), + keyAuthorizedNamespaces = listOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE, + PaymentTransaction.openId4VpMdocResponseNamespace + ) + ) + documentStoreTestHarness.provisionSdJwtVc( + displayName = "Payment Card SD-JWT", + vct = "org.multipaz.payment.sca.1", + data = listOf( + Pair("account_id", JsonPrimitive("acc-12345")) + ) + ) + } + + private class TipPresentmentSource( + documentStore: DocumentStore, + documentTypeRepository: DocumentTypeRepository, + ) : PresentmentSource( + documentStore = documentStore, + documentTypeRepository = documentTypeRepository + ) { + var tipPercent: Double? = null + + fun insertTip(tipPercent: Double) { + this.tipPercent = tipPercent + } + + private val delegate = SimplePresentmentSource( + documentStore = documentStore, + documentTypeRepository = documentTypeRepository, + preferSignatureToKeyAgreement = true, + domainsMdocSignature = listOf("mdoc"), + domainsKeyBoundSdJwt = listOf("sdjwt") + ) + + override suspend fun resolveTrust(requester: Requester): TrustedRequesterIdentity? = + delegate.resolveTrust(requester) + + override suspend fun showConsentPrompt( + requester: Requester, + trustedRequesterIdentity: TrustedRequesterIdentity?, + consentData: ConsentData, + preselectedDocuments: List, + onDocumentsInFocus: (documents: List) -> Unit + ): CredentialSelection? { + val ret = consentData.credentialQueryResult.select(preselectedDocuments) + onDocumentsInFocus(ret.matches.map { it.credential.document }) + val userInputMap = mutableMapOf() + tipPercent?.let { tip -> + userInputMap[PaymentTransaction.identifier] = PaymentTransaction.UserInput(tipPercent = tip) + } + return ret.copy(transactionUserInput = userInputMap) + } + + override suspend fun getBadges(document: Document): List = + delegate.getBadges(document) + + override suspend fun selectCredential( + document: Document, + requestedClaims: List, + keyAgreementPossible: List, + credential: Credential?, + ): Credential? = + delegate.selectCredential(document, requestedClaims, keyAgreementPossible, credential) } private data class ShownConsentPrompt( @@ -588,6 +664,40 @@ class DigitalCredentialsPresentmentTest { ) } + suspend fun test_OID4VP_mDL_noClaims( + versionDraftNumber: Int, + signRequest: Boolean, + encryptResponse: Boolean, + ) { + val version = when (versionDraftNumber) { + 24 -> OpenID4VP.Version.DRAFT_24 + 29 -> OpenID4VP.Version.DRAFT_29 + else -> throw IllegalArgumentException("Unknown draft number") + } + val encryptionKey = if (encryptResponse) Crypto.createEcPrivateKey(EcCurve.P256) else null + test_OpenID4VP_mdoc( + version = version, + signRequest = signRequest, + encryptionKey = encryptionKey, + dcql = + """ + { + "credentials": [{ + "id": "mDL", + "format": "mso_mdoc", + "meta": { "doctype_value": "org.iso.18013.5.1.mDL" } + }]} + """.trimIndent().trim(), + transactionData = listOf(), + expectedMdocResponse = + """ + Document 0: + DocType: org.iso.18013.5.1.mDL + IssuerSigned: + """.trimIndent().trim(), + ) + } + suspend fun test_OID4VP_mDL_withTransaction( versionDraftNumber: Int, signRequest: Boolean, @@ -629,7 +739,7 @@ class DigitalCredentialsPresentmentTest { age_over_21: true portrait: 5318 bytes DeviceNamespaces: - FooNS: + foo: transactionDataHash: 32 bytes result: 42 bar: @@ -857,6 +967,7 @@ class DigitalCredentialsPresentmentTest { @Test fun OID4VP_29_NoSignedRequest_EncryptedResponse_mDL() = runTestWithSetup { test_OID4VP_mDL(29, false, true) } @Test fun OID4VP_29_SignedRequest_NoEncryptedResponse_mDL() = runTestWithSetup { test_OID4VP_mDL(29, true, false) } @Test fun OID4VP_29_SignedRequest_EncryptedResponse_mDL() = runTestWithSetup { test_OID4VP_mDL(29, true, true) } + @Test fun OID4VP_29_NoSignedRequest_NoEncryptedResponse_mDL_noClaims() = runTestWithSetup { test_OID4VP_mDL_noClaims(29, false, false) } @Test fun OID4VP_29_NoSignedRequest_NoEncryptedResponse_mDL_withTransaction() = runTestWithSetup { test_OID4VP_mDL_withTransaction(29, false, false) } @Test fun OID4VP_29_NoSignedRequest_EncryptedResponse_mDL_withTransaction() = runTestWithSetup { test_OID4VP_mDL_withTransaction(29, false, true ) } @@ -876,6 +987,764 @@ class DigitalCredentialsPresentmentTest { @Test fun OID4VP_29_SignedRequest_EncryptedResponse_SDJWT_unknownTransaction() = runTestWithSetup { test_OID4VP_SDJWT_unknownTransaction(29, true, true) } @Test fun OID4VP_29_SignedRequest_EncryptedResponse_SDJWT_failingTransaction() = runTestWithSetup { test_OID4VP_SDJWT_failingTransaction(29, true, true) } + + // ----------------------------------------------------------------------------------------- + // PaymentTransaction End-to-End Tests: (ISO 18013-5 vs OpenID4VP) x (ISO mdoc vs SD-JWT VC) + // ----------------------------------------------------------------------------------------- + + @Test + fun payment_Iso18013_IsoMdoc() = runTestWithSetup { + val sessionTranscript = buildCborArray { add(Simple.NULL); add(Simple.NULL); add(byteArrayOf(1, 2, 3)) } + val source = TipPresentmentSource( + documentStore = documentStoreTestHarness.documentStore, + documentTypeRepository = documentStoreTestHarness.documentTypeRepository, + ) + source.insertTip(20.0) + + // 1. Verifier prepares entire DeviceRequest in ISO 18013-5 + val payload = PaymentTransaction.sampleData.payload + val requestDataItem = PaymentTransaction.serializeIso18013Request(payload) + val deviceRequest = buildDeviceRequest(sessionTranscript = sessionTranscript) { + addDocRequest( + docType = "org.multipaz.payment.sca.1", + nameSpaces = mapOf( + "org.multipaz.payment.sca.1" to mapOf("account_id" to false) + ), + docRequestInfo = DocRequestInfo( + transactionData = TransactionsInfo( + data = mapOf(PaymentTransaction.identifier to requestDataItem) + ) + ) + ) + } + + // Pretty-printed entire DeviceRequest (Concise Diagnostic Notation) + val requestCdn = Cdn.encode( + item = deviceRequest.toDataItem(), + options = CdnGeneratorOptions.Pretty + ) + val expectedRequestCdn = """ + { + "version": "1.1", + "docRequests": [ + { + "itemsRequest": 24(<< { + "docType": "org.multipaz.payment.sca.1", + "nameSpaces": { + "org.multipaz.payment.sca.1": { + "account_id": false + }, + "org.iso.transactiondata": { + "urn:eudi:sca:payment:1": true + } + }, + "requestInfo": { + "transactionData": { + "urn:eudi:sca:payment:1": { + "transactionId": "3AD99006-6E0D-4D07-AE75-5DAEF0FE21D9", + "currency": "USD", + "amount": 123.25, + "payee": { + "name": "Linux Foundation", + "id": "01234" + }, + "tipRequested": true + } + } + } + } >>) + } + ] + } + """.trimIndent() + assertEquals(expectedRequestCdn, requestCdn.trim()) + + // 2. Wallet presentment + val creationTime = Clock.System.now() + val isoResponse = mdocPresentment( + deviceRequest = deviceRequest, + eReaderKey = null, + sessionTranscript = sessionTranscript, + source = source, + keyAgreementPossible = emptyList(), + requesterAppId = null, + requesterOrigin = ORIGIN, + creationTime = creationTime, + preselectedDocuments = emptyList(), + onWaitingForUserInput = {}, + onDocumentsInFocus = {} + ) + val deviceResponse = isoResponse.deviceResponse + + // 3. Verifier verifies response + deviceResponse.verify( + sessionTranscript = sessionTranscript, + eReaderKey = null, + deviceRequest = deviceRequest, + documentTypeRepository = source.documentTypeRepository, + atTime = creationTime + ) + assertEquals(DeviceResponse.STATUS_OK, deviceResponse.status) + assertEquals(1, deviceResponse.documents.size) + val mdocDoc = deviceResponse.documents[0] + val txElements = mdocDoc.deviceNamespaces.data[ISO_18013_TRANSACTION_DATA_NAMESPACE]!![PaymentTransaction.identifier]!! + assertEquals(0L, txElements["docRequestId"].asNumber) + assertEquals(123.25, txElements["amount"].asDouble) + assertEquals("USD", txElements["currency"].asTstr) + assertEquals(24.65, txElements["tipAmount"].asDouble) + + // 4. Pretty-printed entire DeviceResponse (Concise Diagnostic Notation) + val responseCdn = Cdn.encode( + item = deviceResponse.toDataItem(), + options = CdnGeneratorOptions.Pretty + ) + val expectedResponseCdn = """ + { + "version": "1.0", + "status": 0, + "documents": [ + { + "docType": "org.multipaz.payment.sca.1", + "issuerSigned": { + "issuerAuth": [ # COSE_Sign1 + /protected/ << { + /alg/ 1: -7 # ES256: ECDSA with SHA-256 + } >>, + /unprotected/ { + /x5chain/ 33: + # Subject DN: C=US,CN=OWF Multipaz TEST DS + # Issuer DN: C=US,CN=OWF Multipaz TEST IACA + cert'''...''' + }, + /payload/ << 24(<< { + "version": "1.0", + "digestAlgorithm": "SHA-256", + "docType": "org.multipaz.payment.sca.1", + "valueDigests": { + "org.multipaz.payment.sca.1": { + 0: h'...' + } + }, + "deviceKeyInfo": { + "deviceKey": { # COSE_Key + /kty/ 1: 2, # EC2 + /crv/ -1: 1, # P-256 + /x/ -2: h'...', + /y/ -3: h'...' + }, + "keyAuthorizations": { + "nameSpaces": [ + "org.iso.transactiondata", + "urn:eudi:sca:payment:1" + ] + } + }, + "validityInfo": { + "signed": dt'...', + "validFrom": dt'...', + "validUntil": dt'...' + } + } >>) >>, + /signature/ h'...' + ], + "nameSpaces": { + "org.multipaz.payment.sca.1": [ + 24(<< { + "digestID": 0, + "random": h'...', + "elementIdentifier": "account_id", + "elementValue": "acc-12345" + } >>) + ] + } + }, + "deviceSigned": { + "deviceAuth": { + "deviceSignature": [ # COSE_Sign1 + /protected/ << { + /alg/ 1: -7 # ES256: ECDSA with SHA-256 + } >>, + /unprotected/ {}, + /payload/ null, + /signature/ h'...' + ] + }, + "nameSpaces": 24(<< { + "org.iso.transactiondata": { + "urn:eudi:sca:payment:1": { + "tipAmount": 24.65, + "docRequestId": 0, + "amount": 123.25, + "currency": "USD" + } + } + } >>) + } + } + ] + } + """.trimIndent() + assertEquals(expectedResponseCdn, normalizeMdocResponseCdn(responseCdn.trim())) + } + + @Test + fun payment_Iso18013_SdJwtVc() = runTestWithSetup { + val sessionTranscript = buildCborArray { add(Simple.NULL); add(Simple.NULL); add(byteArrayOf(1, 2, 3)) } + val source = TipPresentmentSource( + documentStore = documentStoreTestHarness.documentStore, + documentTypeRepository = documentStoreTestHarness.documentTypeRepository, + ) + source.insertTip(20.0) + + // 1. Verifier prepares entire DeviceRequest in ISO 18013-5 requesting SD-JWT VC + val payload = PaymentTransaction.sampleData.payload + val requestDataItem = PaymentTransaction.serializeIso18013Request(payload) + val deviceRequest = buildDeviceRequest(sessionTranscript = sessionTranscript) { + addDocRequest( + docType = "org.multipaz.payment.sca.1", + nameSpaces = mapOf( + "_" to mapOf("sdjwtvc_account_id" to false) + ), + docRequestInfo = DocRequestInfo( + docFormat = "dc+sd-jwt", + dataElementIdentifierMapping = mapOf( + "sdjwtvc_account_id" to buildJsonArray { add("account_id") } + ), + transactionData = TransactionsInfo( + data = mapOf(PaymentTransaction.identifier to requestDataItem) + ) + ) + ) + } + + // Pretty-printed entire DeviceRequest (Concise Diagnostic Notation) + val requestCdn = Cdn.encode( + item = deviceRequest.toDataItem(), + options = CdnGeneratorOptions.Pretty + ) + val expectedRequestCdn = """ + { + "version": "1.1", + "docRequests": [ + { + "itemsRequest": 24(<< { + "docType": "org.multipaz.payment.sca.1", + "nameSpaces": { + "_": { + "sdjwtvc_account_id": false + }, + "org.iso.transactiondata": { + "urn:eudi:sca:payment:1": true + } + }, + "requestInfo": { + "docFormat": "dc+sd-jwt", + "dataElementIdentifierMapping": { + "sdjwtvc_account_id": [ + "account_id" + ] + }, + "transactionData": { + "urn:eudi:sca:payment:1": { + "transactionId": "3AD99006-6E0D-4D07-AE75-5DAEF0FE21D9", + "currency": "USD", + "amount": 123.25, + "payee": { + "name": "Linux Foundation", + "id": "01234" + }, + "tipRequested": true + } + } + } + } >>) + } + ] + } + """.trimIndent() + assertEquals(expectedRequestCdn, requestCdn.trim()) + + // 2. Wallet presentment + val creationTime = Clock.System.now() + val isoResponse = mdocPresentment( + deviceRequest = deviceRequest, + eReaderKey = null, + sessionTranscript = sessionTranscript, + source = source, + keyAgreementPossible = emptyList(), + requesterAppId = null, + requesterOrigin = ORIGIN, + creationTime = creationTime, + preselectedDocuments = emptyList(), + onWaitingForUserInput = {}, + onDocumentsInFocus = {} + ) + val deviceResponse = isoResponse.deviceResponse + + // 3. Verifier verifies response + deviceResponse.verify( + sessionTranscript = sessionTranscript, + eReaderKey = null, + deviceRequest = deviceRequest, + documentTypeRepository = source.documentTypeRepository, + atTime = creationTime + ) + + assertEquals(0, deviceResponse.documents.size) + assertEquals(1, deviceResponse.otherDocuments.size) + val otherDoc = deviceResponse.otherDocuments[0] + assertEquals("dc+sd-jwt", otherDoc.docFormat) + + // Pretty-printed entire DeviceResponse (Concise Diagnostic Notation) using LENGTH_ONLY + val responseCdn = Cdn.encode( + item = deviceResponse.toDataItem(), + options = CdnGeneratorOptions(prettyPrint = true, byteStringFormat = ByteStringFormat.LENGTH_ONLY) + ) + val expectedResponseCdn = """ + { + "version": "1.1", + "status": 0, + "otherDocuments": [ + { + "docFormat": "dc+sd-jwt", + "data": ${otherDoc.data.size} bytes + } + ] + } + """.trimIndent() + assertEquals(expectedResponseCdn, responseCdn.trim()) + + // Pretty-printed SD-JWT KB-JWT payload (JSON) + val decompressedData = otherDoc.data.toByteArray().zlibInflate() + val sdJwtKb = SdJwtKb.fromCompactSerialization(decompressedData.decodeToString()) + val prettyJson = Json { prettyPrint = true } + val kbJwtJson = prettyJson.encodeToString(sdJwtKb.jwtBody) + val sdHash = sdJwtKb.jwtBody["sd_hash"]!!.jsonPrimitive.content + val iat = sdJwtKb.jwtBody["iat"]!!.jsonPrimitive.content + val nonce = sdJwtKb.jwtBody["nonce"]!!.jsonPrimitive.content + val expectedKbJwtJson = """ + { + "iat": $iat, + "nonce": "$nonce", + "aud": "none", + "sd_hash": "$sdHash", + "urn:eudi:sca:payment:1": { + "tip_amount": 24.65, + "doc_request_id": 0, + "amount": 123.25, + "currency": "USD" + } + } + """.trimIndent() + assertEquals(expectedKbJwtJson, kbJwtJson.trim()) + } + + @Test + fun payment_OpenID4VP_SdJwtVc() = runTestWithSetup { + val source = TipPresentmentSource( + documentStore = documentStoreTestHarness.documentStore, + documentTypeRepository = documentStoreTestHarness.documentTypeRepository, + ) + source.insertTip(20.0) + + // 1. Verifier prepares DCQL query and transaction_data for OpenID4VP request + val dcql = buildJsonObject { + put("credentials", buildJsonArray { + add(buildJsonObject { + put("id", "payment_credential") + put("format", "dc+sd-jwt") + put("meta", buildJsonObject { + put("vct_values", buildJsonArray { add("org.multipaz.payment.sca.1") }) + }) + put("claims", buildJsonArray { + add(buildJsonObject { put("path", buildJsonArray { add("account_id") }) }) + }) + }) + }) + } + + val payload = PaymentTransaction.sampleData.payload + val requestJsonString = PaymentTransaction.serializeOpenId4VpRequest( + payload = payload, + credentialIds = listOf("payment_credential"), + hashAlgorithms = listOf(Algorithm.SHA256) + ) + + val prettyJson = Json { prettyPrint = true } + val dcqlJsonString = prettyJson.encodeToString(dcql) + val expectedDcqlJson = """ + { + "credentials": [ + { + "id": "payment_credential", + "format": "dc+sd-jwt", + "meta": { + "vct_values": [ + "org.multipaz.payment.sca.1" + ] + }, + "claims": [ + { + "path": [ + "account_id" + ] + } + ] + } + ] + } + """.trimIndent() + assertEquals(expectedDcqlJson, dcqlJsonString.trim()) + + val transactionDataJson = prettyJson.encodeToString(Json.parseToJsonElement(requestJsonString)) + val expectedTransactionDataJson = """ + { + "type": "urn:eudi:sca:payment:1", + "credential_ids": [ + "payment_credential" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], + "payload": { + "transaction_id": "3AD99006-6E0D-4D07-AE75-5DAEF0FE21D9", + "currency": "USD", + "amount": 123.25, + "payee": { + "name": "Linux Foundation", + "id": "01234" + }, + "tip_requested": true + } + } + """.trimIndent() + assertEquals(expectedTransactionDataJson, transactionDataJson.trim()) + + val nonce = "openid4vp-nonce-67890" + val request = OpenID4VP.generateRequest( + version = OpenID4VP.Version.DRAFT_29, + origin = ORIGIN, + nonce = nonce, + responseEncryptionKey = null, + verifierIdentities = emptyList(), + responseMode = OpenID4VP.ResponseMode.DC_API, + responseUri = null, + dcqlQuery = dcql, + jsonTransactionData = listOf(requestJsonString) + ) + + // 2. Wallet presentment + val response = OpenID4VP.generateResponse( + version = OpenID4VP.Version.DRAFT_29, + preselectedDocuments = emptyList(), + source = source, + appId = null, + origin = ORIGIN, + request = request, + requesterIdentities = emptyList(), + ) + + // 3. Verifier processes response + val vpTokens = response.response["vp_token"]!!.jsonObject + val compactSerialization = vpTokens["payment_credential"]!!.jsonArray[0].jsonPrimitive.content + val sdJwtKb = SdJwtKb.fromCompactSerialization(compactSerialization) + + val transactionData = source.documentTypeRepository.parseJsonTransactions( + base64UrlEncodedJson = listOf(requestJsonString.encodeToByteArray().toBase64Url()) + ).values.first() + + sdJwtKb.verify( + issuerKey = documentStoreTestHarness.dsKey.publicKey, + checkNonce = { it == nonce }, + checkAudience = { it == "origin:$ORIGIN" }, + checkCreationTime = { true }, + transactionData = transactionData + ) + + // 4. Pretty-printed SD-JWT + SD-JWT KB + val kbJwtJson = prettyJson.encodeToString(sdJwtKb.jwtBody) + val sdHash = sdJwtKb.jwtBody["sd_hash"]!!.jsonPrimitive.content + val iat = sdJwtKb.jwtBody["iat"]!!.jsonPrimitive.content + val expectedHash = transactionData.first().computeHash(Algorithm.SHA256).toByteArray().toBase64Url() + val expectedKbJwtJson = """ + { + "iat": $iat, + "nonce": "openid4vp-nonce-67890", + "aud": "origin:https://verifier.multipaz.org", + "sd_hash": "$sdHash", + "urn:eudi:sca:payment:1": { + "tip_amount": 24.65 + }, + "transaction_data_hashes_alg": "sha-256", + "transaction_data_hashes": [ + "$expectedHash" + ] + } + """.trimIndent() + assertEquals(expectedKbJwtJson, kbJwtJson.trim()) + } + + @Test + fun payment_OpenID4VP_IsoMdoc() = runTestWithSetup { + val source = TipPresentmentSource( + documentStore = documentStoreTestHarness.documentStore, + documentTypeRepository = documentStoreTestHarness.documentTypeRepository, + ) + source.insertTip(20.0) + + // 1. Verifier prepares DCQL query and transaction_data for OpenID4VP request + val dcql = buildJsonObject { + put("credentials", buildJsonArray { + add(buildJsonObject { + put("id", "payment_credential") + put("format", "mso_mdoc") + put("meta", buildJsonObject { + put("doctype_value", "org.multipaz.payment.sca.1") + }) + put("claims", buildJsonArray { + add(buildJsonObject { + put("path", buildJsonArray { + add("org.multipaz.payment.sca.1") + add("account_id") + }) + }) + }) + }) + }) + } + + val payload = PaymentTransaction.sampleData.payload + val requestJsonString = PaymentTransaction.serializeOpenId4VpRequest( + payload = payload, + credentialIds = listOf("payment_credential"), + hashAlgorithms = listOf(Algorithm.SHA256) + ) + + val prettyJson = Json { prettyPrint = true } + val dcqlJsonString = prettyJson.encodeToString(dcql) + val expectedDcqlJson = """ + { + "credentials": [ + { + "id": "payment_credential", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.multipaz.payment.sca.1" + }, + "claims": [ + { + "path": [ + "org.multipaz.payment.sca.1", + "account_id" + ] + } + ] + } + ] + } + """.trimIndent() + assertEquals(expectedDcqlJson, dcqlJsonString.trim()) + + val transactionDataJson = prettyJson.encodeToString(Json.parseToJsonElement(requestJsonString)) + val expectedTransactionDataJson = """ + { + "type": "urn:eudi:sca:payment:1", + "credential_ids": [ + "payment_credential" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], + "payload": { + "transaction_id": "3AD99006-6E0D-4D07-AE75-5DAEF0FE21D9", + "currency": "USD", + "amount": 123.25, + "payee": { + "name": "Linux Foundation", + "id": "01234" + }, + "tip_requested": true + } + } + """.trimIndent() + assertEquals(expectedTransactionDataJson, transactionDataJson.trim()) + + val nonce = "openid4vp-nonce-67890" + val request = OpenID4VP.generateRequest( + version = OpenID4VP.Version.DRAFT_29, + origin = ORIGIN, + nonce = nonce, + responseEncryptionKey = null, + verifierIdentities = emptyList(), + responseMode = OpenID4VP.ResponseMode.DC_API, + responseUri = null, + dcqlQuery = dcql, + jsonTransactionData = listOf(requestJsonString) + ) + + // 2. Wallet presentment + val response = OpenID4VP.generateResponse( + version = OpenID4VP.Version.DRAFT_29, + preselectedDocuments = emptyList(), + source = source, + appId = null, + origin = ORIGIN, + request = request, + requesterIdentities = emptyList(), + ) + + // 3. Verifier processes response + val vpTokens = response.response["vp_token"]!!.jsonObject + val encodedDeviceResponse = vpTokens["payment_credential"]!!.jsonArray[0].jsonPrimitive.content.fromBase64Url() + val deviceResponse = DeviceResponse.fromDataItem(Cbor.decode(encodedDeviceResponse)) + + val handoverInfo = Cbor.encode( + buildCborArray { + add(ORIGIN) + add(nonce) + add(Simple.NULL) + } + ) + val handoverInfoDigest = Crypto.digest(Algorithm.SHA256, handoverInfo) + val sessionTranscript = buildCborArray { + add(Simple.NULL) + add(Simple.NULL) + addCborArray { + add("OpenID4VPDCAPIHandover") + add(handoverInfoDigest) + } + } + + val transactionData = source.documentTypeRepository.parseJsonTransactions( + base64UrlEncodedJson = listOf(requestJsonString.encodeToByteArray().toBase64Url()) + ).values.first() + + deviceResponse.verifySingleDoc( + sessionTranscript = sessionTranscript, + transactionData = transactionData + ) + assertEquals(DeviceResponse.STATUS_OK, deviceResponse.status) + assertEquals(1, deviceResponse.documents.size) + val mdocDoc = deviceResponse.documents[0] + val txElements = mdocDoc.deviceNamespaces.data[PaymentTransaction.openId4VpMdocResponseNamespace]!! + assertEquals(-16L, txElements["transactionDataHashAlg"]!!.asNumber) + val expectedHash = transactionData.first().computeHash(Algorithm.SHA256) + assertEquals(expectedHash, ByteString(txElements["transactionDataHash"]!!.asBstr)) + assertEquals(24.65, txElements["tipAmount"]!!.asDouble) + + // 4. Pretty-printed entire DeviceResponse (Concise Diagnostic Notation) + val responseCdn = Cdn.encode( + item = deviceResponse.toDataItem(), + options = CdnGeneratorOptions.Pretty + ) + val expectedResponseCdn = """ + { + "version": "1.0", + "status": 0, + "documents": [ + { + "docType": "org.multipaz.payment.sca.1", + "issuerSigned": { + "issuerAuth": [ # COSE_Sign1 + /protected/ << { + /alg/ 1: -7 # ES256: ECDSA with SHA-256 + } >>, + /unprotected/ { + /x5chain/ 33: + # Subject DN: C=US,CN=OWF Multipaz TEST DS + # Issuer DN: C=US,CN=OWF Multipaz TEST IACA + cert'''...''' + }, + /payload/ << 24(<< { + "version": "1.0", + "digestAlgorithm": "SHA-256", + "docType": "org.multipaz.payment.sca.1", + "valueDigests": { + "org.multipaz.payment.sca.1": { + 0: h'...' + } + }, + "deviceKeyInfo": { + "deviceKey": { # COSE_Key + /kty/ 1: 2, # EC2 + /crv/ -1: 1, # P-256 + /x/ -2: h'...', + /y/ -3: h'...' + }, + "keyAuthorizations": { + "nameSpaces": [ + "org.iso.transactiondata", + "urn:eudi:sca:payment:1" + ] + } + }, + "validityInfo": { + "signed": dt'...', + "validFrom": dt'...', + "validUntil": dt'...' + } + } >>) >>, + /signature/ h'...' + ], + "nameSpaces": { + "org.multipaz.payment.sca.1": [ + 24(<< { + "digestID": 0, + "random": h'...', + "elementIdentifier": "account_id", + "elementValue": "acc-12345" + } >>) + ] + } + }, + "deviceSigned": { + "deviceAuth": { + "deviceSignature": [ # COSE_Sign1 + /protected/ << { + /alg/ 1: -7 # ES256: ECDSA with SHA-256 + } >>, + /unprotected/ {}, + /payload/ null, + /signature/ h'...' + ] + }, + "nameSpaces": 24(<< { + "urn:eudi:sca:payment:1": { + "tipAmount": 24.65, + "transactionDataHashAlg": -16, + "transactionDataHash": h'${expectedHash.toByteArray().toHex()}' + } + } >>) + } + } + ] + } + """.trimIndent() + assertEquals(expectedResponseCdn, normalizeMdocResponseCdn(responseCdn.trim())) + } + + @Test + fun test_normalizeMdocResponseCdn() { + assertEquals( + "\"random\": h'...',", + normalizeMdocResponseCdn("\"random\": h'2ba86224fbb8692180862f0b7d75',") + ) + assertEquals( + "\"random\": h'...',", + normalizeMdocResponseCdn("\"random\": << 10(h'2ba86224fbb8692180862f0b7d75') >>,") + ) + } +} + +private fun normalizeMdocResponseCdn(cdn: String): String { + return cdn + .replace(Regex("""cert'''[\s\S]*?'''"""), "cert'''...'''") + .replace(Regex("""0: h'[0-9a-fA-F]+'"""), "0: h'...'") + .replace(Regex("""/x/ -2: h'[0-9a-fA-F]+'"""), "/x/ -2: h'...'") + .replace(Regex("""/y/ -3: h'[0-9a-fA-F]+'"""), "/y/ -3: h'...'") + .replace(Regex("""dt'[0-9T:Z-]+'"""), "dt'...'") + .replace(Regex("""/signature/ h'[0-9a-fA-F]+'"""), "/signature/ h'...'") + .replace(Regex(""""random": (h'[0-9a-fA-F]+'|<<[\s\S]*?>>(?=\s*[,}]))"""), "\"random\": h'...'") } private fun DeviceResponse.prettyPrint(): String { diff --git a/multipaz/src/commonTest/kotlin/org/multipaz/verification/VerificationSessionTest.kt b/multipaz/src/commonTest/kotlin/org/multipaz/verification/VerificationSessionTest.kt index 04da17076f..802a1f64b5 100644 --- a/multipaz/src/commonTest/kotlin/org/multipaz/verification/VerificationSessionTest.kt +++ b/multipaz/src/commonTest/kotlin/org/multipaz/verification/VerificationSessionTest.kt @@ -19,6 +19,7 @@ import org.multipaz.crypto.JsonWebSignature import org.multipaz.crypto.X500Name import org.multipaz.crypto.X509CertChain import org.multipaz.document.Document +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.MultiDocumentCannedRequest import org.multipaz.documenttype.knowntypes.DrivingLicense import org.multipaz.documenttype.knowntypes.EUPersonalID @@ -68,7 +69,8 @@ class VerificationSessionTest { harness.initialize() harness.provisionStandardDocuments( keyAuthorizedNamespaces = listOf( - PingTransaction.mdocResponseNamespace + ISO_18013_TRANSACTION_DATA_NAMESPACE, + PingTransaction.openId4VpMdocResponseNamespace, ) ) @@ -148,14 +150,17 @@ class VerificationSessionTest { ) if (expectPingTransaction) { - // Ping transaction round-trip: the holder echoes the request "string" attribute and - // includes a transaction_data_hash binding the response to the request. + // Ping transaction round-trip: the holder echoes the request "string" attribute. val pingResponse = assertNotNull( mdoc.transactionResponses?.get(PingTransaction.identifier), "expected a Ping transaction response" ) assertEquals("string data", pingResponse["string"]!!.asTstr) - assertEquals(32, pingResponse["transactionDataHash"]!!.asBstr.size) + if (record is OpenID4VPPresentmentRecord) { + assertEquals(32, pingResponse["transactionDataHash"]!!.asBstr.size) + } else { + assertEquals(null, pingResponse["transactionDataHash"]) + } } } @@ -181,7 +186,7 @@ class VerificationSessionTest { pid.documentSignerCertChain.certificates.first().ecPublicKey ) - // Ping transaction round-trip: applyCbor("string") is echoed in the KB-JWT body under + // Ping transaction round-trip: generateSdJwtResponseClaims is echoed in the KB-JWT body under // kbJwtResponseClaimName and surfaced under the transaction type's identifier. val pingResponse = assertNotNull( pid.transactionResponses?.get(PingTransaction.identifier), @@ -222,7 +227,11 @@ class VerificationSessionTest { "expected a Ping transaction response" ) assertEquals(expectedPingString, pingResponse["string"]!!.asTstr) - assertEquals(32, pingResponse["transactionDataHash"]!!.asBstr.size) + if (record is OpenID4VPPresentmentRecord) { + assertEquals(32, pingResponse["transactionDataHash"]!!.asBstr.size) + } else { + assertEquals(null, pingResponse["transactionDataHash"]) + } assertEquals(expectedDocRequestId, pingResponse["docRequestId"]?.asNumber) } diff --git a/samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/Contents.json b/samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/Contents.json new file mode 100644 index 0000000000..d6ae0838f5 --- /dev/null +++ b/samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "payment_card_art.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/payment_card_art.png b/samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/payment_card_art.png new file mode 100644 index 0000000000000000000000000000000000000000..0cc8c223e752136161656b838f9f908ee55c87b7 GIT binary patch literal 24627 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 xfriKduzFJBqen`T9quN;y#P<4;M?An?cYa(]] = switch requestType { + case .payment: + paymentDocType.cannedRequests.first(where: { cr in cr.id == "payment_transaction" })!.toTransactionDataMap(credentialId: "cred1") + case .paymentOnlyConf: + paymentDocType.cannedRequests.first(where: { cr in cr.id == "payment_transaction_only_conf" })!.toTransactionDataMap(credentialId: "cred1") + default: + [:] + } + if dcqlString != nil { let query = try! DcqlQuery.companion.fromJsonString(dcql: dcqlString!) let credentialQueryResult = try! await query.execute( presentmentSource: source, keyAgreementPossible: [], - transactionDataMap: [:], + transactionDataMap: transactionDataMap, requesterIdentities: requester.requesterIdentities ) @@ -872,7 +939,7 @@ private func calcRequestData( ), docFormat: nil, dataElementIdentifierMapping: [:], - transactions: nil, + transactionData: nil, otherInfo: [:]) ) drBuilder.setDeviceRequestInfo( diff --git a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppSettingsModel.kt b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppSettingsModel.kt index 4f1e5af36e..ee163de418 100644 --- a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppSettingsModel.kt +++ b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppSettingsModel.kt @@ -177,6 +177,7 @@ class TestAppSettingsModel private constructor( bind(cloudSecureAreaUrl, "cloudSecureAreaUrl", CSA_URL_DEFAULT) bind(dcApiProtocols, "dcApiProtocols", digitalCredentials.supportedProtocols) bind(dcRequestIssuerIdentifiers, "dcRequestIssuerIdentifiers", "") + bind(dcRequestLastSelectedRequestId, "dcRequestLastSelectedRequestId", null) bind(cryptoPreferBouncyCastle, "cryptoForceBouncyCastle", false) @@ -221,6 +222,7 @@ class TestAppSettingsModel private constructor( val cloudSecureAreaUrl = MutableStateFlow(CSA_URL_DEFAULT) val dcApiProtocols = MutableStateFlow>(emptySet()) val dcRequestIssuerIdentifiers = MutableStateFlow("") + val dcRequestLastSelectedRequestId = MutableStateFlow(null) val cryptoPreferBouncyCastle = MutableStateFlow(false) diff --git a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppUtils.kt b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppUtils.kt index f2e41e06dd..369b731a33 100644 --- a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppUtils.kt +++ b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/TestAppUtils.kt @@ -47,8 +47,11 @@ import org.multipaz.documenttype.MultiDocumentCannedRequest import org.multipaz.documenttype.SingleDocumentCannedRequest import org.multipaz.documenttype.knowntypes.Aadhaar import org.multipaz.documenttype.knowntypes.AgeVerification +import org.multipaz.documenttype.knowntypes.PaymentTransaction import org.multipaz.utopia.knowntypes.Loyalty import org.multipaz.utopia.knowntypes.DigitalPaymentCredential +import org.multipaz.utopia.knowntypes.PingTransaction +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.knowntypes.DrivingLicense import org.multipaz.documenttype.knowntypes.EUPersonalID import org.multipaz.documenttype.knowntypes.IDPass @@ -342,7 +345,11 @@ object TestAppUtils { DrivingLicense.getDocumentType(), "Erika", "Erika's Driving License", - Res.drawable.driving_license_card_art + Res.drawable.driving_license_card_art, + deviceKeyAuthorizedNamespaces = listOf(PingTransaction.openId4VpMdocResponseNamespace), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf(PingTransaction.identifier) + ) ) // A second, leaner mDL whose MSO is small enough for the Longfellow ZK circuits. // The full mDL above overflows the circuit (MDOC_PROVER_TAGGED_MSO_TOO_BIG), so we @@ -359,7 +366,11 @@ object TestAppUtils { "Erika", "Erika's Driving License (ZKP-friendly)", Res.drawable.driving_license_card_art, - zkFriendly = true + zkFriendly = true, + deviceKeyAuthorizedNamespaces = listOf(PingTransaction.openId4VpMdocResponseNamespace), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf(PingTransaction.identifier) + ) ) provisionDocument( documentStore, @@ -398,7 +409,11 @@ object TestAppUtils { EUPersonalID.getDocumentType(), "Erika", "Erika's EU PID", - Res.drawable.pid_card_art + Res.drawable.pid_card_art, + deviceKeyAuthorizedNamespaces = listOf(PingTransaction.openId4VpMdocResponseNamespace), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf(PingTransaction.identifier) + ) ) provisionDocument( documentStore, @@ -476,7 +491,17 @@ object TestAppUtils { DigitalPaymentCredential.getDocumentType(), "Erika", "Erika's Payment Card Credential", - Res.drawable.payment_card_art + Res.drawable.payment_card_art, + deviceKeyAuthorizedNamespaces = listOf( + PaymentTransaction.openId4VpMdocResponseNamespace, + PingTransaction.openId4VpMdocResponseNamespace, + ), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf( + PaymentTransaction.identifier, + PingTransaction.identifier, + ) + ) ) provisionDocument( documentStore = documentStore, @@ -685,6 +710,8 @@ object TestAppUtils { displayName: String, cardArtResource: DrawableResource, zkFriendly: Boolean = false, + deviceKeyAuthorizedNamespaces: List = emptyList(), + deviceKeyAuthorizedDataElements: Map> = emptyMap(), ) { val cardArt = getDrawableResourceBytes( getSystemResourceEnvironment(), @@ -716,7 +743,9 @@ object TestAppUtils { dsKey = dsKey, numCredentialsPerDomain = numCredentialsPerDomain, givenNameOverride = givenNameOverride, - zkFriendly = zkFriendly + zkFriendly = zkFriendly, + deviceKeyAuthorizedNamespaces = deviceKeyAuthorizedNamespaces, + deviceKeyAuthorizedDataElements = deviceKeyAuthorizedDataElements, ) } @@ -756,7 +785,9 @@ object TestAppUtils { dsKey: AsymmetricKey.X509Certified, numCredentialsPerDomain: Int, givenNameOverride: String, - zkFriendly: Boolean = false + zkFriendly: Boolean = false, + deviceKeyAuthorizedNamespaces: List = emptyList(), + deviceKeyAuthorizedDataElements: Map> = emptyMap(), ) { val issuerNamespaces = buildIssuerNamespaces { for ((nsName, ns) in documentType.mdocDocumentType?.namespaces!!) { @@ -832,6 +863,8 @@ object TestAppUtils { digestAlgorithm = Algorithm.SHA256, valueDigests = issuerNamespaces.getValueDigests(Algorithm.SHA256), deviceKey = mdocCredential.getAttestation().publicKey, + deviceKeyAuthorizedNamespaces = deviceKeyAuthorizedNamespaces, + deviceKeyAuthorizedDataElements = deviceKeyAuthorizedDataElements, ) val taggedEncodedMso = Cbor.encode(Tagged( Tagged.ENCODED_CBOR, @@ -896,7 +929,9 @@ object TestAppUtils { validUntil: Instant, dsKey: AsymmetricKey.X509Certified, numCredentialsPerDomain: Int, - givenNameOverride: String + givenNameOverride: String, + deviceKeyAuthorizedNamespaces: List = emptyList(), + deviceKeyAuthorizedDataElements: Map> = emptyMap(), ): String? { val issuerNamespaces = buildIssuerNamespaces { for ((nsName, ns) in documentType.mdocDocumentType?.namespaces!!) { @@ -968,6 +1003,8 @@ object TestAppUtils { digestAlgorithm = Algorithm.SHA256, valueDigests = issuerNamespaces.getValueDigests(Algorithm.SHA256), deviceKey = mdocCredential.getAttestation().publicKey, + deviceKeyAuthorizedNamespaces = deviceKeyAuthorizedNamespaces, + deviceKeyAuthorizedDataElements = deviceKeyAuthorizedDataElements, ) val taggedEncodedMso = Cbor.encode(Tagged( Tagged.ENCODED_CBOR, diff --git a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/ConsentPromptScreen.kt b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/ConsentPromptScreen.kt index f5499ab9df..c481757504 100644 --- a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/ConsentPromptScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/ConsentPromptScreen.kt @@ -52,8 +52,11 @@ import org.multipaz.document.Document import org.multipaz.document.DocumentStore import org.multipaz.document.buildDocumentStore import org.multipaz.documenttype.DocumentTypeRepository +import org.multipaz.documenttype.ISO_18013_TRANSACTION_DATA_NAMESPACE import org.multipaz.documenttype.knowntypes.DrivingLicense +import org.multipaz.documenttype.knowntypes.PaymentTransaction import org.multipaz.documenttype.knowntypes.PhotoID +import org.multipaz.utopia.knowntypes.PingTransaction import org.multipaz.utopia.knowntypes.UtopiaBoardingPass import org.multipaz.documenttype.knowntypes.addKnownTypes import org.multipaz.mdoc.request.DeviceRequestInfo @@ -139,7 +142,8 @@ private enum class Example( MDL_NAME_AND_ADDRESS_PARTIALLY_STORED("mDL: Name and address (partially stored)"), MDL_NAME_AND_ADDRESS_ALL_STORED("mDL: Name and address (all stored)"), PHOTO_ID_MANDATORY("PhotoID: Mandatory data elements (2 docs)"), - PAYMENT("Payment"), + PAYMENT("DPC: Payment Confirmation"), + PAYMENT_ONLY_CONF("DPC: Payment Confirmation (only confirmation)"), OPENID4VP_COMPLEX_EXAMPLE("Complex example from OpenID4VP Appendix D"), MDL_AND_BOARDING_PASS_EXAMPLE("mDL AND Boarding pass"), MDL_AND_OPTIONAL_BOARDING_PASS_EXAMPLE("mDL AND optional Boarding pass"), @@ -166,6 +170,7 @@ private enum class PaPreselectedDocuments( PRESELECTED_DOCUMENTS_MDL("mDL"), PRESELECTED_DOCUMENTS_PHOTOID("PhotoID"), PRESELECTED_DOCUMENTS_BOARDING_PASS("Boarding pass"), + PRESELECTED_DOCUMENTS_PAYMENT("Payment"), PRESELECTED_DOCUMENTS_MDL_AND_PHOTOID("mDL and PhotoID"), PRESELECTED_DOCUMENTS_MDL_AND_PHOTOID_AND_PHOTOID("mDL and PhotoID and PhotoID"), PRESELECTED_DOCUMENTS_MDL_AND_BOARDING_PASS("mDL and boarding pass"), @@ -207,6 +212,7 @@ fun ConsentPromptScreen( var cardArtMdl by remember { mutableStateOf(ByteArray(0)) } var cardArtPhotoId by remember { mutableStateOf(ByteArray(0)) } var cardArtBoardingPass by remember { mutableStateOf(ByteArray(0)) } + var cardArtPayment by remember { mutableStateOf(ByteArray(0)) } var utopiaMarketplaceIcon by remember { mutableStateOf(ByteString()) } var utopiaAirlinesIcon by remember { mutableStateOf(ByteString()) } var utopiaCbpIcon by remember { mutableStateOf(ByteString()) } @@ -225,11 +231,13 @@ fun ConsentPromptScreen( lateinit var documentPhotoId: Document lateinit var documentPhotoId2: Document lateinit var documentBoardingPass: Document + lateinit var documentPayment: Document LaunchedEffect(Unit) { cardArtMdl = Res.readBytes("files/utopia_driving_license_card_art.png") cardArtPhotoId = Res.readBytes("drawable/photo_id_card_art.png") cardArtBoardingPass = Res.readBytes("files/boarding-pass-utopia-airlines.png") + cardArtPayment = Res.readBytes("drawable/payment_card_art.png") utopiaMarketplaceIcon = ByteString(Res.readBytes("files/utopia-marketplace.png")) utopiaAirlinesIcon = ByteString(Res.readBytes("files/utopia-airlines.png")) utopiaCbpIcon = ByteString(Res.readBytes("files/utopia-cbp.png")) @@ -342,6 +350,32 @@ fun ConsentPromptScreen( expectedUpdate = null, domain = "mdoc" ) + documentPayment = documentStore!!.createDocument( + displayName = "Erika's Payment Card Credential", + typeDisplayName = "Payment Card", + cardArt = ByteString(cardArtPayment) + ) + DigitalPaymentCredential.getDocumentType().createMdocCredentialWithSampleData( + document = documentPayment, + secureArea = secureArea, + createKeySettings = CreateKeySettings(), + dsKey = dsKey, + signedAt = credsValidFrom, + validFrom = credsValidFrom, + validUntil = credsValidUntil, + expectedUpdate = null, + domain = "mdoc", + deviceKeyAuthorizedNamespaces = listOf( + PaymentTransaction.openId4VpMdocResponseNamespace, + PingTransaction.openId4VpMdocResponseNamespace, + ), + deviceKeyAuthorizedDataElements = mapOf( + ISO_18013_TRANSACTION_DATA_NAMESPACE to listOf( + PaymentTransaction.identifier, + PingTransaction.identifier, + ) + ) + ) addCredentialsForOpenID4VPComplexExample( documentStore = documentStore!!, secureArea = secureArea, @@ -572,6 +606,7 @@ fun ConsentPromptScreen( PaPreselectedDocuments.PRESELECTED_DOCUMENTS_MDL -> listOf(documentMdl) PaPreselectedDocuments.PRESELECTED_DOCUMENTS_PHOTOID -> listOf(documentPhotoId) PaPreselectedDocuments.PRESELECTED_DOCUMENTS_BOARDING_PASS -> listOf(documentBoardingPass) + PaPreselectedDocuments.PRESELECTED_DOCUMENTS_PAYMENT -> listOf(documentPayment) PaPreselectedDocuments.PRESELECTED_DOCUMENTS_MDL_AND_PHOTOID -> listOf(documentMdl, documentPhotoId) PaPreselectedDocuments.PRESELECTED_DOCUMENTS_MDL_AND_PHOTOID_AND_PHOTOID -> listOf(documentMdl, documentPhotoId, documentPhotoId2) @@ -624,6 +659,8 @@ private suspend fun getQueryResult( PhotoID.getDocumentType().cannedRequests.find { it.id == "mandatory" }!!.mdocRequest!!.toDcql(emptyList()) Example.PAYMENT -> DigitalPaymentCredential.getDocumentType().cannedRequests.find { it.id == "payment_transaction" }!!.mdocRequest!!.toDcql(emptyList()) + Example.PAYMENT_ONLY_CONF -> + DigitalPaymentCredential.getDocumentType().cannedRequests.find { it.id == "payment_transaction_only_conf" }!!.mdocRequest!!.toDcql(emptyList()) Example.OPENID4VP_COMPLEX_EXAMPLE -> Json.parseToJsonElement( """ { @@ -938,6 +975,9 @@ private suspend fun getQueryResult( Example.PAYMENT -> DigitalPaymentCredential.getDocumentType() .cannedRequests.find { it.id == "payment_transaction" }!! .toTransactionDataMap("cred1") + Example.PAYMENT_ONLY_CONF -> DigitalPaymentCredential.getDocumentType() + .cannedRequests.find { it.id == "payment_transaction_only_conf" }!! + .toTransactionDataMap("cred1") else -> emptyMap() } @@ -961,6 +1001,7 @@ private suspend fun getQueryResult( Example.MDL_NAME_AND_ADDRESS_ALL_STORED, Example.PHOTO_ID_MANDATORY, Example.PAYMENT, + Example.PAYMENT_ONLY_CONF, Example.OPENID4VP_COMPLEX_EXAMPLE, Example.MDL_AND_BOARDING_PASS_EXAMPLE, Example.MDL_AND_OPTIONAL_BOARDING_PASS_EXAMPLE, diff --git a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/CredentialViewerScreen.kt b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/CredentialViewerScreen.kt index c40e5351cb..0c21f361e3 100644 --- a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/CredentialViewerScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/CredentialViewerScreen.kt @@ -143,6 +143,48 @@ fun CredentialViewerScreen( } } ) + val keyAuthorizationsText = try { + val mso = (credentialInfo.credential as MdocCredential).mso + val authorizedNamespaces = mso.deviceKeyAuthorizedNamespaces + val authorizedDataElements = mso.deviceKeyAuthorizedDataElements + if (authorizedNamespaces.isEmpty() && authorizedDataElements.isEmpty()) { + AnnotatedString("None") + } else { + buildAnnotatedString { + var firstSection = true + if (authorizedNamespaces.isNotEmpty()) { + firstSection = false + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append("Namespaces:") + } + for (ns in authorizedNamespaces) { + append("\n• $ns") + } + } + if (authorizedDataElements.isNotEmpty()) { + if (!firstSection) { + append("\n\n") + } + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append("Data Elements:") + } + for ((ns, elements) in authorizedDataElements) { + append("\n• $ns:") + for (elem in elements) { + append("\n - $elem") + } + } + } + } + } + } catch (e: Throwable) { + Logger.w(TAG, "Error getting MSO key authorizations", e) + AnnotatedString("Error parsing MSO") + } + KeyValuePairText( + keyText = "Key Authorizations", + valueText = keyAuthorizationsText + ) } is SdJwtVcCredential -> { diff --git a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/DcRequestScreen.kt b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/DcRequestScreen.kt index 5201f9fe2a..6d5aa1a909 100644 --- a/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/DcRequestScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/org/multipaz/testapp/ui/DcRequestScreen.kt @@ -55,6 +55,7 @@ private fun parseIssuerIdentifiers(input: String?): List { } private data class RequestEntry( + val id: String, val displayName: String, val request: DocumentCannedRequest ) @@ -167,7 +168,6 @@ private enum class CredentialFormat( IETF_SDJWT("IETF SD-JWT"), } -private var lastRequest: Int = 0 private var lastProtocol: Int = 4 private var lastFormat: Int = 0 @@ -186,8 +186,12 @@ fun DcRequestScreen( ) { val requestOptions = mutableListOf() for (documentType in TestAppUtils.provisionedDocumentTypes) { + val docTypeId = documentType.mdocDocumentType?.docType + ?: documentType.jsonDocumentType?.vct + ?: documentType.displayName for (sampleRequest in documentType.cannedRequests) { requestOptions.add(RequestEntry( + id = "${docTypeId}_${sampleRequest.id}", displayName = "${documentType.displayName}: ${sampleRequest.displayName}", request = sampleRequest )) @@ -195,18 +199,24 @@ fun DcRequestScreen( } for (request in app.documentTypeRepository.extraSingleDocumentCannedRequests) { requestOptions.add(RequestEntry( + id = "extra_" + request.id, displayName = request.displayName, request = request )) } for (request in wellKnownMultipleDocumentRequests) { requestOptions.add(RequestEntry( + id = "multidoc_" + request.id, displayName = "Multi-doc: ${request.displayName}", request = request )) } val requestDropdownExpanded = remember { mutableStateOf(false) } - val requestSelected = remember { mutableStateOf(requestOptions[lastRequest]) } + val requestSelected = remember { mutableStateOf( + requestOptions.find { + it.id == app.settingsModel.dcRequestLastSelectedRequestId.value + } ?: requestOptions.first() + )} val protocolOptions = RequestProtocol.entries val protocolDropdownExpanded = remember { mutableStateOf(false) } val protocolSelected = remember { mutableStateOf(protocolOptions[lastProtocol]) } @@ -226,7 +236,9 @@ fun DcRequestScreen( comboBoxSelected = requestSelected, comboBoxExpanded = requestDropdownExpanded, getDisplayName = { it.displayName }, - onSelected = { index, value -> lastRequest = index } + onSelected = { index, value -> + app.settingsModel.dcRequestLastSelectedRequestId.value = value.id + } ) } item {