From a248e6a1e191fe941b0f2363ecc697ff2b935cef Mon Sep 17 00:00:00 2001 From: Peter Sorotokin Date: Thu, 31 Oct 2024 13:04:20 -0700 Subject: [PATCH] Presentation while issuance. Signed-off-by: Peter Sorotokin --- .../evidence/EvidenceRequestOpenid4Vp.kt | 6 + .../evidence/EvidenceResponseOpenid4Vp.kt | 5 + .../funke/FunkeIssuingAuthorityState.kt | 61 +++-- .../issuance/funke/FunkeProofingState.kt | 108 ++++++-- .../identity/issuance/funke/ProofingInfo.kt | 6 +- .../funke/openid4VciIssuerMetadata.kt | 4 + .../openid4vci/AuthorizeChallengeServlet.kt | 65 +++++ .../server/openid4vci/AuthorizeServlet.kt | 62 +++-- .../identity/server/openid4vci/BaseServlet.kt | 5 + .../openid4vci/Openid4VpResponseServlet.kt | 134 ++++++++++ .../identity/server/openid4vci/ParServlet.kt | 57 +---- .../WellKnownOauthAuthorizationServlet.kt | 6 +- .../WellKnownOpenidCredentialIssuerServlet.kt | 2 +- .../server/openid4vci/createSession.kt | 71 ++++++ .../identity/server/openid4vci/messages.kt | 8 +- .../identity/server/openid4vci/openid4vp.kt | 233 ++++++++++++++++++ server/src/main/webapp/WEB-INF/web.xml | 24 ++ .../wallet/ProvisioningViewModel.kt | 121 ++++++++- .../wallet/navigation/WalletNavigation.kt | 2 +- .../provisioncredential/EvidenceRequest.kt | 94 +++++-- .../ProvisionCredentialScreen.kt | 224 ++++++++++++++++- wallet/src/main/res/values/strings.xml | 8 + 22 files changed, 1151 insertions(+), 155 deletions(-) create mode 100644 identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestOpenid4Vp.kt create mode 100644 identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponseOpenid4Vp.kt create mode 100644 server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt create mode 100644 server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt create mode 100644 server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt create mode 100644 server-openid4vci/src/main/java/com/android/identity/server/openid4vci/openid4vp.kt diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestOpenid4Vp.kt b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestOpenid4Vp.kt new file mode 100644 index 000000000..9fc9704c4 --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestOpenid4Vp.kt @@ -0,0 +1,6 @@ +package com.android.identity.issuance.evidence + +class EvidenceRequestOpenid4Vp( + val originUri: String, + val request: String +): EvidenceRequest() \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponseOpenid4Vp.kt b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponseOpenid4Vp.kt new file mode 100644 index 000000000..3124c006e --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponseOpenid4Vp.kt @@ -0,0 +1,5 @@ +package com.android.identity.issuance.evidence + +class EvidenceResponseOpenid4Vp( + val response: String +) : EvidenceResponse() \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt index 20b65a345..f9bda84c5 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt @@ -246,6 +246,14 @@ class FunkeIssuingAuthorityState( val proofingInfo = performPushedAuthorizationRequest(env) val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) val authorizationMetadata = metadata.authorizationServers[0] + var openid4VpRequest: String? = null + if (proofingInfo.authSession != null && proofingInfo.openid4VpPresentation != null) { + val httpClient = env.getInterface(HttpClient::class)!! + val presentationResponse = httpClient.get(proofingInfo.openid4VpPresentation) {} + if (presentationResponse.status == HttpStatusCode.OK) { + openid4VpRequest = String(presentationResponse.readBytes()) + } + } val storage = env.getInterface(Storage::class)!! val applicationCapabilities = storage.get( "WalletApplicationCapabilities", @@ -255,15 +263,15 @@ class FunkeIssuingAuthorityState( WalletApplicationCapabilities.fromCbor(it.toByteArray()) } ?: throw IllegalStateException("WalletApplicationCapabilities not found") return FunkeProofingState( + credentialIssuerUri = credentialIssuerUri, clientId = clientId, issuanceClientId = issuanceClientId, documentId = documentId, proofingInfo = proofingInfo, applicationCapabilities = applicationCapabilities, - tokenUri = authorizationMetadata.tokenEndpoint, - useGermanEId = authorizationMetadata.useGermanEId, // Don't show TOS when using browser API - tosAcknowleged = !authorizationMetadata.useGermanEId + tosAcknowleged = !authorizationMetadata.useGermanEId, + openid4VpRequest = openid4VpRequest ) } @@ -566,7 +574,7 @@ class FunkeIssuingAuthorityState( } private suspend fun performPushedAuthorizationRequest( - env: FlowEnvironment, + env: FlowEnvironment ): ProofingInfo { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) val config = metadata.credentialConfigurations[credentialConfigurationId]!! @@ -613,31 +621,48 @@ class FunkeIssuingAuthorityState( add("client_id", issuanceClientId) } val httpClient = env.getInterface(HttpClient::class)!! - val response = httpClient.post(authorizationMetadata.pushedAuthorizationRequestEndpoint) { + // Use authorization challenge if available, as we want to try it first before falling + // back to web-based authorization. + val (endpoint, expectedResponseStatus) = + if (authorizationMetadata.authorizationChallengeEndpoint != null) { + Pair( + authorizationMetadata.authorizationChallengeEndpoint, + HttpStatusCode.BadRequest + ) + } else { + Pair( + authorizationMetadata.pushedAuthorizationRequestEndpoint, + HttpStatusCode.Created + ) + } + val response = httpClient.post(endpoint) { headers { append("Content-Type", "application/x-www-form-urlencoded") } setBody(req.toString()) } - if (response.status != HttpStatusCode.Created) { - val responseText = String(response.readBytes()) + val responseText = String(response.readBytes()) + if (response.status != expectedResponseStatus) { Logger.e(TAG, "PAR request error: ${response.status}: $responseText") throw IssuingAuthorityException("Error establishing authenticated channel with issuer") } - val parsedResponse = Json.parseToJsonElement(String(response.readBytes())) as JsonObject - val requestUri = parsedResponse["request_uri"] - if (requestUri !is JsonPrimitive) { - Logger.e(TAG, "PAR response error") - throw IllegalStateException("PAR response syntax error") + val parsedResponse = Json.parseToJsonElement(responseText) as JsonObject + if (response.status == HttpStatusCode.BadRequest) { + val errorCode = parsedResponse["error"] + if (errorCode !is JsonPrimitive || errorCode.content != "insufficient_authorization") { + Logger.e(TAG, "PAR request error: ${response.status}: $responseText") + throw IssuingAuthorityException("Error establishing authenticated channel with issuer") + } } - Logger.i(TAG, "Request uri: ${requestUri.content}") + val authSession = parsedResponse["auth_session"] + val requestUri = parsedResponse["request_uri"] + val presentation = parsedResponse["presentation"] return ProofingInfo( - authorizeUrl = "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { - add("client_id", issuanceClientId) - add("request_uri", requestUri.content) - }, + requestUri = requestUri?.jsonPrimitive?.content, + authSession = authSession?.jsonPrimitive?.content, pkceCodeVerifier = pkceCodeVerifier, - landingUrl = landingUrl + landingUrl = landingUrl, + openid4VpPresentation = presentation?.jsonPrimitive?.content ) } diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt index acedd5416..8c53c2da8 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt @@ -14,6 +14,7 @@ import com.android.identity.issuance.evidence.EvidenceRequest import com.android.identity.issuance.evidence.EvidenceRequestGermanEid import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission +import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceRequestSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceRequestWeb @@ -21,14 +22,24 @@ import com.android.identity.issuance.evidence.EvidenceResponse import com.android.identity.issuance.evidence.EvidenceResponseGermanEid import com.android.identity.issuance.evidence.EvidenceResponseMessage import com.android.identity.issuance.evidence.EvidenceResponseNotificationPermission +import com.android.identity.issuance.evidence.EvidenceResponseOpenid4Vp import com.android.identity.issuance.evidence.EvidenceResponseQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceResponseSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceResponseWeb import com.android.identity.securearea.PassphraseConstraints import com.android.identity.util.Logger +import com.android.identity.util.fromBase64Url import io.ktor.client.HttpClient import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.readBytes import io.ktor.http.HttpStatusCode +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.URI import java.net.URLEncoder @@ -37,25 +48,25 @@ import java.net.URLEncoder ) @CborSerializable class FunkeProofingState( + val credentialIssuerUri: String, val clientId: String, val issuanceClientId: String, val documentId: String, val proofingInfo: ProofingInfo, val applicationCapabilities: WalletApplicationCapabilities, - val tokenUri:String, - val useGermanEId: Boolean, var access: FunkeAccess? = null, var secureAreaIdentifier: String? = null, var secureAreaSetupDone: Boolean = false, var tosAcknowleged: Boolean = false, var notificationPermissonRequested: Boolean = false, + var openid4VpRequest: String? = null ) { companion object { private const val TAG = "FunkeProofingState" } @FlowMethod - fun getEvidenceRequests(env: FlowEnvironment): List { + suspend fun getEvidenceRequests(env: FlowEnvironment): List { return if (access == null) { if (!tosAcknowleged) { val message = env.getInterface(Resources::class)!! @@ -83,10 +94,25 @@ class FunkeProofingState( continueWithoutPermissionButtonText = "No Thanks", assets = mapOf() )) - } else if (useGermanEId) { - listOf(EvidenceRequestGermanEid(proofingInfo.authorizeUrl, listOf())) } else { - listOf(EvidenceRequestWeb(proofingInfo.authorizeUrl, proofingInfo.landingUrl)) + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val authorizationMetadata = metadata.authorizationServers[0] + val authorizeUrl = "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { + add("client_id", issuanceClientId) + add("request_uri", proofingInfo.requestUri!!) + } + if (authorizationMetadata.useGermanEId) { + listOf(EvidenceRequestGermanEid(authorizeUrl, listOf())) + } else if (openid4VpRequest != null) { + val uri = URI(authorizationMetadata.authorizationChallengeEndpoint!!) + val origin = uri.scheme + ":" + uri.authority + listOf( + EvidenceRequestOpenid4Vp(origin, openid4VpRequest!!), + EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl) + ) + } else { + listOf(EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl)) + } } } else if (secureAreaIdentifier == null) { listOf( @@ -130,7 +156,14 @@ class FunkeProofingState( is EvidenceResponseGermanEid -> if (evidenceResponse.url != null) { processRedirectUrl(env, evidenceResponse.url) } - is EvidenceResponseWeb -> obtainTokenUsingCode(env, evidenceResponse.response, null) + is EvidenceResponseWeb -> { + val index = evidenceResponse.response.indexOf("code=") + if (index < 0) { + throw IllegalStateException("No code after web authorization") + } + val authCode = evidenceResponse.response.substring(index + 5) + obtainTokenUsingCode(env, authCode, null) + } is EvidenceResponseMessage -> { if (!evidenceResponse.acknowledged) { throw IssuingAuthorityException("Issuance rejected") @@ -154,6 +187,9 @@ class FunkeProofingState( is EvidenceResponseNotificationPermission -> { notificationPermissonRequested = true } + is EvidenceResponseOpenid4Vp -> { + processOpenid4VpResponse(env, evidenceResponse.response) + } else -> throw IllegalArgumentException("Unexpected evidence type") } } @@ -182,28 +218,68 @@ class FunkeProofingState( Logger.e(TAG, "No DPoP nonce in authentication response") throw IllegalStateException("No DPoP nonce in authentication response") } - obtainTokenUsingCode(env, response.headers["Location"]!!, dpopNonce) + val location = response.headers["Location"]!! + val code = location.substring(location.indexOf("code=") + 5) + obtainTokenUsingCode(env, code, dpopNonce) } private suspend fun obtainTokenUsingCode( env: FlowEnvironment, - location: String, + authCode: String, dpopNonce: String? ) { - val index = location.indexOf("code=") - if (index < 0) { - throw IllegalStateException("No code after web authorization") - } - val code = location.substring(index + 5) + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) this.access = FunkeUtil.obtainToken( env = env, - tokenUrl = tokenUri, + tokenUrl = metadata.authorizationServers[0].tokenEndpoint, clientId = clientId, issuanceClientId = issuanceClientId, - authorizationCode = code, + authorizationCode = authCode, codeVerifier = proofingInfo.pkceCodeVerifier, dpopNonce = dpopNonce ) Logger.i(TAG, "Token request: success") } + + private suspend fun processOpenid4VpResponse(env: FlowEnvironment, response: String) { + val body = String(openid4VpRequest!!.split('.')[1].fromBase64Url()) + val url = Json.parseToJsonElement(body).jsonObject["response_uri"]!!.jsonPrimitive.content + val state = Json.parseToJsonElement(body).jsonObject["state"]!!.jsonPrimitive.content + val httpClient = env.getInterface(HttpClient::class)!! + val resp = httpClient.post(url) { + headers { + append("Content-Type", "application/x-www-form-urlencoded") + } + setBody(FormUrlEncoder { + add("response", response) + add("state", state) + }.toString()) + } + val parsedResponse = Json.parseToJsonElement(String(resp.readBytes())).jsonObject + val presentationCode = + parsedResponse["presentation_during_issuance_session"]!!.jsonPrimitive.content + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val authorizationMetadata = metadata.authorizationServers[0] + val dpop = FunkeUtil.generateDPoP(env, clientId, + authorizationMetadata.authorizationChallengeEndpoint!!, null, null) + val challengeRequest = FormUrlEncoder { + add("auth_session", proofingInfo.authSession!!) + add("presentation_during_issuance_session", presentationCode) + }.toString() + val challengeResponse = httpClient.post( + authorizationMetadata.authorizationChallengeEndpoint) { + headers { + append("DPoP", dpop) + append("Content-Type", "application/x-www-form-urlencoded") + } + setBody(challengeRequest) + } + if (challengeResponse.status != HttpStatusCode.OK) { + throw IllegalStateException("failed to authorize") + } + val parsedChallengeResponse = + Json.parseToJsonElement(String(challengeResponse.readBytes())).jsonObject + val authCode = parsedChallengeResponse["authorization_code"]!!.jsonPrimitive.content + obtainTokenUsingCode(env, authCode, null) + } } \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofingInfo.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofingInfo.kt index 4cf25211d..14751c659 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofingInfo.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofingInfo.kt @@ -4,7 +4,9 @@ import com.android.identity.cbor.annotation.CborSerializable @CborSerializable data class ProofingInfo( - val authorizeUrl: String, + val requestUri: String?, val pkceCodeVerifier: String, - val landingUrl: String + val landingUrl: String, + val authSession: String?, + val openid4VpPresentation: String?, ) \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt index 97fbd8e62..2d6b8e98b 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt @@ -123,9 +123,12 @@ internal data class Openid4VciIssuerMetadata( SUPPORTED_SIGNATURE_ALGORITHMS ) ?: return null val authorizationEndpoint = jsonObject["authorization_endpoint"]!!.jsonPrimitive.content + val authorizationChallengeEndpoint = + jsonObject["authorization_challenge_endpoint"]?.jsonPrimitive?.content return Openid4VciAuthorizationMetadata( pushedAuthorizationRequestEndpoint = jsonObject["pushed_authorization_request_endpoint"]!!.jsonPrimitive.content, authorizationEndpoint = authorizationEndpoint, + authorizationChallengeEndpoint = authorizationChallengeEndpoint, tokenEndpoint = jsonObject["token_endpoint"]!!.jsonPrimitive.content, responseType = responseType, codeChallengeMethod = codeChallengeMethod, @@ -178,6 +181,7 @@ internal data class Openid4VciIssuerMetadata( internal data class Openid4VciAuthorizationMetadata( val pushedAuthorizationRequestEndpoint: String, val authorizationEndpoint: String, + val authorizationChallengeEndpoint: String?, val tokenEndpoint: String, val responseType: String, val codeChallengeMethod: String, diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt new file mode 100644 index 000000000..12d790a51 --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt @@ -0,0 +1,65 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.flow.handler.InvalidRequestException +import com.android.identity.flow.server.Storage +import com.android.identity.util.toBase64Url +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.net.URLEncoder +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class AuthorizeChallengeServlet : BaseServlet() { + override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { + val authSession = req.getParameter("auth_session") + val id = if (authSession == null) { + // Initial call, authSession was not yet established + runBlocking { + createSession(environment, req) + } + } else { + codeToId(OpaqueIdType.AUTH_SESSION, authSession) + } + val presentation = req.getParameter("presentation_during_issuance_session") + val storage = environment.getInterface(Storage::class)!! + val state = runBlocking { + IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + } + if (authSession != null) { + authorizeWithDpop( + state.dpopKey, + req, + state.dpopNonce?.toByteArray()?.toBase64Url(), + null + ) + } + if (presentation == null) { + val expirationSeconds = 600 + val code = idToCode(OpaqueIdType.PAR_CODE, id, expirationSeconds.seconds) + val openid4VpCode = idToCode(OpaqueIdType.OPENID4VP_CODE, id, 5.minutes) + val requestUri = URLEncoder.encode( + AuthorizeServlet.OPENID4VP_REQUEST_URI_PREFIX + openid4VpCode, + "UTF-8" + ) + resp.status = 400 + resp.writer.write(buildJsonObject { + put("error", "insufficient_authorization") + put("presentation", "$baseUrl/authorize?request_uri=$requestUri") + put("auth_session", idToCode(OpaqueIdType.AUTH_SESSION, id, 5.minutes)) + put("request_uri", "urn:ietf:params:oauth:request_uri:$code") + put("expires_in", expirationSeconds) + + }.toString()) + } else { + if (codeToId(OpaqueIdType.OPENID4VP_PRESENTATION, presentation) != id) { + throw IllegalStateException("Bad presentation code") + } + resp.writer.write(buildJsonObject { + put("authorization_code", idToCode(OpaqueIdType.REDIRECT, id, 2.minutes)) + }.toString()) + } + } +} \ No newline at end of file diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt index fed3c6880..49e06dcfd 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt @@ -3,8 +3,6 @@ package com.android.identity.server.openid4vci import com.android.identity.cbor.Cbor import com.android.identity.cbor.CborArray import com.android.identity.cbor.CborMap -import com.android.identity.cbor.DataItem -import com.android.identity.cbor.DiagnosticOption import com.android.identity.cbor.Simple import com.android.identity.cbor.Tstr import com.android.identity.crypto.Algorithm @@ -13,13 +11,11 @@ import com.android.identity.crypto.EcCurve import com.android.identity.crypto.EcPublicKey import com.android.identity.crypto.EcPublicKeyDoubleCoordinate import com.android.identity.document.NameSpacedData -import com.android.identity.flow.server.Configuration +import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.Resources import com.android.identity.flow.server.Storage import com.android.identity.mdoc.response.DeviceResponseParser import com.android.identity.util.fromBase64Url -import com.android.identity.util.fromHex -import com.android.identity.util.toBase64Url import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import kotlinx.coroutines.runBlocking @@ -28,29 +24,40 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.net.URI -import java.net.URL import kotlin.time.Duration.Companion.minutes /** - * Process the request to run web-based authorization. This is typically the second request - * (after [ParServlet] request) and it is sent from the browser. + * Initialize authorization workflow of some sort, based on `request_uri` parameter. * - * Specifics of how the web authorization session is run actually do not matter much for the - * Wallet App and Wallet Server, as long as the session results in redirecting (or resolving) - * redirect_uri supplied to [ParServlet] on the previous step. + * When `request_uri` starts with `urn:ietf:params:oauth:request_uri:` run web-based authorization. + * In this case the request is typically sent from the browser. Specifics of how the web + * authorization session is run actually do not matter much for the Wallet App and Wallet Server, + * as long as the session results in redirecting (or resolving) `redirect_uri` supplied + * to [ParServlet] on the previous step. */ class AuthorizeServlet : BaseServlet() { companion object { const val RESOURCE_BASE = "openid4vci" + + const val OAUTH_REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:" + const val OPENID4VP_REQUEST_URI_PREFIX = "https://rp.example.com/oidc/request/" } - /** - * Create a simple web page for the user to authorize the credential issuance. - */ override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { + val requestUri = req.getParameter("request_uri") ?: "" + if (requestUri.startsWith(OAUTH_REQUEST_URI_PREFIX)) { + // Create a simple web page for the user to authorize the credential issuance. + getHtml(requestUri.substring(OAUTH_REQUEST_URI_PREFIX.length), resp) + } else if (requestUri.startsWith(OPENID4VP_REQUEST_URI_PREFIX)) { + // Request a presentation using openid4vp + getOpenid4Vp(requestUri.substring(OPENID4VP_REQUEST_URI_PREFIX.length), resp) + } else { + throw InvalidRequestException("Invalid or missing 'request_uri' parameter") + } + } + + private fun getHtml(code: String, resp: HttpServletResponse) { val resources = environment.getInterface(Resources::class)!! - val requestUri = req.getParameter("request_uri") - val code = requestUri.substring(requestUri.lastIndexOf(":") + 1) val id = codeToId(OpaqueIdType.PAR_CODE, code) val authorizationCode = idToCode(OpaqueIdType.AUTHORIZATION_STATE, id, 20.minutes) val pidReadingCode = idToCode(OpaqueIdType.PID_READING, id, 20.minutes) @@ -59,7 +66,25 @@ class AuthorizeServlet : BaseServlet() { resp.writer.print( authorizeHtml .replace("\$authorizationCode", authorizationCode) - .replace("\$pidReadingCode", pidReadingCode)) + .replace("\$pidReadingCode", pidReadingCode) + ) + } + + private fun getOpenid4Vp(code: String, resp: HttpServletResponse) { + val id = codeToId(OpaqueIdType.OPENID4VP_CODE, code) + val stateRef = idToCode(OpaqueIdType.OPENID4VP_STATE, id, 5.minutes) + val storage = environment.getInterface(Storage::class)!! + val responseUri = "$baseUrl/openid4vp-response" + val jwt = runBlocking { + val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val session = initiateOpenid4Vp(state.clientId, responseUri, stateRef) + state.pidReadingKey = session.privateKey + state.pidNonce = session.nonce + storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + session.jwt + } + resp.contentType = "application/oauth-authz-req+jwt" + resp.writer.write(jwt) } /** @@ -71,7 +96,7 @@ class AuthorizeServlet : BaseServlet() { val extraInfo = req.getParameter("extraInfo") val id = codeToId(OpaqueIdType.AUTHORIZATION_STATE, code) val storage = environment.getInterface(Storage::class)!! - val configuration = environment.getInterface(Configuration::class)!! + val baseUri = URI(this.baseUrl) val tokenData = Json.parseToJsonElement(pidData).jsonObject["token"]!! .jsonPrimitive.content.fromBase64Url() @@ -79,7 +104,6 @@ class AuthorizeServlet : BaseServlet() { val (cipherText, encapsulatedPublicKey) = parseCredentialDocument(tokenData) runBlocking { - val baseUri = URI(configuration.getValue("base_url")!!) val origin = baseUri.scheme + "://" + baseUri.authority val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) val encodedKey = (state.pidReadingKey!!.publicKey as EcPublicKeyDoubleCoordinate).asUncompressedPointEncoding diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt index ca5fe76d6..2def04cbc 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt @@ -7,6 +7,7 @@ import com.android.identity.flow.handler.AesGcmCipher import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.handler.SimpleCipher +import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Storage import com.android.identity.server.BaseHttpServlet @@ -49,6 +50,10 @@ abstract class BaseServlet: BaseHttpServlet() { return null } + protected val baseUrl: String + get() = environment.getInterface(Configuration::class)!! + .getValue("base_url") + "/openid4vci" + /** * Creates an opaque session id ("code") that can be safely given to the client. On the server * the session is just identified by its id, which stays the same. When referencing the session diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt new file mode 100644 index 000000000..ea42a6c97 --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt @@ -0,0 +1,134 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborArray +import com.android.identity.cbor.Simple +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.javaPrivateKey +import com.android.identity.crypto.javaPublicKey +import com.android.identity.document.NameSpacedData +import com.android.identity.flow.server.Storage +import com.android.identity.mdoc.response.DeviceResponseParser +import com.android.identity.util.fromBase64Url +import com.nimbusds.jose.crypto.ECDHDecrypter +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jwt.EncryptedJWT +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kotlinx.coroutines.runBlocking +import kotlinx.io.bytestring.ByteString +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import kotlin.time.Duration.Companion.minutes + +class Openid4VpResponseServlet: BaseServlet() { + override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { + val stateCode = req.getParameter("state")!! + val id = codeToId(OpaqueIdType.OPENID4VP_STATE, stateCode) + val storage = environment.getInterface(Storage::class)!! + val state = runBlocking { + IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + } + + val encryptedJWT = EncryptedJWT.parse(req.getParameter("response")!!) + + val encPub = state.pidReadingKey!!.publicKey.javaPublicKey as ECPublicKey + val encPriv = state.pidReadingKey!!.javaPrivateKey as ECPrivateKey + + val encKey = ECKey( + Curve.P_256, + encPub, + encPriv, + null, + null, + null, + null, + null, + null, + null, + null, + null + ) + + 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( + clientId = state.clientId, + responseUri = responseUri, + authorizationRequestNonce = encryptedJWT.header.agreementPartyVInfo.toString(), + mdocGeneratedNonce = encryptedJWT.header.agreementPartyUInfo.toString() + ) + + val parser = DeviceResponseParser(deviceResponse, sessionTranscript) + val parsedResponse = parser.parse() + + val data = NameSpacedData.Builder() + for (document in parsedResponse.documents) { + for (namespaceName in document.issuerNamespaces) { + for (dataElementName in document.getIssuerEntryNames(namespaceName)) { + val value = document.getIssuerEntryData(namespaceName, dataElementName) + data.putEntry(namespaceName, dataElementName, value) + } + } + } + + state.credentialData = data.build() + runBlocking { + storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + } + + val presentation = idToCode(OpaqueIdType.OPENID4VP_PRESENTATION, id, 5.minutes) + resp.writer.write(buildJsonObject { + put("presentation_during_issuance_session", presentation) + }.toString()) + } +} + +// defined in ISO 18013-7 Annex B +private fun createSessionTranscriptOpenID4VP( + clientId: String, + responseUri: String, + authorizationRequestNonce: String, + mdocGeneratedNonce: String +): ByteArray { + val clientIdToHash = Cbor.encode( + CborArray.builder() + .add(clientId) + .add(mdocGeneratedNonce) + .end() + .build()) + val clientIdHash = Crypto.digest(Algorithm.SHA256, clientIdToHash) + + val responseUriToHash = Cbor.encode( + CborArray.builder() + .add(responseUri) + .add(mdocGeneratedNonce) + .end() + .build()) + val responseUriHash = Crypto.digest(Algorithm.SHA256, responseUriToHash) + + val oid4vpHandover = CborArray.builder() + .add(clientIdHash) + .add(responseUriHash) + .add(authorizationRequestNonce) + .end() + .build() + + return Cbor.encode( + CborArray.builder() + .add(Simple.NULL) + .add(Simple.NULL) + .add(oid4vpHandover) + .end() + .build() + ) +} diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt index 0d08d30af..ae892cfd1 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt @@ -36,61 +36,8 @@ class ParServlet : BaseServlet() { } override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { - // Read all parameters - if (req.getParameter("client_assertion_type") != ASSERTION_TYPE) { - throw InvalidRequestException("invalid parameter 'client_assertion_type'") - } - val scope = req.getParameter("scope") ?: "" - if (!CredentialFactory.supportedScopes.contains(scope)) { - throw InvalidRequestException("invalid parameter 'scope'") - } - if (req.getParameter("response_type") != "code") { - throw InvalidRequestException("invalid parameter 'response_type'") - } - if (req.getParameter("code_challenge_method") != "S256") { - throw InvalidRequestException("invalid parameter 'code_challenge_method'") - } - val redirectUri = req.getParameter("redirect_uri") - ?: throw InvalidRequestException("missing parameter 'redirect_uri'") - val clientId = req.getParameter("client_id") - ?: throw InvalidRequestException("missing parameter 'client_id'") - val codeChallenge = try { - ByteString(req.getParameter("code_challenge").fromBase64Url()) - } catch (err: Exception) { - throw InvalidRequestException("invalid parameter 'code_challenge'") - } - val clientAssertion = req.getParameter("client_assertion") - - // Check that client assertion is signed by the trusted client. - val sequence = clientAssertion.split("~") - val clientCertificate = runBlocking { - environment.cache( - ClientCertificate::class, - clientId - ) { configuration, resources -> - // So in real life this should be parameterized by clientId, as different clients will - // have different public keys. - val certificateName = configuration.getValue("attestation.certificate") - ?: "attestation/certificate.pem" - val certificate = X509Cert.fromPem(resources.getStringResource(certificateName)!!) - ClientCertificate(certificate) - } - } - checkJwtSignature(clientCertificate.certificate.ecPublicKey, sequence[0]) - - // Extract session key (used in DPoP authorization for subsequent requests). - val parts = sequence[0].split(".") - if (parts.size != 3) { - throw InvalidRequestException("invalid JWT assertion") - } - val assertionBody = Json.parseToJsonElement(String(parts[1].fromBase64Url(), Charsets.UTF_8)) - val dpopKey = JsonWebKey((assertionBody as JsonObject)["cnf"] as JsonObject).asEcPublicKey - - // Create a session - val storage = environment.getInterface(Storage::class)!! - val state = IssuanceState(clientId, scope, dpopKey, redirectUri, codeChallenge) val id = runBlocking { - storage.insert("IssuanceState", "", ByteString(state.toCbor())) + createSession(environment, req) } // Format the result (session identifying information). @@ -106,6 +53,4 @@ class ParServlet : BaseServlet() { ) ).toByteArray()) } - - private data class ClientCertificate(val certificate: X509Cert) } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt index 73a5fd7c8..b1f8a526b 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt @@ -9,11 +9,13 @@ import kotlinx.serialization.json.buildJsonObject class WellKnownOauthAuthorizationServlet : BaseServlet() { override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { - val configuration = environment.getInterface(Configuration::class)!! - val baseUrl = configuration.getValue("base_url") + "/openid4vci" + val baseUrl = this.baseUrl resp.writer.write(buildJsonObject { put("issuer", JsonPrimitive(baseUrl)) put("authorization_endpoint", JsonPrimitive("$baseUrl/authorize")) + // OAuth for First-Party Apps (FiPA) + put("authorization_challenge_endpoint", + JsonPrimitive("$baseUrl/authorize-challenge")) put("token_endpoint", JsonPrimitive("$baseUrl/token")) put("pushed_authorization_request_endpoint", JsonPrimitive("$baseUrl/par")) put("require_pushed_authorization_requests", JsonPrimitive(true)) diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt index 4ea826f3c..e4d2986d9 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt @@ -14,7 +14,7 @@ class WellKnownOpenidCredentialIssuerServlet : BaseServlet() { } override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { val configuration = environment.getInterface(Configuration::class)!! - val baseUrl = configuration.getValue("base_url") + "/openid4vci" + val baseUrl = this.baseUrl resp.writer.write(buildJsonObject { put("credential_issuer", JsonPrimitive(baseUrl)) put("credential_endpoint", JsonPrimitive("$baseUrl/credential")) diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt new file mode 100644 index 000000000..4c11bef1b --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt @@ -0,0 +1,71 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.crypto.X509Cert +import com.android.identity.flow.handler.InvalidRequestException +import com.android.identity.flow.server.FlowEnvironment +import com.android.identity.flow.server.Storage +import com.android.identity.issuance.common.cache +import com.android.identity.sdjwt.util.JsonWebKey +import com.android.identity.util.fromBase64Url +import jakarta.servlet.http.HttpServletRequest +import kotlinx.io.bytestring.ByteString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +suspend fun createSession(environment: FlowEnvironment, req: HttpServletRequest): String { + // Read all parameters + if (req.getParameter("client_assertion_type") != ParServlet.ASSERTION_TYPE) { + throw InvalidRequestException("invalid parameter 'client_assertion_type'") + } + val scope = req.getParameter("scope") ?: "" + if (!CredentialFactory.supportedScopes.contains(scope)) { + throw InvalidRequestException("invalid parameter 'scope'") + } + if (req.getParameter("response_type") != "code") { + throw InvalidRequestException("invalid parameter 'response_type'") + } + if (req.getParameter("code_challenge_method") != "S256") { + throw InvalidRequestException("invalid parameter 'code_challenge_method'") + } + val redirectUri = req.getParameter("redirect_uri") + ?: throw InvalidRequestException("missing parameter 'redirect_uri'") + val clientId = req.getParameter("client_id") + ?: throw InvalidRequestException("missing parameter 'client_id'") + val codeChallenge = try { + ByteString(req.getParameter("code_challenge").fromBase64Url()) + } catch (err: Exception) { + throw InvalidRequestException("invalid parameter 'code_challenge'") + } + val clientAssertion = req.getParameter("client_assertion") + + // Check that client assertion is signed by the trusted client. + val sequence = clientAssertion.split("~") + val clientCertificate = + environment.cache( + ClientCertificate::class, + clientId + ) { configuration, resources -> + // So in real life this should be parameterized by clientId, as different clients will + // have different public keys. + val certificateName = configuration.getValue("attestation.certificate") + ?: "attestation/certificate.pem" + val certificate = X509Cert.fromPem(resources.getStringResource(certificateName)!!) + ClientCertificate(certificate) + } + checkJwtSignature(clientCertificate.certificate.ecPublicKey, sequence[0]) + + // Extract session key (used in DPoP authorization for subsequent requests). + val parts = sequence[0].split(".") + if (parts.size != 3) { + throw InvalidRequestException("invalid JWT assertion") + } + val assertionBody = Json.parseToJsonElement(String(parts[1].fromBase64Url(), Charsets.UTF_8)) + val dpopKey = JsonWebKey((assertionBody as JsonObject)["cnf"] as JsonObject).asEcPublicKey + + // Create a session + val storage = environment.getInterface(Storage::class)!! + val state = IssuanceState(clientId, scope, dpopKey, redirectUri, codeChallenge) + return storage.insert("IssuanceState", "", ByteString(state.toCbor())) +} + +private data class ClientCertificate(val certificate: X509Cert) diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt index 56ca5875d..47bf063b4 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt @@ -1,6 +1,5 @@ package com.android.identity.server.openid4vci -import com.android.identity.cbor.DataItem import com.android.identity.cbor.annotation.CborSerializable import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey @@ -39,6 +38,7 @@ data class IssuanceState( var dpopNonce: ByteString? = null, var cNonce: ByteString? = null, var pidReadingKey: EcPrivateKey? = null, + var pidNonce: String? = null, var credentialData: NameSpacedData? = null ) { companion object @@ -54,5 +54,9 @@ enum class OpaqueIdType { REDIRECT, ACCESS_TOKEN, REFRESH_TOKEN, - PID_READING + PID_READING, + AUTH_SESSION, // for use in /authorize_challenge + OPENID4VP_CODE, // send to /authorize when we want openid4vp request + OPENID4VP_STATE, // for state field in openid4vp + OPENID4VP_PRESENTATION // for use in presentation_during_issuance_session } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/openid4vp.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/openid4vp.kt new file mode 100644 index 000000000..998fabb1e --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/openid4vp.kt @@ -0,0 +1,233 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.crypto.EcPrivateKey +import com.android.identity.crypto.EcPublicKey +import com.android.identity.crypto.EcPublicKeyDoubleCoordinate +import com.android.identity.crypto.X509Cert +import com.android.identity.crypto.X509CertChain +import com.android.identity.crypto.X509CertificateCreateOption +import com.android.identity.crypto.X509CertificateExtension +import com.android.identity.crypto.create +import com.android.identity.crypto.javaX509Certificate +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.knowntypes.EUPersonalID +import com.android.identity.sdjwt.util.JsonWebKey +import com.android.identity.util.toBase64Url +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimePeriod +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.x509.ExtendedKeyUsage +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.KeyPurposeId +import org.bouncycastle.asn1.x509.KeyUsage +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import kotlin.random.Random + +data class Openid4VpSession( + val jwt: String, + val privateKey: EcPrivateKey, + val nonce: String +) + +fun initiateOpenid4Vp( + clientId: String, + responseUri: String, + state: String +): Openid4VpSession { + val (singleUseReaderKeyPriv, singleUseReaderKeyCertChain) = createSingleUseReaderKey() + + val request = EUPersonalID.getDocumentType().sampleRequests.first { it.id == "full" } + val nonce = Random.nextBytes(15).toBase64Url() + val publicKey = singleUseReaderKeyPriv.publicKey + + val header = buildJsonObject { + put("typ", JsonPrimitive("oauth-authz-req+jwt")) + put("alg", JsonPrimitive(publicKey.curve.defaultSigningAlgorithm.jwseAlgorithmIdentifier)) + put("jwk", publicKey.toJson(null)) + put("x5c", buildJsonArray { + for (cert in singleUseReaderKeyCertChain.certificates) { + add(cert.encodedCertificate.toBase64Url()) + } + }) + }.toString().toByteArray().toBase64Url() + + 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) + put("presentation_definition", mdocCalcPresentationDefinition(request)) + put("client_metadata", calcClientMetadata(singleUseReaderKeyPriv.publicKey)) + }.toString().toByteArray().toBase64Url() + + val message = "$header.$body" + val signature = Crypto.sign(singleUseReaderKeyPriv, Algorithm.ES256, message.toByteArray()) + .toCoseEncoded().toBase64Url() + + return Openid4VpSession("$message.$signature", singleUseReaderKeyPriv, nonce) +} + +private fun EcPublicKey.toJson(keyId: String?): JsonObject { + return JsonWebKey(this).toRawJwk { + if (keyId != null) { + put("kid", JsonPrimitive(keyId)) + } + put("alg", JsonPrimitive(curve.defaultSigningAlgorithm.jwseAlgorithmIdentifier)) + put("use", JsonPrimitive("sig")) + } +} + +private fun mdocCalcPresentationDefinition( + request: DocumentWellKnownRequest +): JsonObject { + return buildJsonObject { + // Fill in a unique ID. + put("id", Random.Default.nextBytes(15).toBase64Url()) + put("input_descriptors", buildJsonArray { + add(buildJsonObject { + put("id", request.mdocRequest!!.docType) + put("format", buildJsonObject { + put("mso_mdoc", buildJsonObject { + put("alg", buildJsonArray { + add("ES256") + }) + }) + }) + put("constraints", buildJsonObject { + put("limit_disclosure", "required") + put("fields", buildJsonArray { + for (ns in request.mdocRequest!!.namespacesToRequest) { + for ((de, intentToRetain) in ns.dataElementsToRequest) { + add(buildJsonObject { + put("path", buildJsonArray { + add("\$['${ns.namespace}']['${de.attribute.identifier}']") + }) + put("intent_to_retain", intentToRetain) + }) + } + } + }) + }) + }) + }) + } +} + +private fun createSingleUseReaderKey(): Pair { + val now = Clock.System.now() + val validFrom = now.plus(DateTimePeriod(minutes = -10), TimeZone.currentSystemDefault()) + val validUntil = now.plus(DateTimePeriod(minutes = 10), TimeZone.currentSystemDefault()) + val readerKey = Crypto.createEcPrivateKey(EcCurve.P256) + + val extensions = mutableListOf() + extensions.add( + X509CertificateExtension( + Extension.keyUsage.toString(), + true, + KeyUsage(KeyUsage.digitalSignature).encoded + ) + ) + extensions.add( + X509CertificateExtension( + Extension.extendedKeyUsage.toString(), + true, + ExtendedKeyUsage( + KeyPurposeId.getInstance(ASN1ObjectIdentifier("1.0.18013.5.1.2")) + ).encoded + ) + ) + val readerKeySubject = "CN=OWF IC Online Verifier Single-Use Reader Key" + + // TODO: for now, instead of using the per-site Reader Root generated at first run, use the + // well-know OWF IC Reader root checked into Git. + val owfIcReaderCert = X509Cert.fromPem(""" +-----BEGIN CERTIFICATE----- +MIICCTCCAY+gAwIBAgIQZc/0rhdjZ9n3XoZYzpt2GjAKBggqhkjOPQQDAzA+MS8wLQYDVQQDDCZP +V0YgSWRlbnRpdHkgQ3JlZGVudGlhbCBURVNUIFJlYWRlciBDQTELMAkGA1UEBhMCWlowHhcNMjQw +OTE3MTY1NjA5WhcNMjkwOTE3MTY1NjA5WjA+MS8wLQYDVQQDDCZPV0YgSWRlbnRpdHkgQ3JlZGVu +dGlhbCBURVNUIFJlYWRlciBDQTELMAkGA1UEBhMCWlowdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATM +1ZVDQ7E4A+ujJl0J7Op8qvy/BSgg/UCTw+WrwYI32/jV9pk8Qu5BSTbUDZE2PQheqy4s3j8y1gMu ++Q5pemhYn/c4OMYXZY8uD+t4Wo9UFoSDkFbvlumZ/cuO5TTAI76jUjBQMB0GA1UdDgQWBBTgtILK +HJ50qO/Nc33zshz2aX4+4TAfBgNVHSMEGDAWgBTgtILKHJ50qO/Nc33zshz2aX4+4TAOBgNVHQ8B +Af8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIxALmOcU+Ggax3wHbD8tcd8umuDxzimf9PSICjvlh5 +kwR0/1SZZF7bqMAOQXsrwNYFLgIwLVirmU4WvRlUktR2Ty5kxgDG0iy+g00ur9JXCF+wAUQjKHbg +VvIQ6NRr06GwpPJR +-----END CERTIFICATE----- + """.trimIndent()) + + val owfIcReaderRoot = EcPrivateKey.fromPem(""" +-----BEGIN PRIVATE KEY----- +MFcCAQAwEAYHKoZIzj0CAQYFK4EEACIEQDA+AgEBBDDxgrZBXnoO54/hZM2DAGrByoWRatjH9hGs +lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= +-----END PRIVATE KEY----- + """.trimIndent(), + owfIcReaderCert.ecPublicKey) + val owfIcReaderRootSignatureAlgorithm = Algorithm.ES384 + val owfIcReaderRootIssuer = owfIcReaderCert.javaX509Certificate.issuerX500Principal.name + + val readerKeyCertificate = X509Cert.create( + readerKey.publicKey, + owfIcReaderRoot, + owfIcReaderCert, + owfIcReaderRootSignatureAlgorithm, + "1", + readerKeySubject, + owfIcReaderRootIssuer, + validFrom, + validUntil, + setOf( + X509CertificateCreateOption.INCLUDE_SUBJECT_KEY_IDENTIFIER, + X509CertificateCreateOption.INCLUDE_AUTHORITY_KEY_IDENTIFIER_FROM_SIGNING_KEY_CERTIFICATE + ), + extensions + ) + return Pair( + readerKey, + X509CertChain(listOf(readerKeyCertificate) + owfIcReaderCert) + ) +} + +private fun calcClientMetadata(publicKey: EcPublicKey): JsonObject { + val encPub = publicKey as EcPublicKeyDoubleCoordinate + val formats = buildJsonObject { + put("mso_mdoc", buildJsonObject { + put("alg", buildJsonArray { + add("ES256") + }) + }) + } + return buildJsonObject { + put("authorization_encrypted_response_alg", "ECDH-ES") + put("authorization_encrypted_response_enc", "A128CBC-HS256") + put("response_mode", "direct_post.jwt") + put("vp_formats", formats) + put("vp_formats_supported", formats) + put("jwks", buildJsonObject { + put("keys", buildJsonArray { + add(buildJsonObject { + put("kty", "EC") + put("use", "enc") + put("crv", "P-256") + put("alg", "ECDH-ES") + put("x", encPub.x.toBase64Url()) + put("y", encPub.y.toBase64Url()) + }) + }) + }) + } +} + diff --git a/server/src/main/webapp/WEB-INF/web.xml b/server/src/main/webapp/WEB-INF/web.xml index cd874d691..9be74baeb 100644 --- a/server/src/main/webapp/WEB-INF/web.xml +++ b/server/src/main/webapp/WEB-INF/web.xml @@ -295,6 +295,30 @@ /openid4vci/authorize + + AuthorizeChallengeServlet + AuthorizeChallengeServlet + com.android.identity.server.openid4vci.AuthorizeChallengeServlet + 10 + + + + AuthorizeChallengeServlet + /openid4vci/authorize-challenge + + + + Openid4VpResponseServlet + Openid4VpResponseServlet + com.android.identity.server.openid4vci.Openid4VpResponseServlet + 10 + + + + Openid4VpResponseServlet + /openid4vci/openid4vp-response + + FinishAuthorizationServlet FinishAuthorizationServlet diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt index 5523b1a86..2c49e5eb6 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt @@ -4,8 +4,10 @@ import android.os.Looper import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.identity.credential.Credential import com.android.identity.document.Document import com.android.identity.document.DocumentStore +import com.android.identity.issuance.CredentialFormat import com.android.identity.issuance.DocumentExtensions.documentConfiguration import com.android.identity.issuance.DocumentExtensions.documentIdentifier import com.android.identity.issuance.DocumentExtensions.issuingAuthorityConfiguration @@ -16,17 +18,21 @@ import com.android.identity.issuance.ProofingFlow import com.android.identity.issuance.RegistrationResponse import com.android.identity.issuance.evidence.EvidenceRequest import com.android.identity.issuance.evidence.EvidenceRequestIcaoNfcTunnel +import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp import com.android.identity.issuance.evidence.EvidenceResponse import com.android.identity.issuance.evidence.EvidenceResponseIcaoNfcTunnel import com.android.identity.issuance.remote.WalletServerProvider import com.android.identity.util.Logger +import com.android.identity.util.fromBase64Url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock import kotlinx.io.bytestring.buildByteString +import org.json.JSONObject class ProvisioningViewModel : ViewModel() { @@ -80,15 +86,23 @@ class ProvisioningViewModel : ViewModel() { document = null proofingFlow = null evidenceRequests = null + currentEvidenceRequestIndex = 0 nextEvidenceRequest.value = null + selectedOpenid4VpCredential.value = null + documentStore = null + settingsModel = null } private var proofingFlow: ProofingFlow? = null var document: Document? = null private var evidenceRequests: List? = null + private var currentEvidenceRequestIndex: Int = 0 + private var documentStore: DocumentStore? = null + private var settingsModel: SettingsModel? = null val nextEvidenceRequest = mutableStateOf(null) + val selectedOpenid4VpCredential = mutableStateOf(null) fun start( walletServerProvider: WalletServerProvider, @@ -100,6 +114,8 @@ class ProvisioningViewModel : ViewModel() { credentialIssuerUri: String? = null, credentialIssuerConfigurationId: String? = null, ) { + this.documentStore = documentStore + this.settingsModel = settingsModel viewModelScope.launch(Dispatchers.IO) { try { if (credentialIssuerUri != null) { @@ -138,6 +154,7 @@ class ProvisioningViewModel : ViewModel() { proofingFlow = issuer.proof(issuerDocumentIdentifier) evidenceRequests = proofingFlow!!.getEvidenceRequests() + currentEvidenceRequestIndex = 0 Logger.d(TAG, "ers0 ${evidenceRequests!!.size}") if (evidenceRequests!!.size == 0) { state.value = State.PROOFING_COMPLETE @@ -147,7 +164,7 @@ class ProvisioningViewModel : ViewModel() { documentStore.addDocument(document!!) proofingFlow!!.complete() } else { - nextEvidenceRequest.value = evidenceRequests!!.first() + selectViableEvidenceRequest() state.value = State.EVIDENCE_REQUESTS_READY } } catch (e: Throwable) { @@ -178,7 +195,6 @@ class ProvisioningViewModel : ViewModel() { fun provideEvidence( evidence: EvidenceResponse, walletServerProvider: WalletServerProvider, - documentStore: DocumentStore ) { viewModelScope.launch(Dispatchers.IO) { try { @@ -187,20 +203,21 @@ class ProvisioningViewModel : ViewModel() { proofingFlow!!.sendEvidence(evidence) evidenceRequests = proofingFlow!!.getEvidenceRequests() + currentEvidenceRequestIndex = 0 Logger.d(TAG, "ers1 ${evidenceRequests!!.size}") if (evidenceRequests!!.size == 0) { state.value = State.PROOFING_COMPLETE document!!.refreshState(walletServerProvider) - documentStore.addDocument(document!!) + documentStore!!.addDocument(document!!) proofingFlow!!.complete() document!!.refreshState(walletServerProvider) } else { - nextEvidenceRequest.value = evidenceRequests!!.first() + selectViableEvidenceRequest() state.value = State.EVIDENCE_REQUESTS_READY } } catch (e: Throwable) { if (document != null) { - documentStore.deleteDocument(document!!.name) + documentStore!!.deleteDocument(document!!.name) } Logger.w(TAG, "Error submitting evidence", e) e.printStackTrace() @@ -252,7 +269,99 @@ class ProvisioningViewModel : ViewModel() { state.value = State.SUBMITTING_EVIDENCE state.value = State.EVIDENCE_REQUESTS_READY evidenceRequests = proofingFlow!!.getEvidenceRequests() - nextEvidenceRequest.value = evidenceRequests!!.first() + currentEvidenceRequestIndex = 0 + selectViableEvidenceRequest() + } + } + + fun moveToNextEvidenceRequest(): Boolean { + currentEvidenceRequestIndex++ + return selectViableEvidenceRequest() + } + + private fun selectViableEvidenceRequest(): Boolean { + val evidenceRequests = this.evidenceRequests!! + if (currentEvidenceRequestIndex >= evidenceRequests.size) { + return false + } + val request = evidenceRequests[currentEvidenceRequestIndex] + if (request is EvidenceRequestOpenid4Vp) { + val openid4VpCredential = selectCredential(request.request) + if (openid4VpCredential != null) { + // EvidenceRequestOpenid4Vp must not come by itself + nextEvidenceRequest.value = request + selectedOpenid4VpCredential.value = openid4VpCredential + } else { + currentEvidenceRequestIndex++ + if (currentEvidenceRequestIndex >= evidenceRequests.size) { + return false + } + nextEvidenceRequest.value = evidenceRequests[currentEvidenceRequestIndex] + selectedOpenid4VpCredential.value = null + } + } else { + nextEvidenceRequest.value = request + selectedOpenid4VpCredential.value = null + } + return true + } + + private fun selectCredential(request: String): Credential? { + val parts = request.split('.') + val openid4vpRequest = JSONObject(String(parts[1].fromBase64Url())) + + val presentationDefinition = openid4vpRequest.getJSONObject("presentation_definition") + val inputDescriptors = presentationDefinition.getJSONArray("input_descriptors") + if (inputDescriptors.length() != 1) { + throw IllegalArgumentException("Only support a single input input_descriptor") + } + val inputDescriptor = inputDescriptors.getJSONObject(0)!! + val docType = inputDescriptor.getString("id") + + // For now, we only respond to the first credential being requested. + // + // NOTE: openid4vp spec gives a non-normative example of multiple input descriptors + // as "alternatives credentials", see + // + // https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-5.1-6 + // + // Also note identity.foundation says all input descriptors MUST be satisfied, see + // + // https://identity.foundation/presentation-exchange/spec/v2.0.0/#input-descriptor + // + val credentialFormat = CredentialFormat.MDOC_MSO + val document = firstMatchingDocument(credentialFormat, docType) + return document?.findCredential(WalletApplication.CREDENTIAL_DOMAIN_MDOC, Clock.System.now()) + } + + private fun firstMatchingDocument( + credentialFormat: CredentialFormat, + docType: String + ): Document? { + // prefer the credential which is on-screen if possible + val credentialIdFromPager: String? = settingsModel!!.focusedCardId.value + if (credentialIdFromPager != null + && canDocumentSatisfyRequest(credentialIdFromPager, credentialFormat, docType) + ) { + return documentStore!!.lookupDocument(credentialIdFromPager) + } + + val docId = documentStore!!.listDocuments().firstOrNull { credentialId -> + canDocumentSatisfyRequest(credentialId, credentialFormat, docType) + } + return docId?.let { documentStore!!.lookupDocument(it) } + } + + private fun canDocumentSatisfyRequest( + credentialId: String, + credentialFormat: CredentialFormat, + docType: String + ): Boolean { + val document = documentStore!!.lookupDocument(credentialId) ?: return false + val documentConfiguration = document.documentConfiguration + return when (credentialFormat) { + CredentialFormat.MDOC_MSO -> documentConfiguration.mdocConfiguration?.docType == docType + CredentialFormat.SD_JWT_VC -> documentConfiguration.sdJwtVcDocumentConfiguration != null } } } \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt index c652a99d2..c876439de 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletNavigation.kt @@ -171,7 +171,7 @@ fun WalletNavigation( */ composable(WalletDestination.ProvisionDocument.route) { ProvisionDocumentScreen( - context = application.applicationContext, + application = application, secureAreaRepository = application.secureAreaRepository, provisioningViewModel = provisioningViewModel, onNavigate = onNavigate, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt index 90fd51d39..dffe218cc 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt @@ -57,6 +57,7 @@ import com.android.identity.issuance.evidence.EvidenceRequestIcaoNfcTunnel import com.android.identity.issuance.evidence.EvidenceRequestIcaoPassiveAuthentication import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission +import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceRequestSelfieVideo @@ -66,6 +67,7 @@ import com.android.identity.issuance.evidence.EvidenceResponseGermanEid import com.android.identity.issuance.evidence.EvidenceResponseIcaoPassiveAuthentication import com.android.identity.issuance.evidence.EvidenceResponseMessage import com.android.identity.issuance.evidence.EvidenceResponseNotificationPermission +import com.android.identity.issuance.evidence.EvidenceResponseOpenid4Vp import com.android.identity.issuance.evidence.EvidenceResponseSelfieVideo import com.android.identity.issuance.evidence.EvidenceResponseWeb import com.android.identity.issuance.remote.WalletServerProvider @@ -80,11 +82,14 @@ import com.android.identity_credential.wallet.NfcTunnelScanner import com.android.identity_credential.wallet.PermissionTracker import com.android.identity_credential.wallet.ProvisioningViewModel import com.android.identity_credential.wallet.R +import com.android.identity_credential.wallet.WalletApplication import com.android.identity_credential.wallet.ui.RichTextSnippet import com.android.identity_credential.wallet.ui.SelfieRecorder import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -122,8 +127,7 @@ fun EvidenceRequestMessageView( onClick = { provisioningViewModel.provideEvidence( evidence = EvidenceResponseMessage(false), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) }) { Text(rejectButtonText) @@ -134,8 +138,7 @@ fun EvidenceRequestMessageView( onClick = { provisioningViewModel.provideEvidence( evidence = EvidenceResponseMessage(true), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) }) { Text(evidenceRequest.acceptButtonText) @@ -158,8 +161,7 @@ fun EvidenceRequestNotificationPermissionView( SideEffect { provisioningViewModel.provideEvidence( evidence = EvidenceResponseNotificationPermission(true), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } return @@ -169,8 +171,7 @@ fun EvidenceRequestNotificationPermissionView( if (postNotificationsPermissionState.status.isGranted) { provisioningViewModel.provideEvidence( evidence = EvidenceResponseNotificationPermission(true), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } else { Column { @@ -198,8 +199,7 @@ fun EvidenceRequestNotificationPermissionView( onClick = { provisioningViewModel.provideEvidence( evidence = EvidenceResponseNotificationPermission(false), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) }) { Text(evidenceRequest.continueWithoutPermissionButtonText) @@ -637,8 +637,7 @@ fun EvidenceRequestIcaoPassiveAuthenticationView( ) { nfcData -> provisioningViewModel.provideEvidence( evidence = EvidenceResponseIcaoPassiveAuthentication(nfcData.dataGroups, nfcData.sod), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } } @@ -850,8 +849,7 @@ fun EvidenceRequestSelfieVideoView( } else { provisioningViewModel.provideEvidence( evidence = EvidenceResponseSelfieVideo(selfieResult), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } }, @@ -1038,8 +1036,7 @@ fun EvidenceRequestEIdView( ) { evidence -> provisioningViewModel.provideEvidence( evidence = evidence, - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } } @@ -1291,12 +1288,69 @@ fun EvidenceRequestWebView( ), textAlign = TextAlign.Center, modifier = Modifier.padding(8.dp), - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.bodyLarge ) } } } +@Composable +fun EvidenceRequestOpenid4Vp( + evidenceRequest: EvidenceRequestOpenid4Vp, + provisioningViewModel: ProvisioningViewModel, + walletServerProvider: WalletServerProvider, + application: WalletApplication +) { + val cx = LocalContext.current + val credential = provisioningViewModel.selectedOpenid4VpCredential.value!! + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource( + R.string.presentation_evidence_message + ), + textAlign = TextAlign.Center, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Button( + modifier = Modifier.padding(8.dp), + onClick = { + val activity = getFragmentActivity(cx) + CoroutineScope(Dispatchers.Main).launch { + val response = openid4VpPresentation( + credential, + application, + activity, + evidenceRequest.originUri, + evidenceRequest.request + ) + provisioningViewModel.provideEvidence( + evidence = EvidenceResponseOpenid4Vp(response), + walletServerProvider = walletServerProvider + ) + } + }) { + Text(text = stringResource(id = R.string.presentation_evidence_ok)) + } + Button( + modifier = Modifier.padding(8.dp), + onClick = {provisioningViewModel.moveToNextEvidenceRequest()} + ) { + Text(text = stringResource(id = R.string.presentation_evidence_cancel)) + } + } + } +} + private suspend fun handleLanding( appSupport: ApplicationSupport, redirectUri: String, @@ -1313,8 +1367,7 @@ private suspend fun handleLanding( ) provisioningViewModel.provideEvidence( evidence = EvidenceResponseWeb(""), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) return false } @@ -1328,8 +1381,7 @@ private suspend fun handleLanding( } provisioningViewModel.provideEvidence( evidence = EvidenceResponseWeb(resp), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) return false } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt index 6f93e8d97..c7c976627 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt @@ -1,7 +1,9 @@ package com.android.identity_credential.wallet.ui.destination.provisioncredential +import android.app.Activity import android.content.Context -import android.widget.Toast +import android.content.ContextWrapper +import android.util.Base64 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -13,11 +15,23 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import com.android.identity.android.mdoc.util.CredmanUtil +import com.android.identity.appsupport.ui.consent.ConsentDocument +import com.android.identity.appsupport.ui.consent.ConsentField +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.credential.Credential +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto import com.android.identity.document.DocumentStore +import com.android.identity.issuance.DocumentExtensions.documentConfiguration import com.android.identity.issuance.IssuingAuthorityException import com.android.identity.issuance.evidence.EvidenceRequestCreatePassphrase import com.android.identity.issuance.evidence.EvidenceRequestGermanEid @@ -25,27 +39,53 @@ import com.android.identity.issuance.evidence.EvidenceRequestIcaoNfcTunnel import com.android.identity.issuance.evidence.EvidenceRequestIcaoPassiveAuthentication import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission +import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceRequestSelfieVideo import com.android.identity.issuance.evidence.EvidenceRequestSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceRequestWeb import com.android.identity.issuance.evidence.EvidenceResponseCreatePassphrase +import com.android.identity.issuance.evidence.EvidenceResponseOpenid4Vp import com.android.identity.issuance.evidence.EvidenceResponseQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceResponseQuestionString import com.android.identity.issuance.evidence.EvidenceResponseSetupCloudSecureArea import com.android.identity.issuance.remote.WalletServerProvider +import com.android.identity.mdoc.credential.MdocCredential +import com.android.identity.mdoc.response.DeviceResponseGenerator import com.android.identity.securearea.SecureAreaRepository +import com.android.identity.trustmanagement.TrustPoint +import com.android.identity.util.Constants +import com.android.identity.util.fromBase64Url +import com.android.identity.util.toBase64Url import com.android.identity_credential.wallet.PermissionTracker import com.android.identity_credential.wallet.ProvisioningViewModel import com.android.identity_credential.wallet.R +import com.android.identity_credential.wallet.WalletApplication import com.android.identity_credential.wallet.navigation.WalletDestination +import com.android.identity_credential.wallet.presentation.showMdocPresentmentFlow import com.android.identity_credential.wallet.ui.ScreenWithAppBar +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.crypto.ECDHEncrypter +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.util.Base64URL +import com.nimbusds.jwt.EncryptedJWT +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.PlainJWT +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.json.JSONObject +import java.util.StringTokenizer +import kotlin.random.Random @Composable fun ProvisionDocumentScreen( - context: Context, + application: WalletApplication, secureAreaRepository: SecureAreaRepository, provisioningViewModel: ProvisioningViewModel, onNavigate: (String) -> Unit, @@ -53,6 +93,8 @@ fun ProvisionDocumentScreen( walletServerProvider: WalletServerProvider, documentStore: DocumentStore ) { + val context = application.applicationContext + ScreenWithAppBar(title = stringResource(R.string.provisioning_title), navigationIcon = { if (provisioningViewModel.state.value != ProvisioningViewModel.State.PROOFING_COMPLETE) { IconButton( @@ -104,8 +146,7 @@ fun ProvisionDocumentScreen( onAccept = { inputString -> provisioningViewModel.provideEvidence( evidence = EvidenceResponseQuestionString(inputString), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } ) @@ -118,8 +159,7 @@ fun ProvisionDocumentScreen( onAccept = { inputString -> provisioningViewModel.provideEvidence( evidence = EvidenceResponseCreatePassphrase(inputString), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } ) @@ -134,8 +174,7 @@ fun ProvisionDocumentScreen( provisioningViewModel.provideEvidence( evidence = EvidenceResponseSetupCloudSecureArea( evidenceRequest.cloudSecureAreaIdentifier), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) }, onError = { error -> @@ -172,8 +211,7 @@ fun ProvisionDocumentScreen( onAccept = { selectedOption -> provisioningViewModel.provideEvidence( evidence = EvidenceResponseQuestionMultipleChoice(selectedOption), - walletServerProvider = walletServerProvider, - documentStore = documentStore + walletServerProvider = walletServerProvider ) } ) @@ -225,6 +263,15 @@ fun ProvisionDocumentScreen( documentStore = documentStore ) } + + is EvidenceRequestOpenid4Vp -> { + EvidenceRequestOpenid4Vp( + evidenceRequest = evidenceRequest, + provisioningViewModel = provisioningViewModel, + walletServerProvider = walletServerProvider, + application = application + ) + } } } @@ -296,4 +343,159 @@ fun ProvisionDocumentScreen( } } } -} \ No newline at end of file +} + +fun getFragmentActivity(cx: Context): FragmentActivity { + var context = cx + while (context is ContextWrapper) { + if (context is Activity) { + if (context is FragmentActivity) { + return context + } + break + } + context = context.baseContext + } + + throw IllegalStateException("Not a FragmentActivity") +} + +suspend fun openid4VpPresentation( + credential: Credential, + walletApp: WalletApplication, + fragmentActivity: FragmentActivity, + originUri: String, + request: String +): String { + val parts = request.split('.') + val openid4vpRequest = JSONObject(String(parts[1].fromBase64Url())) + val nonceBase64 = openid4vpRequest.getString("nonce") + val nonce = Base64.decode(nonceBase64, Base64.NO_WRAP or Base64.URL_SAFE) + val clientID = openid4vpRequest.getString("client_id") + + val presentationDefinition = openid4vpRequest.getJSONObject("presentation_definition") + val inputDescriptors = presentationDefinition.getJSONArray("input_descriptors") + if (inputDescriptors.length() != 1) { + throw IllegalArgumentException("Only support a single input input_descriptor") + } + val inputDescriptor = inputDescriptors.getJSONObject(0)!! + val docType = inputDescriptor.getString("id") + + val constraints = inputDescriptor.getJSONObject("constraints") + val fields = constraints.getJSONArray("fields") + + val requestedData = mutableMapOf>>() + + for (n in 0 until fields.length()) { + val field = fields.getJSONObject(n) + // Only support a single path entry for now + val path = field.getJSONArray("path").getString(0)!! + // JSONPath is horrible, hacky way to parse it for demonstration purposes + val st = StringTokenizer(path, "'", false).asSequence().toList() + val namespace = st[1] as String + val name = st[3] as String + val intentToRetain = field.getBoolean("intent_to_retain") + requestedData.getOrPut(namespace) { mutableListOf() } + .add(Pair(name, intentToRetain)) + } + + val consentFields = MdocConsentField.generateConsentFields( + docType, + requestedData, + walletApp.documentTypeRepository, + credential as MdocCredential + ) + + // Generate the Session Transcript + val encodedSessionTranscript = CredmanUtil.generateBrowserSessionTranscript( + nonce, + originUri, + Crypto.digest(Algorithm.SHA256, clientID.toByteArray()) + ) + val deviceResponse = showPresentmentFlowAndGetDeviceResponse( + fragmentActivity, + credential, + consentFields, + null, + originUri, + encodedSessionTranscript + ) + + val clientMetadata = openid4vpRequest.getJSONObject("client_metadata") + val encryptionAlg = clientMetadata.getString("authorization_encrypted_response_alg") + val encryptionEnc = clientMetadata.getString("authorization_encrypted_response_enc") + + // Create the openid4vp response + val responseBody = buildJsonObject { + if (openid4vpRequest.has("state")) { + put("state", openid4vpRequest.getString("state")) + } + put("vp_token", deviceResponse.toBase64Url()) + + // TODO: do we need this? + //put("presentation_submission", Json.encodeToJsonElement(presentationSubmission)) + }.toString() + + val jwt = maybeEncryptJwtResponse(JWTClaimsSet.parse(responseBody), + encryptionAlg, encryptionEnc, nonce, clientMetadata.getJSONObject("jwks").toString()) + + return jwt.serialize() +} + +private fun maybeEncryptJwtResponse( + claimSet: JWTClaimsSet, + encryptedResponseAlg: String?, + encryptedResponseEnc: String?, + requestNonce: ByteArray, + jwks: String +): JWT { + return if (encryptedResponseAlg == null || encryptedResponseEnc == null) { + PlainJWT(claimSet) + } else { + val generatedNonce = Random.nextBytes(15) + val apv = Base64URL.encode(requestNonce) + val apu = Base64URL.encode(generatedNonce) + val responseEncryptionAlg = JWEAlgorithm.parse(encryptedResponseAlg) + val responseEncryptionMethod = EncryptionMethod.parse(encryptedResponseEnc) + val jweHeader = JWEHeader.Builder(responseEncryptionAlg, responseEncryptionMethod) + .apply { + apv.let(::agreementPartyVInfo) + apu.let(::agreementPartyUInfo) + } + .build() + val keySet = JWKSet.parse(jwks) + val jweEncrypter: ECDHEncrypter? = keySet.keys.mapNotNull { key -> + runCatching { ECDHEncrypter(key as ECKey) }.getOrNull() + ?.let { encrypter -> key to encrypter } + } + .toMap().firstNotNullOfOrNull { it.value } + EncryptedJWT(jweHeader, claimSet).apply { encrypt(jweEncrypter) } + } +} + +private suspend fun showPresentmentFlowAndGetDeviceResponse( + fragmentActivity: FragmentActivity, + mdocCredential: MdocCredential, + consentFields: List, + trustPoint: TrustPoint?, + websiteOrigin: String?, + encodedSessionTranscript: ByteArray, +): ByteArray { + val documentCborBytes = showMdocPresentmentFlow( + activity = fragmentActivity, + consentFields = consentFields, + document = ConsentDocument( + name = mdocCredential.document.documentConfiguration.displayName, + description = mdocCredential.document.documentConfiguration.typeDisplayName, + cardArt = mdocCredential.document.documentConfiguration.cardArt, + ), + relyingParty = ConsentRelyingParty(trustPoint, websiteOrigin), + credential = mdocCredential, + encodedSessionTranscript = encodedSessionTranscript, + ) + // Create ISO DeviceResponse + DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK).run { + addDocument(documentCborBytes) + return generate() + } +} diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index 6baea2b47..52b48b07e 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -357,6 +357,14 @@ Launching browser, continue thereā€¦ + + + The Issuing Authority requires you to authenticate yourself. + You can do this by presenting the eID you have in your wallet. + + Present eID + Cancel + Go back Menu