Skip to content

Commit aca22c6

Browse files
committed
Fixes for presentation during issuance workflow.
Minimal PR that brings presentation-during-issuance implementation to the current spec, in particular with DCQL. (More work will follow up to integrate PresentmentModel with the ProvisioningModel, in particular to select potentially matching credential(s) more intellegently). Testing: tested openid4vci issuance workflow with our own openid4vci server implementation. Signed-off-by: Peter Sorotokin <sorotokin@gmail.com>
1 parent 59f1617 commit aca22c6

File tree

5 files changed

+168
-37
lines changed

5 files changed

+168
-37
lines changed

multipaz-models/src/commonMain/kotlin/org/multipaz/models/presentment/digitalCredentialsPresentment.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ private suspend fun digitalCredentialsOpenID4VPProtocol(
296296

297297
val nonce = req["nonce"]!!.jsonPrimitive.content
298298
val responseMode = req["response_mode"]!!.jsonPrimitive.content
299-
if (!(responseMode == "dc_api" || responseMode == "dc_api.jwt")) {
299+
if (!(responseMode == "dc_api" || responseMode == "dc_api.jwt" || responseMode == "direct_post.jwt")) {
300300
// TODO: in the future, flat out reject requests that doesn't use encrypted response
301301
throw IllegalArgumentException("Unexpected response_mode $responseMode")
302302
}
@@ -340,7 +340,7 @@ private suspend fun digitalCredentialsOpenID4VPProtocol(
340340
var reEncAlg: Algorithm = Algorithm.UNSET
341341
val reReaderPublicKey: EcPublicKey? = when (responseMode) {
342342
"dc_api" -> null
343-
"dc_api.jwt" -> {
343+
"dc_api.jwt", "direct_post.jwt" -> {
344344
val clientMetadata = req["client_metadata"]!!.jsonObject
345345
val reAlg = clientMetadata["authorization_encrypted_response_alg"]!!.jsonPrimitive.content
346346
if (reAlg != "ECDH-ES") {

samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -745,8 +745,9 @@ class App private constructor(val promptModel: PromptModel) {
745745
}
746746
composable(route = ProvisioningTestDestination.route) {
747747
ProvisioningTestScreen(
748-
promptModel = promptModel,
749-
provisioningModel = provisioningModel
748+
app = this@App,
749+
provisioningModel = provisioningModel,
750+
presentmentModel = presentmentModel
750751
)
751752
}
752753
composable(route = ConsentModalBottomSheetListDestination.route) {

samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/ProvisioningTestScreen.kt

+101-28
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import org.multipaz.provisioning.evidence.EvidenceRequestMessage
3333
import org.multipaz.provisioning.evidence.EvidenceRequestOpenid4Vp
3434
import org.multipaz.provisioning.evidence.EvidenceRequestQuestionMultipleChoice
3535
import org.multipaz.provisioning.evidence.EvidenceRequestQuestionString
36-
import org.multipaz.provisioning.evidence.EvidenceRequestSetupCloudSecureArea
3736
import org.multipaz.provisioning.evidence.EvidenceRequestWeb
3837
import org.multipaz.provisioning.evidence.EvidenceResponseCreatePassphrase
3938
import org.multipaz.provisioning.evidence.EvidenceResponseMessage
@@ -44,28 +43,48 @@ import com.android.identity.testapp.provisioning.model.ProvisioningModel
4443
import kotlinx.coroutines.delay
4544
import kotlinx.coroutines.launch
4645
import kotlinx.coroutines.withContext
46+
import kotlinx.serialization.json.Json
47+
import kotlinx.serialization.json.buildJsonObject
48+
import kotlinx.serialization.json.jsonObject
49+
import kotlinx.serialization.json.jsonPrimitive
50+
import kotlinx.serialization.json.put
51+
import org.jetbrains.compose.resources.painterResource
4752
import org.multipaz.compose.PassphraseEntryField
53+
import org.multipaz.compose.presentment.Presentment
4854
import org.multipaz.compose.webview.RichText
4955
import org.multipaz.credential.Credential
50-
import org.multipaz.prompt.PromptModel
56+
import org.multipaz.models.presentment.DigitalCredentialsPresentmentMechanism
57+
import org.multipaz.models.presentment.PresentmentModel
5158
import org.multipaz.provisioning.ApplicationSupport
5259
import org.multipaz.provisioning.LandingUrlUnknownException
5360
import org.multipaz.provisioning.evidence.EvidenceRequestNotificationPermission
5461
import org.multipaz.provisioning.evidence.EvidenceResponseNotificationPermission
55-
import org.multipaz.securearea.SecureAreaRepository
56-
import org.multipaz.securearea.cloud.CloudSecureArea
62+
import org.multipaz.provisioning.evidence.EvidenceResponseOpenid4Vp
63+
import org.multipaz.testapp.App
64+
import org.multipaz.testapp.TestAppPresentmentSource
65+
import org.multipaz.testapp.platformAppIcon
66+
import org.multipaz.testapp.platformAppName
5767
import org.multipaz.util.Logger
5868
import kotlin.time.Duration.Companion.seconds
5969

6070
@Composable
61-
fun ProvisioningTestScreen(promptModel: PromptModel, provisioningModel: ProvisioningModel) {
71+
fun ProvisioningTestScreen(
72+
app: App,
73+
provisioningModel: ProvisioningModel,
74+
presentmentModel: PresentmentModel
75+
) {
6276
LaunchedEffect(provisioningModel) {
6377
provisioningModel.run()
6478
}
6579
val provisioningState = provisioningModel.state.collectAsState(ProvisioningModel.Initial).value
6680
Column {
6781
if (provisioningState is ProvisioningModel.EvidenceRequested) {
68-
RequestEvidence(provisioningModel, promptModel, provisioningState)
82+
RequestEvidence(
83+
app,
84+
provisioningModel,
85+
presentmentModel,
86+
provisioningState
87+
)
6988
} else {
7089
val text = when (provisioningState) {
7190
ProvisioningModel.Initial -> "Starting provisioning..."
@@ -91,13 +110,14 @@ fun ProvisioningTestScreen(promptModel: PromptModel, provisioningModel: Provisio
91110

92111
@Composable
93112
private fun RequestEvidence(
113+
app: App,
94114
provisioningModel: ProvisioningModel,
95-
promptModel: PromptModel,
115+
presentmentModel: PresentmentModel,
96116
request: ProvisioningModel.EvidenceRequested
97117
) {
98118
val index = remember { mutableIntStateOf(0) }
99119
val evidenceRequest = request.evidenceRequests[index.value]
100-
val coroutineScope = rememberCoroutineScope { promptModel }
120+
val coroutineScope = rememberCoroutineScope { app.promptModel }
101121
when (evidenceRequest) {
102122
is EvidenceRequestQuestionString -> {
103123
EvidenceRequestQuestionStringView(
@@ -154,7 +174,10 @@ private fun RequestEvidence(
154174

155175
is EvidenceRequestOpenid4Vp -> {
156176
EvidenceRequestOpenid4VpView(
177+
app = app,
178+
provisioningModel = provisioningModel,
157179
evidenceRequest = evidenceRequest,
180+
presentmentModel = presentmentModel,
158181
viableCredentials = request.credentials,
159182
onNextEvidenceRequest = {
160183
index.value++
@@ -542,31 +565,81 @@ private suspend fun handleLanding(
542565

543566
@Composable
544567
fun EvidenceRequestOpenid4VpView(
568+
app: App,
569+
provisioningModel: ProvisioningModel,
545570
evidenceRequest: EvidenceRequestOpenid4Vp,
571+
presentmentModel: PresentmentModel,
546572
viableCredentials: List<Credential>,
547573
onNextEvidenceRequest: () -> Unit
548574
) {
549-
Column {
550-
Row(
551-
modifier = Modifier.fillMaxWidth(),
552-
horizontalArrangement = Arrangement.Center
553-
) {
554-
Text(
555-
text = "Presentation during issuance is not yet implemented",
556-
textAlign = TextAlign.Center,
557-
modifier = Modifier.padding(8.dp),
558-
style = MaterialTheme.typography.titleLarge
559-
)
560-
}
561-
Row(
562-
modifier = Modifier.fillMaxWidth(),
563-
horizontalArrangement = Arrangement.Center
564-
) {
565-
Button(
566-
modifier = Modifier.padding(8.dp),
567-
onClick = onNextEvidenceRequest
575+
val coroutineScope = rememberCoroutineScope()
576+
val presenting = remember { mutableStateOf(false) }
577+
if (presenting.value) {
578+
Presentment(
579+
presentmentModel = presentmentModel,
580+
promptModel = app.promptModel,
581+
documentTypeRepository = app.documentTypeRepository,
582+
source = TestAppPresentmentSource(app),
583+
onPresentmentComplete = { presenting.value = false},
584+
appName = platformAppName,
585+
appIconPainter = painterResource(platformAppIcon),
586+
)
587+
} else {
588+
Column {
589+
Row(
590+
modifier = Modifier.fillMaxWidth(),
591+
horizontalArrangement = Arrangement.Center
592+
) {
593+
Text(
594+
text = "Presentation during issuance",
595+
textAlign = TextAlign.Center,
596+
modifier = Modifier.padding(8.dp),
597+
style = MaterialTheme.typography.titleLarge
598+
)
599+
}
600+
Row(
601+
modifier = Modifier.fillMaxWidth(),
602+
horizontalArrangement = Arrangement.Center
568603
) {
569-
Text(text = evidenceRequest.cancelText ?: "Use browser")
604+
Button(
605+
modifier = Modifier.padding(8.dp),
606+
onClick = {
607+
presentmentModel.reset()
608+
val mechanism = object : DigitalCredentialsPresentmentMechanism(
609+
appId = "",
610+
webOrigin = evidenceRequest.originUri,
611+
protocol = "openid4vp",
612+
request = buildJsonObject {
613+
put("request", evidenceRequest.request)
614+
}.toString(),
615+
document = viableCredentials.first().document
616+
) {
617+
override fun sendResponse(response: String) {
618+
coroutineScope.launch {
619+
val json = Json.parseToJsonElement(response).jsonObject
620+
provisioningModel.provideEvidence(
621+
EvidenceResponseOpenid4Vp(json["response"]!!.jsonPrimitive.content)
622+
)
623+
}
624+
}
625+
626+
override fun close() {
627+
presenting.value = false
628+
}
629+
}
630+
presentmentModel.setConnecting()
631+
presentmentModel.setMechanism(mechanism)
632+
presenting.value = true
633+
}
634+
) {
635+
Text(text = "Present Credential")
636+
}
637+
Button(
638+
modifier = Modifier.padding(8.dp),
639+
onClick = onNextEvidenceRequest
640+
) {
641+
Text(text = evidenceRequest.cancelText ?: "Use browser")
642+
}
570643
}
571644
}
572645
}

server/src/main/java/org/multipaz/server/openid4vci/Openid4VpResponseServlet.kt

+6-2
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ class Openid4VpResponseServlet: BaseServlet() {
5555
val decrypter = ECDHDecrypter(encKey)
5656
encryptedJWT.decrypt(decrypter)
5757

58-
val vpToken = encryptedJWT.jwtClaimsSet.getClaim("vp_token") as String
59-
val deviceResponse = vpToken.fromBase64Url()
6058
val responseUri = "$baseUrl/openid4vp-response"
6159

6260
val sessionTranscript = createSessionTranscriptOpenID4VP(
@@ -66,6 +64,12 @@ class Openid4VpResponseServlet: BaseServlet() {
6664
mdocGeneratedNonce = encryptedJWT.header.agreementPartyUInfo.toString()
6765
)
6866

67+
68+
val vpTokenMap = encryptedJWT.jwtClaimsSet.getClaim("vp_token") as Map<*,*>
69+
70+
// "cred1" is id that we specified in request DCQL.
71+
val deviceResponse = (vpTokenMap["cred1"] as String).fromBase64Url()
72+
6973
val parser = DeviceResponseParser(deviceResponse, sessionTranscript)
7074
val parsedResponse = parser.parse()
7175

server/src/main/java/org/multipaz/server/openid4vci/openid4vp.kt

+56-3
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ import kotlinx.datetime.plus
2222
import kotlinx.serialization.json.JsonObject
2323
import kotlinx.serialization.json.JsonPrimitive
2424
import kotlinx.serialization.json.add
25+
import kotlinx.serialization.json.addJsonObject
2526
import kotlinx.serialization.json.buildJsonArray
2627
import kotlinx.serialization.json.buildJsonObject
2728
import kotlinx.serialization.json.put
29+
import kotlinx.serialization.json.putJsonArray
30+
import kotlinx.serialization.json.putJsonObject
2831
import kotlin.random.Random
2932

3033
data class Openid4VpSession(
@@ -46,22 +49,72 @@ fun initiateOpenid4Vp(
4649

4750
val header = buildJsonObject {
4851
put("typ", JsonPrimitive("oauth-authz-req+jwt"))
49-
put("alg", JsonPrimitive(publicKey.curve.defaultSigningAlgorithm.joseAlgorithmIdentifier))
50-
put("jwk", publicKey.toJson(null))
52+
put("alg", JsonPrimitive(publicKey.curve.defaultSigningAlgorithmFullySpecified.joseAlgorithmIdentifier))
53+
//put("jwk", publicKey.toJson(null))
5154
put("x5c", buildJsonArray {
5255
for (cert in singleUseReaderKeyCertChain.certificates) {
5356
add(cert.encodedCertificate.toBase64Url())
5457
}
5558
})
5659
}.toString().toByteArray().toBase64Url()
5760

61+
val credFormat = "mdoc"
62+
5863
val body = buildJsonObject {
5964
put("client_id", clientId)
6065
put("response_uri", responseUri)
6166
put("response_type", "vp_token")
6267
put("response_mode", "direct_post.jwt")
6368
put("nonce", nonce)
6469
put("state", state)
70+
putJsonObject("dcql_query") {
71+
putJsonArray("credentials") {
72+
if (credFormat == "vc") {
73+
addJsonObject {
74+
put("id", JsonPrimitive("cred1"))
75+
put("format", JsonPrimitive("dc+sd-jwt"))
76+
putJsonObject("meta") {
77+
put("vct_values",
78+
buildJsonArray {
79+
add(JsonPrimitive(request.vcRequest!!.vct))
80+
}
81+
)
82+
}
83+
putJsonArray("claims") {
84+
// TODO: support path-based claims, e.g. ["address", "postal_code"]
85+
for (claim in request.vcRequest!!.claimsToRequest) {
86+
addJsonObject {
87+
putJsonArray("path") {
88+
add(JsonPrimitive(claim.identifier))
89+
}
90+
}
91+
}
92+
}
93+
}
94+
} else {
95+
addJsonObject {
96+
put("id", JsonPrimitive("cred1"))
97+
put("format", JsonPrimitive("mso_mdoc"))
98+
putJsonObject("meta") {
99+
put("doctype_value", JsonPrimitive(request.mdocRequest!!.docType))
100+
}
101+
putJsonArray("claims") {
102+
for (ns in request.mdocRequest!!.namespacesToRequest) {
103+
for ((de, intentToRetain) in ns.dataElementsToRequest) {
104+
addJsonObject {
105+
putJsonArray("path") {
106+
add(JsonPrimitive(ns.namespace))
107+
add(JsonPrimitive(de.attribute.identifier))
108+
}
109+
put("intent_to_retain", JsonPrimitive(intentToRetain))
110+
}
111+
}
112+
}
113+
}
114+
}
115+
}
116+
}
117+
}
65118
put("presentation_definition", mdocCalcPresentationDefinition(request))
66119
put("client_metadata", calcClientMetadata(singleUseReaderKeyPriv.publicKey))
67120
}.toString().toByteArray().toBase64Url()
@@ -176,7 +229,7 @@ private fun calcClientMetadata(publicKey: EcPublicKey): JsonObject {
176229
}
177230
return buildJsonObject {
178231
put("authorization_encrypted_response_alg", "ECDH-ES")
179-
put("authorization_encrypted_response_enc", "A128CBC-HS256")
232+
put("authorization_encrypted_response_enc", "A128GCM")
180233
put("response_mode", "direct_post.jwt")
181234
put("vp_formats", formats)
182235
put("vp_formats_supported", formats)

0 commit comments

Comments
 (0)