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 887bacf8af..1b1898ec23 100755 Binary files a/multipaz-dcapi/src/androidMain/assets/identitycredentialmatcher.wasm and b/multipaz-dcapi/src/androidMain/assets/identitycredentialmatcher.wasm differ 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 0000000000..0cc8c223e7 Binary files /dev/null and b/samples/SwiftTestApp/SwiftTestApp/Assets.xcassets/payment_card_art.imageset/payment_card_art.png differ diff --git a/samples/SwiftTestApp/SwiftTestApp/ConsentPromptScreen.swift b/samples/SwiftTestApp/SwiftTestApp/ConsentPromptScreen.swift index 4be9f21ca6..996ecdcb9c 100644 --- a/samples/SwiftTestApp/SwiftTestApp/ConsentPromptScreen.swift +++ b/samples/SwiftTestApp/SwiftTestApp/ConsentPromptScreen.swift @@ -9,6 +9,8 @@ private enum RequestType: String, CaseIterable { case mdlNameAndAddressPartiallyStored = "mDL: Name and address (partially stored)" case mdlNameAndAddressAllStored = "mDL: Name and address (all stored)" case photoIdMandatory = "PhotoID: Mandatory data elements (two docs)" + case payment = "DPC: Payment Confirmation" + case paymentOnlyConf = "DPC: Payment Confirmation (only confirmation)" case openid4vpComplexExampleFromAppendixD = "Complex example from OpenID4VP Appendix D" case mdlAndBoardingPass = "mDL AND Boarding pass" case mdlAndOptionalBoardingPass = "mDL AND optional Boarding pass" @@ -142,6 +144,7 @@ private func calcRequestData( let mdlCardArt = UIImage(named: "driving_license_card_art")!.pngData()! let photoIdCardArt = UIImage(named: "photo_id_card_art")!.pngData()! let boardingPassCardArt = UIImage(named: "boarding-pass-utopia-airlines")!.pngData()! + let paymentCardArt = UIImage(named: "payment_card_art")!.pngData()! let utopiaMarketplaceLogo = UIImage(named: "utopia-marketplace")!.pngData()! let utopiaAirlinesLogo = UIImage(named: "utopia-airlines")!.pngData()! let utopiaCbpLogo = UIImage(named: "utopia-cbp")!.pngData()! @@ -334,6 +337,54 @@ private func calcRequestData( deviceKeyAuthorizedDataElements: [:] ) + let paymentDoc = try! await documentStore.createDocument( + displayName: "Erika's Payment Card Credential", + typeDisplayName: "Payment Card", + cardArt: paymentCardArt.toByteString(), + issuerLogo: nil, + authorizationData: nil, + appData: nil, + created: now.toKotlinInstant(), + readerIdentifiers: [], + metadata: nil + ) + let _ = try! await DigitalPaymentCredential.shared.getDocumentType( + locale: LocalizedStrings.shared.getCurrentLocale() + ).createMdocCredentialWithSampleData( + document: paymentDoc, + secureArea: secureArea, + createKeySettings: CreateKeySettings( + algorithm: Algorithm.esp256, + nonce: ByteStringBuilder(initialCapacity: 3).appendString(string: "123").toByteString(), + userAuthenticationRequired: true, + userAuthenticationTimeout: 0, + validFrom: nil, + validUntil: nil + ), + dsKey: AsymmetricKey.X509CertifiedExplicit( + certChain: X509CertChain(certificates: [dsCert]), + privateKey: dsKey, + algorithm: Algorithm.esp256 + ), + signedAt: signedAt.toKotlinInstant().truncateToWholeSeconds(), + validFrom: validFrom.toKotlinInstant().truncateToWholeSeconds(), + validUntil: validUntil.toKotlinInstant().truncateToWholeSeconds(), + expectedUpdate: nil, + domain: "mdoc", + randomProvider: KotlinRandom.companion, + includeElement: { _, _ in KotlinBoolean(value: true) }, + deviceKeyAuthorizedNamespaces: [ + PaymentTransaction.shared.openId4VpMdocResponseNamespace, + PingTransaction.shared.openId4VpMdocResponseNamespace, + ], + deviceKeyAuthorizedDataElements: [ + ISO_18013_TRANSACTION_DATA_NAMESPACE: [ + PaymentTransaction.shared.identifier, + PingTransaction.shared.identifier, + ] + ] + ) + try! await addCredentialsForOpenID4VPComplexExample( documentStore: documentStore, secureArea: secureArea, @@ -353,6 +404,9 @@ private func calcRequestData( let photoIdDocType = PhotoID.shared.getDocumentType( locale: LocalizedStrings.shared.getCurrentLocale() ) + let paymentDocType = DigitalPaymentCredential.shared.getDocumentType( + locale: LocalizedStrings.shared.getCurrentLocale() + ) let zks: [ZkSystemSpec] = [] let dcqlString: String? = switch requestType { @@ -370,6 +424,10 @@ private func calcRequestData( mdlDocType.cannedRequests.first(where: { cr in cr.id == "name-and-address-all-stored" })!.mdocRequest!.toDcqlString(zkSystemSpecs: zks) case .photoIdMandatory: photoIdDocType.cannedRequests.first(where: { cr in cr.id == "mandatory" })!.mdocRequest!.toDcqlString(zkSystemSpecs: zks) + case .payment: + paymentDocType.cannedRequests.first(where: { cr in cr.id == "payment_transaction" })!.mdocRequest!.toDcqlString(zkSystemSpecs: zks) + case .paymentOnlyConf: + paymentDocType.cannedRequests.first(where: { cr in cr.id == "payment_transaction_only_conf" })!.mdocRequest!.toDcqlString(zkSystemSpecs: zks) case .openid4vpComplexExampleFromAppendixD: """ { @@ -788,12 +846,21 @@ private func calcRequestData( domainsKeyBoundSdJwt: ["sdjwt"] ) + let transactionDataMap: [String: [TransactionData]] = 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 {