Skip to content

Fixes for presentation during issuance workflow. #981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ private suspend fun digitalCredentialsOpenID4VPProtocol(

val nonce = req["nonce"]!!.jsonPrimitive.content
val responseMode = req["response_mode"]!!.jsonPrimitive.content
if (!(responseMode == "dc_api" || responseMode == "dc_api.jwt")) {
if (!(responseMode == "dc_api" || responseMode == "dc_api.jwt" || responseMode == "direct_post.jwt")) {
// TODO: in the future, flat out reject requests that doesn't use encrypted response
throw IllegalArgumentException("Unexpected response_mode $responseMode")
}
Expand Down Expand Up @@ -340,7 +340,7 @@ private suspend fun digitalCredentialsOpenID4VPProtocol(
var reEncAlg: Algorithm = Algorithm.UNSET
val reReaderPublicKey: EcPublicKey? = when (responseMode) {
"dc_api" -> null
"dc_api.jwt" -> {
"dc_api.jwt", "direct_post.jwt" -> {
val clientMetadata = req["client_metadata"]!!.jsonObject
val reAlg = clientMetadata["authorization_encrypted_response_alg"]!!.jsonPrimitive.content
if (reAlg != "ECDH-ES") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,8 +745,9 @@ class App private constructor(val promptModel: PromptModel) {
}
composable(route = ProvisioningTestDestination.route) {
ProvisioningTestScreen(
promptModel = promptModel,
provisioningModel = provisioningModel
app = this@App,
provisioningModel = provisioningModel,
presentmentModel = presentmentModel
)
}
composable(route = ConsentModalBottomSheetListDestination.route) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import org.multipaz.provisioning.evidence.EvidenceRequestMessage
import org.multipaz.provisioning.evidence.EvidenceRequestOpenid4Vp
import org.multipaz.provisioning.evidence.EvidenceRequestQuestionMultipleChoice
import org.multipaz.provisioning.evidence.EvidenceRequestQuestionString
import org.multipaz.provisioning.evidence.EvidenceRequestSetupCloudSecureArea
import org.multipaz.provisioning.evidence.EvidenceRequestWeb
import org.multipaz.provisioning.evidence.EvidenceResponseCreatePassphrase
import org.multipaz.provisioning.evidence.EvidenceResponseMessage
Expand All @@ -44,28 +43,48 @@ import com.android.identity.testapp.provisioning.model.ProvisioningModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.jetbrains.compose.resources.painterResource
import org.multipaz.compose.PassphraseEntryField
import org.multipaz.compose.presentment.Presentment
import org.multipaz.compose.webview.RichText
import org.multipaz.credential.Credential
import org.multipaz.prompt.PromptModel
import org.multipaz.models.presentment.DigitalCredentialsPresentmentMechanism
import org.multipaz.models.presentment.PresentmentModel
import org.multipaz.provisioning.ApplicationSupport
import org.multipaz.provisioning.LandingUrlUnknownException
import org.multipaz.provisioning.evidence.EvidenceRequestNotificationPermission
import org.multipaz.provisioning.evidence.EvidenceResponseNotificationPermission
import org.multipaz.securearea.SecureAreaRepository
import org.multipaz.securearea.cloud.CloudSecureArea
import org.multipaz.provisioning.evidence.EvidenceResponseOpenid4Vp
import org.multipaz.testapp.App
import org.multipaz.testapp.TestAppPresentmentSource
import org.multipaz.testapp.platformAppIcon
import org.multipaz.testapp.platformAppName
import org.multipaz.util.Logger
import kotlin.time.Duration.Companion.seconds

@Composable
fun ProvisioningTestScreen(promptModel: PromptModel, provisioningModel: ProvisioningModel) {
fun ProvisioningTestScreen(
app: App,
provisioningModel: ProvisioningModel,
presentmentModel: PresentmentModel
) {
LaunchedEffect(provisioningModel) {
provisioningModel.run()
}
val provisioningState = provisioningModel.state.collectAsState(ProvisioningModel.Initial).value
Column {
if (provisioningState is ProvisioningModel.EvidenceRequested) {
RequestEvidence(provisioningModel, promptModel, provisioningState)
RequestEvidence(
app,
provisioningModel,
presentmentModel,
provisioningState
)
} else {
val text = when (provisioningState) {
ProvisioningModel.Initial -> "Starting provisioning..."
Expand All @@ -91,13 +110,14 @@ fun ProvisioningTestScreen(promptModel: PromptModel, provisioningModel: Provisio

@Composable
private fun RequestEvidence(
app: App,
provisioningModel: ProvisioningModel,
promptModel: PromptModel,
presentmentModel: PresentmentModel,
request: ProvisioningModel.EvidenceRequested
) {
val index = remember { mutableIntStateOf(0) }
val evidenceRequest = request.evidenceRequests[index.value]
val coroutineScope = rememberCoroutineScope { promptModel }
val coroutineScope = rememberCoroutineScope { app.promptModel }
when (evidenceRequest) {
is EvidenceRequestQuestionString -> {
EvidenceRequestQuestionStringView(
Expand Down Expand Up @@ -154,7 +174,10 @@ private fun RequestEvidence(

is EvidenceRequestOpenid4Vp -> {
EvidenceRequestOpenid4VpView(
app = app,
provisioningModel = provisioningModel,
evidenceRequest = evidenceRequest,
presentmentModel = presentmentModel,
viableCredentials = request.credentials,
onNextEvidenceRequest = {
index.value++
Expand Down Expand Up @@ -542,31 +565,81 @@ private suspend fun handleLanding(

@Composable
fun EvidenceRequestOpenid4VpView(
app: App,
provisioningModel: ProvisioningModel,
evidenceRequest: EvidenceRequestOpenid4Vp,
presentmentModel: PresentmentModel,
viableCredentials: List<Credential>,
onNextEvidenceRequest: () -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Presentation during issuance is not yet implemented",
textAlign = TextAlign.Center,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.titleLarge
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.padding(8.dp),
onClick = onNextEvidenceRequest
val coroutineScope = rememberCoroutineScope()
val presenting = remember { mutableStateOf(false) }
if (presenting.value) {
Presentment(
presentmentModel = presentmentModel,
promptModel = app.promptModel,
documentTypeRepository = app.documentTypeRepository,
source = TestAppPresentmentSource(app),
onPresentmentComplete = { presenting.value = false},
appName = platformAppName,
appIconPainter = painterResource(platformAppIcon),
)
} else {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Presentation during issuance",
textAlign = TextAlign.Center,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.titleLarge
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(text = evidenceRequest.cancelText ?: "Use browser")
Button(
modifier = Modifier.padding(8.dp),
onClick = {
presentmentModel.reset()
val mechanism = object : DigitalCredentialsPresentmentMechanism(
appId = "",
webOrigin = evidenceRequest.originUri,
protocol = "openid4vp",
request = buildJsonObject {
put("request", evidenceRequest.request)
}.toString(),
document = viableCredentials.first().document
) {
override fun sendResponse(response: String) {
coroutineScope.launch {
val json = Json.parseToJsonElement(response).jsonObject
provisioningModel.provideEvidence(
EvidenceResponseOpenid4Vp(json["response"]!!.jsonPrimitive.content)
)
}
}

override fun close() {
presenting.value = false
}
}
presentmentModel.setConnecting()
presentmentModel.setMechanism(mechanism)
presenting.value = true
}
) {
Text(text = "Present Credential")
}
Button(
modifier = Modifier.padding(8.dp),
onClick = onNextEvidenceRequest
) {
Text(text = evidenceRequest.cancelText ?: "Use browser")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ class Openid4VpResponseServlet: BaseServlet() {
val decrypter = ECDHDecrypter(encKey)
encryptedJWT.decrypt(decrypter)

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

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


val vpTokenMap = encryptedJWT.jwtClaimsSet.getClaim("vp_token") as Map<*,*>

// "cred1" is id that we specified in request DCQL.
val deviceResponse = (vpTokenMap["cred1"] as String).fromBase64Url()

val parser = DeviceResponseParser(deviceResponse, sessionTranscript)
val parsedResponse = parser.parse()

Expand Down
57 changes: 54 additions & 3 deletions server/src/main/java/org/multipaz/server/openid4vci/openid4vp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ import kotlinx.datetime.plus
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import kotlin.random.Random

data class Openid4VpSession(
Expand All @@ -46,22 +49,70 @@ fun initiateOpenid4Vp(

val header = buildJsonObject {
put("typ", JsonPrimitive("oauth-authz-req+jwt"))
put("alg", JsonPrimitive(publicKey.curve.defaultSigningAlgorithm.joseAlgorithmIdentifier))
put("jwk", publicKey.toJson(null))
put("alg", JsonPrimitive(publicKey.curve.defaultSigningAlgorithmFullySpecified.joseAlgorithmIdentifier))
put("x5c", buildJsonArray {
for (cert in singleUseReaderKeyCertChain.certificates) {
add(cert.encodedCertificate.toBase64Url())
}
})
}.toString().toByteArray().toBase64Url()

val credFormat = "mdoc"

val body = buildJsonObject {
put("client_id", clientId)
put("response_uri", responseUri)
put("response_type", "vp_token")
put("response_mode", "direct_post.jwt")
put("nonce", nonce)
put("state", state)
putJsonObject("dcql_query") {
putJsonArray("credentials") {
if (credFormat == "vc") {
addJsonObject {
put("id", JsonPrimitive("cred1"))
put("format", JsonPrimitive("dc+sd-jwt"))
putJsonObject("meta") {
put("vct_values",
buildJsonArray {
add(JsonPrimitive(request.vcRequest!!.vct))
}
)
}
putJsonArray("claims") {
for (claim in request.vcRequest!!.claimsToRequest) {
addJsonObject {
putJsonArray("path") {
add(JsonPrimitive(claim.identifier))
}
}
}
}
}
} else {
addJsonObject {
put("id", JsonPrimitive("cred1"))
put("format", JsonPrimitive("mso_mdoc"))
putJsonObject("meta") {
put("doctype_value", JsonPrimitive(request.mdocRequest!!.docType))
}
putJsonArray("claims") {
for (ns in request.mdocRequest!!.namespacesToRequest) {
for ((de, intentToRetain) in ns.dataElementsToRequest) {
addJsonObject {
putJsonArray("path") {
add(JsonPrimitive(ns.namespace))
add(JsonPrimitive(de.attribute.identifier))
}
put("intent_to_retain", JsonPrimitive(intentToRetain))
}
}
}
}
}
}
}
}
put("presentation_definition", mdocCalcPresentationDefinition(request))
put("client_metadata", calcClientMetadata(singleUseReaderKeyPriv.publicKey))
}.toString().toByteArray().toBase64Url()
Expand Down Expand Up @@ -176,7 +227,7 @@ private fun calcClientMetadata(publicKey: EcPublicKey): JsonObject {
}
return buildJsonObject {
put("authorization_encrypted_response_alg", "ECDH-ES")
put("authorization_encrypted_response_enc", "A128CBC-HS256")
put("authorization_encrypted_response_enc", Algorithm.A128GCM.joseAlgorithmIdentifier)
put("response_mode", "direct_post.jwt")
put("vp_formats", formats)
put("vp_formats_supported", formats)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1549,7 +1549,6 @@ private fun calcDcRequestStringOpenID4VP(
)
}
putJsonArray("claims") {
// TODO: support path-based claims, e.g. ["address", "postal_code"]
for (claim in request.vcRequest!!.claimsToRequest) {
addJsonObject {
putJsonArray("path") {
Expand Down