From ce913e88702fb7bab2bbad36c4177c2ebcc4f7d3 Mon Sep 17 00:00:00 2001 From: Peter Sorotokin Date: Thu, 10 Oct 2024 16:57:05 -0700 Subject: [PATCH] Implemented key attestation in openid4vci + misc. fixes. Signed-off-by: Peter Sorotokin --- .../identity/flow/handler/FlowExceptionMap.kt | 6 + .../flow/handler/InvalidRequestException.kt | 10 + .../identity/issuance/ApplicationSupport.kt | 21 +- .../funke/AbstractRequestCredentials.kt | 19 ++ .../issuance/funke/FunkeIssuerDocument.kt | 17 +- .../funke/FunkeIssuingAuthorityState.kt | 195 +++++++++++------- .../identity/issuance/funke/FunkeUtil.kt | 9 +- .../funke/KeyAttestationCredentialRequest.kt | 14 ++ ... => ProofOfPossessionCredentialRequest.kt} | 4 +- .../RequestCredentialsUsingKeyAttestation.kt | 52 +++++ ...questCredentialsUsingProofOfPossession.kt} | 18 +- .../wallet/ApplicationSupportState.kt | 145 ++++++++++--- .../issuance/wallet/WalletServerState.kt | 6 +- .../identity/processor/CborSymbolProcessor.kt | 19 +- .../identity/server/BaseFlowHttpServlet.kt | 17 +- .../identity/server/BaseHttpServlet.kt | 50 +++++ .../identity/server/openid4vci/BaseServlet.kt | 33 +-- .../openid4vci/CredentialRequestServlet.kt | 6 +- .../server/openid4vci/CredentialServlet.kt | 162 ++++++++++----- .../openid4vci/FinishAuthorizationServlet.kt | 11 +- .../identity/server/openid4vci/ParServlet.kt | 36 +--- .../server/openid4vci/TokenServlet.kt | 47 +++-- .../identity/server/openid4vci/messages.kt | 6 - .../identity/wallet/server/LandingServlet.kt | 6 +- 24 files changed, 611 insertions(+), 298 deletions(-) create mode 100644 identity-flow/src/main/java/com/android/identity/flow/handler/InvalidRequestException.kt create mode 100644 identity-issuance/src/main/java/com/android/identity/issuance/funke/AbstractRequestCredentials.kt create mode 100644 identity-issuance/src/main/java/com/android/identity/issuance/funke/KeyAttestationCredentialRequest.kt rename identity-issuance/src/main/java/com/android/identity/issuance/funke/{FunkeCredentialRequest.kt => ProofOfPossessionCredentialRequest.kt} (77%) create mode 100644 identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingKeyAttestation.kt rename identity-issuance/src/main/java/com/android/identity/issuance/funke/{FunkeRequestCredentialsState.kt => RequestCredentialsUsingProofOfPossession.kt} (85%) diff --git a/identity-flow/src/main/java/com/android/identity/flow/handler/FlowExceptionMap.kt b/identity-flow/src/main/java/com/android/identity/flow/handler/FlowExceptionMap.kt index df377ee6e..17e43b146 100644 --- a/identity-flow/src/main/java/com/android/identity/flow/handler/FlowExceptionMap.kt +++ b/identity-flow/src/main/java/com/android/identity/flow/handler/FlowExceptionMap.kt @@ -18,6 +18,12 @@ class FlowExceptionMap private constructor( private val byClass = mutableMapOf, Item<*>>() private val byId = mutableMapOf>() + init { + // This exception is the part of the framework and is always supported. + // TODO: consider adding some Kotlin exceptions (they'd need cbor serialization). + InvalidRequestException.register(this) + } + fun addException( exceptionId: String, serializer: (ExceptionT) -> DataItem, diff --git a/identity-flow/src/main/java/com/android/identity/flow/handler/InvalidRequestException.kt b/identity-flow/src/main/java/com/android/identity/flow/handler/InvalidRequestException.kt new file mode 100644 index 000000000..1454e51d2 --- /dev/null +++ b/identity-flow/src/main/java/com/android/identity/flow/handler/InvalidRequestException.kt @@ -0,0 +1,10 @@ +package com.android.identity.flow.handler + +import com.android.identity.cbor.annotation.CborSerializable +import com.android.identity.flow.annotation.FlowException + +@FlowException +@CborSerializable +class InvalidRequestException(message: String?) : RuntimeException(message) { + companion object +} diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/ApplicationSupport.kt b/identity-issuance/src/main/java/com/android/identity/issuance/ApplicationSupport.kt index 78afda1f7..c4131417f 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/ApplicationSupport.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/ApplicationSupport.kt @@ -12,11 +12,8 @@ import com.android.identity.securearea.KeyAttestation @FlowInterface interface ApplicationSupport : FlowNotifiable { /** - * Creates a "landing" URL suitable for web redirects. When a landing URL is navigated to, - * [LandingUrlNotification] is sent to the client. - * - * NB: this method returns the relative URL, server base URL should be prepended to it before - * use. + * Creates a "landing" absolute URL suitable for web redirects. When a landing URL is + * navigated to, [LandingUrlNotification] is sent to the client. */ @FlowMethod suspend fun createLandingUrl(): String @@ -25,10 +22,10 @@ interface ApplicationSupport : FlowNotifiable { * Returns the query portion of the URL which was actually used when navigating to a landing * URL, or null if navigation did not occur yet. * - * [relativeUrl] relative URL of the landing page as returned by [createLandingUrl]. + * [landingUrl] URL of the landing page as returned by [createLandingUrl]. */ @FlowMethod - suspend fun getLandingUrlStatus(relativeUrl: String): String? + suspend fun getLandingUrlStatus(landingUrl: String): String? /** * Creates OAuth JWT client assertion based on the mobile-platform-specific [KeyAttestation]. @@ -38,4 +35,14 @@ interface ApplicationSupport : FlowNotifiable { clientAttestation: KeyAttestation, targetIssuanceUrl: String ): String + + /** + * Creates OAuth JWT key attestation based on the given list of mobile-platform-specific + * [KeyAttestation]s. + */ + @FlowMethod + suspend fun createJwtKeyAttestation( + keyAttestations: List, + nonce: String + ): String } \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/AbstractRequestCredentials.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/AbstractRequestCredentials.kt new file mode 100644 index 000000000..63d9f3ec2 --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/AbstractRequestCredentials.kt @@ -0,0 +1,19 @@ +package com.android.identity.issuance.funke + +import com.android.identity.cbor.annotation.CborSerializable +import com.android.identity.flow.annotation.FlowState +import com.android.identity.issuance.CredentialConfiguration +import com.android.identity.issuance.CredentialFormat +import com.android.identity.issuance.RequestCredentialsFlow + +@FlowState( + flowInterface = RequestCredentialsFlow::class +) +abstract class AbstractRequestCredentials( + val documentId: String, + val credentialConfiguration: CredentialConfiguration, + val nonce: String, + var format: CredentialFormat? = null, +) { + companion object +} diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuerDocument.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuerDocument.kt index 6baed53ac..8fd88cdb1 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuerDocument.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuerDocument.kt @@ -5,20 +5,15 @@ import com.android.identity.issuance.CredentialData import com.android.identity.issuance.DocumentCondition import com.android.identity.issuance.DocumentConfiguration import com.android.identity.issuance.RegistrationResponse -import com.android.identity.issuance.evidence.EvidenceRequestSetupCloudSecureArea -import com.android.identity.issuance.evidence.EvidenceResponseGermanEid -import com.android.identity.securearea.SecureArea -import kotlinx.datetime.Instant @CborSerializable data class FunkeIssuerDocument( val registrationResponse: RegistrationResponse, - var state: DocumentCondition, - var access: FunkeAccess?, - var documentConfiguration: DocumentConfiguration?, - var secureAreaIdentifier: String?, - val credentialRequests: MutableList, - val credentials: MutableList + var state: DocumentCondition = DocumentCondition.PROOFING_REQUIRED, + var access: FunkeAccess? = null, + var documentConfiguration: DocumentConfiguration? = null, + var secureAreaIdentifier: String? = null, + val credentials: MutableList = mutableListOf() ) { companion object -} \ 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 d212a1a59..7c5e4e29b 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 @@ -6,6 +6,7 @@ import com.android.identity.cbor.annotation.CborSerializable import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve +import com.android.identity.crypto.EcPublicKey import com.android.identity.document.NameSpacedData import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.DocumentTypeRepository @@ -13,7 +14,6 @@ import com.android.identity.documenttype.knowntypes.EUPersonalID import com.android.identity.flow.annotation.FlowJoin import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState -import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources import com.android.identity.flow.server.Storage @@ -52,7 +52,9 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.random.Random @@ -102,9 +104,13 @@ class FunkeIssuingAuthorityState( mdocConfiguration = null, sdJwtVcDocumentConfiguration = null ), - numberOfCredentialsToRequest = 1, + // If key attestation is used we can do batch issuance and refresh credentials + // in the background. + numberOfCredentialsToRequest = + if (FunkeUtil.USE_KEY_ATTESTATION) { 3 } else { 1 }, + maxUsesPerCredentials = + if (FunkeUtil.USE_KEY_ATTESTATION) { 1 } else { Int.MAX_VALUE }, minCredentialValidityMillis = 30 * 24 * 3600L, - maxUsesPerCredentials = Int.MAX_VALUE, ) } } @@ -122,15 +128,7 @@ class FunkeIssuingAuthorityState( @FlowMethod suspend fun register(env: FlowEnvironment): FunkeRegistrationState { val documentId = createIssuerDocument(env, - FunkeIssuerDocument( - RegistrationResponse(false), - DocumentCondition.PROOFING_REQUIRED, - null, - null, - null, - mutableListOf(), - mutableListOf() - ) + FunkeIssuerDocument(RegistrationResponse(false)) ) return FunkeRegistrationState(documentId) } @@ -140,15 +138,7 @@ class FunkeIssuingAuthorityState( updateIssuerDocument( env, registrationState.documentId, - FunkeIssuerDocument( - registrationState.response!!, - DocumentCondition.PROOFING_REQUIRED, - null, // no evidence yet - null, - null, // no initial document configuration - mutableListOf(), - mutableListOf() // cpoRequests - initially empty - )) + FunkeIssuerDocument(registrationState.response!!)) } @FlowMethod @@ -239,7 +229,7 @@ class FunkeIssuingAuthorityState( suspend fun requestCredentials( env: FlowEnvironment, documentId: String - ): FunkeRequestCredentialsState { + ): AbstractRequestCredentials { val document = loadIssuerDocument(env, documentId) refreshAccessIfNeeded(env, documentId, document) @@ -276,54 +266,33 @@ class FunkeIssuingAuthorityState( ) ) } - return FunkeRequestCredentialsState( - issuanceClientId, - documentId, - configuration, - cNonce, - credentialIssuerUri = credentialIssuerUri - ) + return if (FunkeUtil.USE_KEY_ATTESTATION) { + RequestCredentialsUsingKeyAttestation(documentId, configuration, cNonce) + } else { + RequestCredentialsUsingProofOfPossession( + issuanceClientId, + documentId, + configuration, + cNonce, + credentialIssuerUri = credentialIssuerUri + ) + } } @FlowJoin - suspend fun completeRequestCredentials(env: FlowEnvironment, state: FunkeRequestCredentialsState) { - val document = loadIssuerDocument(env, state.documentId) - - val proofs = state.credentialRequests!!.map { - JsonPrimitive(it.proofOfPossessionJwtHeaderAndBody + "." + it.proofOfPossessionJwtSignature) + suspend fun completeRequestCredentials(env: FlowEnvironment, state: AbstractRequestCredentials) { + // Create appropriate request to OpenID4VCI issuer to issue credential(s) + val (request, publicKeys) = when (state) { + is RequestCredentialsUsingProofOfPossession -> + createRequestUsingProofOfPossession(state) + is RequestCredentialsUsingKeyAttestation -> + createRequestUsingKeyAttestation(env, state) + else -> throw IllegalStateException("Unsupported RequestCredential type") } - val request = mutableMapOf( - if (proofs.size == 1) { - "proof" to JsonObject( - mapOf( - "jwt" to proofs[0], - "proof_type" to JsonPrimitive("jwt") - ) - ) - } else { - "proofs" to JsonObject( - mapOf( - "jwt" to JsonArray(proofs) - ) - ) - } - ) - - val format = state.format - when (format) { - CredentialFormat.SD_JWT_VC -> { - request["format"] = JsonPrimitive("vc+sd-jwt") - request["vct"] = JsonPrimitive(FunkeUtil.SD_JWT_VCT) - } - CredentialFormat.MDOC_MSO -> { - request["format"] = JsonPrimitive("mso_mdoc") - request["doctype"] = JsonPrimitive(FunkeUtil.EU_PID_MDOC_DOCTYPE) - } - null -> throw IllegalStateException("Credential format was not specified") - } - - val credentialUrl = "${credentialIssuerUri}/credential" + // Send the request + val document = loadIssuerDocument(env, state.documentId) + val credentialUrl = "$credentialIssuerUri/credential" val access = document.access!! val dpop = FunkeUtil.generateDPoP( env, @@ -332,6 +301,8 @@ class FunkeIssuingAuthorityState( access.dpopNonce, access.accessToken ) + Logger.e(TAG,"Credential request: $request") + val httpClient = env.getInterface(HttpClient::class)!! val credentialResponse = httpClient.post(credentialUrl) { headers { @@ -339,7 +310,7 @@ class FunkeIssuingAuthorityState( append("DPoP", dpop) append("Content-Type", "application/json") } - setBody(JsonObject(request).toString()) + setBody(request.toString()) } access.cNonce = null // used up @@ -349,10 +320,7 @@ class FunkeIssuingAuthorityState( if (credentialResponse.status != HttpStatusCode.OK) { val errResponseText = String(credentialResponse.readBytes()) - Logger.e( - TAG, - "Credential request error: ${credentialResponse.status} $errResponseText" - ) + Logger.e(TAG,"Credential request error: ${credentialResponse.status} $errResponseText") // Currently in Funke case this gets document in permanent bad state, notification // is not needed as an exception will generate notification on the client side. @@ -365,19 +333,19 @@ class FunkeIssuingAuthorityState( val responseText = String(credentialResponse.readBytes()) val response = Json.parseToJsonElement(responseText) as JsonObject - val credentials = if (proofs.size == 1) { + val credentials = if (response.contains("credential")) { JsonArray(listOf(response["credential"]!!)) } else { response["credentials"] as JsonArray } - check(credentials.size == state.credentialRequests!!.size) - document.credentials.addAll(credentials.zip(state.credentialRequests!!).map { + check(credentials.size == publicKeys.size) + document.credentials.addAll(credentials.zip(publicKeys).map { val credential = it.first.jsonPrimitive.content - val publicKey = it.second.request.secureAreaBoundKeyAttestation.publicKey + val publicKey = it.second val now = Clock.System.now() - // TODO: where do we get this in SD-JWT world? + // TODO: where do we get this from? Get the real data from the credential val expiration = Clock.System.now() + 14.days - when (format) { + when (state.format!!) { CredentialFormat.SD_JWT_VC -> CredentialData(publicKey, now, expiration, CredentialFormat.SD_JWT_VC, credential.toByteArray()) CredentialFormat.MDOC_MSO -> @@ -387,6 +355,61 @@ class FunkeIssuingAuthorityState( updateIssuerDocument(env, state.documentId, document, true) } + private fun createRequestUsingProofOfPossession( + state: RequestCredentialsUsingProofOfPossession + ): Pair> { + val proofs = state.credentialRequests!!.map { + JsonPrimitive(it.proofOfPossessionJwtHeaderAndBody + "." + it.proofOfPossessionJwtSignature) + } + val publicKeys = state.credentialRequests!!.map { + it.request.secureAreaBoundKeyAttestation.publicKey + } + + val request = buildJsonObject { + if (proofs.size == 1) { + put("proof", buildJsonObject { + put("jwt", proofs[0]) + put("proof_type", JsonPrimitive("jwt")) + }) + } else { + put("proofs", buildJsonObject { + put("jwt", JsonArray(proofs)) + }) + } + putFormat(state.format) + } + + return Pair(request, publicKeys) + } + + private suspend fun createRequestUsingKeyAttestation( + env: FlowEnvironment, + state: RequestCredentialsUsingKeyAttestation + ): Pair> { + // NB: applicationSupport will only be non-null in the environment when running this code + // locally in the Android Wallet app. + val applicationSupport = env.getInterface(ApplicationSupport::class) + + val platformAttestations = + state.credentialRequests!!.map { it.secureAreaBoundKeyAttestation } + + val keyAttestation = + applicationSupport?.createJwtKeyAttestation(platformAttestations, state.nonce) + ?: ApplicationSupportState(clientId).createJwtKeyAttestation( + env, platformAttestations, state.nonce + ) + + val request = buildJsonObject { + put("proof", buildJsonObject { + put("attestation", JsonPrimitive(keyAttestation)) + put("proof_type", JsonPrimitive("attestation")) + }) + putFormat(state.format) + } + + return Pair(request, platformAttestations.map { it.publicKey }) + } + @FlowMethod suspend fun getCredentials(env: FlowEnvironment, documentId: String): List { return loadIssuerDocument(env, documentId).credentials @@ -477,9 +500,7 @@ class FunkeIssuingAuthorityState( } else { landingUrl = applicationSupport?.createLandingUrl() ?: ApplicationSupportState(clientId).createLandingUrl(env) - val configuration = env.getInterface(Configuration::class)!! - val baseUrl = configuration.getValue("base_url") - parRedirectUrl = "$baseUrl/$landingUrl" + parRedirectUrl = landingUrl } val clientKeyInfo = FunkeUtil.communicationKey(env, clientId) @@ -522,7 +543,7 @@ class FunkeIssuingAuthorityState( Logger.e(TAG, "PAR response error") throw IllegalStateException("PAR response syntax error") } - Logger.i(TAG, "Request uri: $requestUri") + Logger.i(TAG, "Request uri: ${requestUri.content}") return ProofingInfo( authorizeUrl = "${credentialIssuerUri}/authorize?" + FormUrlEncoder { add("client_id", issuanceClientId) @@ -634,4 +655,18 @@ class FunkeIssuingAuthorityState( } updateIssuerDocument(env, documentId, document, false) } +} + +private fun JsonObjectBuilder.putFormat(format: CredentialFormat?) { + when (format) { + CredentialFormat.SD_JWT_VC -> { + put("format", JsonPrimitive("vc+sd-jwt")) + put("vct", JsonPrimitive(FunkeUtil.SD_JWT_VCT)) + } + CredentialFormat.MDOC_MSO -> { + put("format", JsonPrimitive("mso_mdoc")) + put("doctype", JsonPrimitive(FunkeUtil.EU_PID_MDOC_DOCTYPE)) + } + null -> throw IllegalStateException("Credential format was not specified") + } } \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt index f5c9987ad..264554dc2 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt @@ -26,10 +26,17 @@ internal object FunkeUtil { const val TAG = "FunkeUtil" const val EU_PID_MDOC_DOCTYPE = "eu.europa.ec.eudi.pid.1" - const val SD_JWT_VCT = "urn:eu.europa.ec.eudi:pid:1" + + // Was changed recently, it seems + // TODO: read this from openid4vci server metadata (which we are yet to read). + const val SD_JWT_VCT = "https://example.bmi.bund.de/credential/pid/1.0" const val USE_AUSWEIS_SDK = true + // Determines if key attestation or of proof of possession should be used. + // TODO: decide this based on the openid4vci server metadata (which we are yet to read). + const val USE_KEY_ATTESTATION = false + private val keyCreationMutex = Mutex() suspend fun communicationKey(env: FlowEnvironment, clientId: String): KeyInfo { diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/KeyAttestationCredentialRequest.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/KeyAttestationCredentialRequest.kt new file mode 100644 index 000000000..0a1e0729c --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/KeyAttestationCredentialRequest.kt @@ -0,0 +1,14 @@ +package com.android.identity.issuance.funke + +import com.android.identity.cbor.annotation.CborSerializable +import com.android.identity.issuance.CredentialFormat +import com.android.identity.issuance.CredentialRequest + +@CborSerializable +data class KeyAttestationCredentialRequest( + val request: MutableList, + val format: CredentialFormat, + val jwtKeyAttestation: String +) { + companion object +} diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeCredentialRequest.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofOfPossessionCredentialRequest.kt similarity index 77% rename from identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeCredentialRequest.kt rename to identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofOfPossessionCredentialRequest.kt index 132db9853..7257b6a33 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeCredentialRequest.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/ProofOfPossessionCredentialRequest.kt @@ -1,13 +1,11 @@ package com.android.identity.issuance.funke import com.android.identity.cbor.annotation.CborSerializable -import com.android.identity.crypto.EcPublicKey import com.android.identity.issuance.CredentialFormat import com.android.identity.issuance.CredentialRequest -import kotlinx.io.bytestring.ByteString @CborSerializable -data class FunkeCredentialRequest( +data class ProofOfPossessionCredentialRequest( val request: CredentialRequest, val format: CredentialFormat, val proofOfPossessionJwtHeaderAndBody: String, diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingKeyAttestation.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingKeyAttestation.kt new file mode 100644 index 000000000..dd4b502a5 --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingKeyAttestation.kt @@ -0,0 +1,52 @@ +package com.android.identity.issuance.funke + +import com.android.identity.cbor.annotation.CborSerializable +import com.android.identity.flow.annotation.FlowMethod +import com.android.identity.flow.annotation.FlowState +import com.android.identity.flow.server.FlowEnvironment +import com.android.identity.issuance.CredentialConfiguration +import com.android.identity.issuance.CredentialFormat +import com.android.identity.issuance.CredentialRequest +import com.android.identity.issuance.KeyPossessionChallenge +import com.android.identity.issuance.KeyPossessionProof +import com.android.identity.issuance.RequestCredentialsFlow + +@FlowState( + flowInterface = RequestCredentialsFlow::class +) +@CborSerializable +class RequestCredentialsUsingKeyAttestation( + documentId: String, + credentialConfiguration: CredentialConfiguration, + nonce: String, + format: CredentialFormat? = null, + var credentialRequests: List? = null +) : AbstractRequestCredentials(documentId, credentialConfiguration, nonce, format) { + companion object + + @FlowMethod + fun getCredentialConfiguration( + env: FlowEnvironment, + format: CredentialFormat + ): CredentialConfiguration { + this.format = format + return credentialConfiguration + } + + @FlowMethod + fun sendCredentials( + env: FlowEnvironment, + newCredentialRequests: List + ): List { + if (credentialRequests != null) { + throw IllegalStateException("Credential requests were already sent") + } + credentialRequests = newCredentialRequests + return listOf() + } + + @FlowMethod + fun sendPossessionProofs(env: FlowEnvironment, keyPossessionProofs: List) { + throw UnsupportedOperationException("Should not be called") + } +} \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeRequestCredentialsState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt similarity index 85% rename from identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeRequestCredentialsState.kt rename to identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt index b03d9659f..b86a6f963 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeRequestCredentialsState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt @@ -20,15 +20,15 @@ import kotlinx.serialization.json.JsonPrimitive flowInterface = RequestCredentialsFlow::class ) @CborSerializable -class FunkeRequestCredentialsState( +class RequestCredentialsUsingProofOfPossession( val issuanceClientId: String, - val documentId: String = "", - val credentialConfiguration: CredentialConfiguration, - val nonce: String, - var format: CredentialFormat? = null, - var credentialRequests: List? = null, - val credentialIssuerUri:String, -) { + documentId: String, + credentialConfiguration: CredentialConfiguration, + nonce: String, + val credentialIssuerUri: String, + format: CredentialFormat? = null, + var credentialRequests: List? = null, +) : AbstractRequestCredentials(documentId, credentialConfiguration, nonce, format) { companion object @FlowMethod @@ -60,7 +60,7 @@ class FunkeRequestCredentialsState( "iat" to JsonPrimitive(Clock.System.now().epochSeconds), "nonce" to JsonPrimitive(nonce) )).toString().toByteArray().toBase64Url() - FunkeCredentialRequest(request, format!!, "$header.$body") + ProofOfPossessionCredentialRequest(request, format!!, "$header.$body") } credentialRequests = requests return requests.map { diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt index b9779ea6d..015d649ee 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt @@ -1,12 +1,10 @@ package com.android.identity.issuance.wallet import com.android.identity.cbor.annotation.CborSerializable -import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey import com.android.identity.crypto.X509Cert -import com.android.identity.crypto.X509CertChain import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.Configuration @@ -16,7 +14,6 @@ import com.android.identity.issuance.ApplicationSupport import com.android.identity.issuance.LandingUrlUnknownException import com.android.identity.issuance.WalletServerSettings import com.android.identity.issuance.common.cache -import com.android.identity.issuance.funke.FunkeUtil import com.android.identity.issuance.funke.toJson import com.android.identity.issuance.validateKeyAttestation import com.android.identity.securearea.KeyAttestation @@ -24,6 +21,7 @@ import com.android.identity.util.Logger import com.android.identity.util.toBase64Url import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject @@ -49,19 +47,25 @@ class ApplicationSupportState( val storage = env.getInterface(Storage::class)!! val id = storage.insert("Landing", "", ByteString(LandingRecord(clientId).toCbor())) Logger.i(TAG, "Created landing URL '$id'") - return URL_PREFIX + id + val configuration = env.getInterface(Configuration::class)!! + val baseUrl = configuration.getValue("base_url")!! + return "$baseUrl/$URL_PREFIX$id" } @FlowMethod - suspend fun getLandingUrlStatus(env: FlowEnvironment, baseUrl: String): String? { - if (!baseUrl.startsWith(URL_PREFIX)) { - throw IllegalStateException("baseUrl must start with $URL_PREFIX") + suspend fun getLandingUrlStatus(env: FlowEnvironment, landingUrl: String): String? { + val configuration = env.getInterface(Configuration::class)!! + val baseUrl = configuration.getValue("base_url")!! + val prefix = "$baseUrl/$URL_PREFIX" + if (!landingUrl.startsWith(prefix)) { + Logger.e(TAG, "baseUrl must start with $prefix, actual '$landingUrl'") + throw IllegalStateException("baseUrl must start with $prefix") } val storage = env.getInterface(Storage::class)!! - val id = baseUrl.substring(URL_PREFIX.length) + val id = landingUrl.substring(prefix.length) Logger.i(TAG, "Querying landing URL '$id'") - val recordData = storage.get("Landing", "", id) ?: - throw LandingUrlUnknownException("No landing url '$id'") + val recordData = storage.get("Landing", "", id) + ?: throw LandingUrlUnknownException("No landing url '$id'") val record = LandingRecord.fromCbor(recordData.toByteArray()) if (record.resolved != null) { Logger.i(TAG, "Removed landing URL '$id'") @@ -72,7 +76,8 @@ class ApplicationSupportState( @FlowMethod fun createJwtClientAssertion( - env: FlowEnvironment, attestation: KeyAttestation, targetIssuanceUrl: String): String { + env: FlowEnvironment, attestation: KeyAttestation, targetIssuanceUrl: String + ): String { val settings = WalletServerSettings(env.getInterface(Configuration::class)!!) validateKeyAttestation( @@ -87,6 +92,78 @@ class ApplicationSupportState( return createJwtClientAssertion(env, attestation.publicKey, targetIssuanceUrl) } + @FlowMethod + fun createJwtKeyAttestation( + env: FlowEnvironment, + keyAttestations: List, + nonce: String + ): String { + val settings = WalletServerSettings(env.getInterface(Configuration::class)!!) + + val keyList = keyAttestations.map { attestation -> + // TODO: ensure that keys come from the same device and extract data for key_type + // and user_authentication values + validateKeyAttestation( + attestation.certChain!!, + nonce, + settings.androidRequireGmsAttestation, + settings.androidRequireVerifiedBootGreen, + settings.androidRequireAppSignatureCertificateDigests + ) + attestation.publicKey.toJson(null) + } + + val attestationData = env.cache(AttestationData::class) { configuration, resources -> + // The key that we use here is unique for a particular Wallet ecosystem. + // Use client attestation key as default for development (default is NOT suitable + // for production, as private key CANNOT be in the source repository). + val certificateName = configuration.getValue("openid4vci.key-attestation.certificate") + ?: "attestation/certificate.pem" + val certificate = X509Cert.fromPem(resources.getStringResource(certificateName)!!) + val privateKeyName = configuration.getValue("openid4vci.key-attestation.privateKey") + ?: "attestation/private_key.pem" + val privateKey = EcPrivateKey.fromPem( + resources.getStringResource(privateKeyName)!!, + certificate.ecPublicKey + ) + val issuer = configuration.getValue("openid4vci.key-attestation.issuer") + ?: configuration.getValue("base_url") + ?: "https://github.com/openwallet-foundation-labs/identity-credential" + AttestationData(certificate, privateKey, issuer) + } + val publicKey = attestationData.certificate.ecPublicKey + val privateKey = attestationData.privateKey + val alg = publicKey.curve.defaultSigningAlgorithm.jwseAlgorithmIdentifier + val head = buildJsonObject { + put("typ", JsonPrimitive("keyattestation+jwt")) + put("alg", JsonPrimitive(alg)) + put("jwk", publicKey.toJson(null)) // TODO: use x5c instead here? + }.toString().toByteArray().toBase64Url() + + val now = Clock.System.now() + val notBefore = now - 1.seconds + val expiration = now + 5.minutes + val payload = JsonObject( + mapOf( + "iss" to JsonPrimitive(attestationData.clientId), + "attested_keys" to JsonArray(keyList), + "nonce" to JsonPrimitive(nonce), + "nbf" to JsonPrimitive(notBefore.epochSeconds), + "exp" to JsonPrimitive(expiration.epochSeconds), + "iat" to JsonPrimitive(now.epochSeconds) + // TODO: add appropriate key_type and user_authentication values + ) + ).toString().toByteArray().toBase64Url() + + val message = "$head.$payload" + val sig = Crypto.sign( + privateKey, privateKey.curve.defaultSigningAlgorithm, message.toByteArray() + ) + val signature = sig.toCoseEncoded().toBase64Url() + + return "$message.$signature" + } + // Not exposed as RPC! fun createJwtClientAssertion( env: FlowEnvironment, @@ -94,7 +171,7 @@ class ApplicationSupportState( targetIssuanceUrl: String ): String { val attestationData = env.cache( - ClientAttestationData::class, + AttestationData::class, targetIssuanceUrl ) { configuration, resources -> // These are basically our credentials to talk to a particular OpenID4VCI issuance @@ -119,7 +196,7 @@ class ApplicationSupportState( resources.getStringResource(privateKeyName)!!, certificate.ecPublicKey ) - ClientAttestationData(certificate, privateKey, clientId) + AttestationData(certificate, privateKey, clientId) } val publicKey = attestationData.certificate.ecPublicKey val privateKey = attestationData.privateKey @@ -133,31 +210,35 @@ class ApplicationSupportState( val now = Clock.System.now() val notBefore = now - 1.seconds val expiration = now + 5.minutes - val payload = JsonObject(mapOf( - "iss" to JsonPrimitive(attestationData.clientId), - // TODO: should this be clientId or applicationData.clientId? Our server does not care - // but others might. - "sub" to JsonPrimitive(attestationData.clientId), - "cnf" to JsonObject(mapOf( - "jwk" to clientPublicKey.toJson(clientId) - )), - "nbf" to JsonPrimitive(notBefore.epochSeconds), - "exp" to JsonPrimitive(expiration.epochSeconds), - "iat" to JsonPrimitive(now.epochSeconds) - )).toString().toByteArray().toBase64Url() + val payload = JsonObject( + mapOf( + "iss" to JsonPrimitive(attestationData.clientId), + // TODO: should this be clientId or applicationData.clientId? Our server does not care + // but others might. + "sub" to JsonPrimitive(attestationData.clientId), + "cnf" to JsonObject( + mapOf( + "jwk" to clientPublicKey.toJson(clientId) + ) + ), + "nbf" to JsonPrimitive(notBefore.epochSeconds), + "exp" to JsonPrimitive(expiration.epochSeconds), + "iat" to JsonPrimitive(now.epochSeconds) + ) + ).toString().toByteArray().toBase64Url() val message = "$head.$payload" val sig = Crypto.sign( - privateKey, privateKey.curve.defaultSigningAlgorithm, message.toByteArray()) + privateKey, privateKey.curve.defaultSigningAlgorithm, message.toByteArray() + ) val signature = sig.toCoseEncoded().toBase64Url() return "$message.$signature" } + internal data class AttestationData( + val certificate: X509Cert, + val privateKey: EcPrivateKey, + val clientId: String + ) } - -data class ClientAttestationData( - val certificate: X509Cert, - val privateKey: EcPrivateKey, - val clientId: String -) \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/WalletServerState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/WalletServerState.kt index b51b5726b..bcc191f27 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/WalletServerState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/WalletServerState.kt @@ -21,7 +21,8 @@ import com.android.identity.issuance.common.AbstractIssuingAuthorityState import com.android.identity.issuance.funke.FunkeIssuingAuthorityState import com.android.identity.issuance.funke.FunkeProofingState import com.android.identity.issuance.funke.FunkeRegistrationState -import com.android.identity.issuance.funke.FunkeRequestCredentialsState +import com.android.identity.issuance.funke.RequestCredentialsUsingKeyAttestation +import com.android.identity.issuance.funke.RequestCredentialsUsingProofOfPossession import com.android.identity.issuance.funke.register import com.android.identity.issuance.hardcoded.IssuingAuthorityState import com.android.identity.issuance.hardcoded.ProofingState @@ -85,7 +86,8 @@ class WalletServerState( FunkeIssuingAuthorityState.register(dispatcher) FunkeProofingState.register(dispatcher) FunkeRegistrationState.register(dispatcher) - FunkeRequestCredentialsState.register(dispatcher) + RequestCredentialsUsingProofOfPossession.register(dispatcher) + RequestCredentialsUsingKeyAttestation.register(dispatcher) } } diff --git a/processor/src/main/kotlin/com/android/identity/processor/CborSymbolProcessor.kt b/processor/src/main/kotlin/com/android/identity/processor/CborSymbolProcessor.kt index 9179dd7c5..9346bc8b8 100644 --- a/processor/src/main/kotlin/com/android/identity/processor/CborSymbolProcessor.kt +++ b/processor/src/main/kotlin/com/android/identity/processor/CborSymbolProcessor.kt @@ -510,7 +510,10 @@ class CborSymbolProcessor( return@forEach } val name = varName(fieldName) - constructorParameters.add(name) + // Always use field names (and require constructor to use them as parameter + // names!), because the order of parameters in the constructor sometimes have + // to differ from the order of the fields (esp. when using inheritance). + constructorParameters.add("$fieldName = $name") if (findAnnotation(property, ANNOTATION_MERGE) != null) { if (type.declaration.qualifiedName!!.asString() == "kotlin.collections.Map") { line("val $name = mutableMapOf<${typeArguments(this, type)}>()") @@ -533,19 +536,13 @@ class CborSymbolProcessor( } } } - line { - append("return $baseName(") - var first = true + line("return $baseName(") + withIndent { constructorParameters.forEach { parameter -> - if (first) { - first = false - } else { - append(", ") - } - append(parameter) + line("$parameter,") } - append(")") } + line(")") } if (hadMergedMap) { diff --git a/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt b/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt index 21fd7bf5e..eb1bcb16e 100644 --- a/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt +++ b/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt @@ -90,24 +90,27 @@ abstract class BaseFlowHttpServlet : BaseHttpServlet() { ) } Logger.i(TAG, "$prefix: POST response status 200 (${bytes.size} bytes)") - resp.contentType = "application/cbor" resp.outputStream.write(bytes.toByteArray()) } catch (e: UnsupportedOperationException) { - Logger.i(TAG, "$prefix: POST response status 404") + Logger.e(TAG, "$prefix: POST response status 404", e) resp.sendError(404, e.message) } catch (e: SimpleCipher.DataTamperedException) { - Logger.i(TAG, "$prefix: POST response status 405") + Logger.e(TAG, "$prefix: POST response status 405", e) resp.sendError(405, "State tampered") } catch (e: IllegalStateException) { - Logger.i(TAG, "$prefix: POST response status 405") + Logger.e(TAG, "$prefix: POST response status 405", e) resp.sendError(405, "IllegalStateException") } catch (e: Throwable) { - Logger.i(TAG, "$prefix: POST response status 500: ${e::class.simpleName}: ${e.message}") // NotificationTimeoutError happens frequently, don't need a stack trace for this... - if (e !is HttpTransport.TimeoutException) { - e.printStackTrace() + if (e is HttpTransport.TimeoutException) { + Logger.e(TAG, "$prefix: POST response status 500 (TimeoutException)") + } else { + Logger.e(TAG, "$prefix: POST response status 500", e) } resp.sendError(500, e.message) } } + + override val outputFormat: String + get() = "application/cbor" } \ No newline at end of file diff --git a/server-env/src/main/java/com/android/identity/server/BaseHttpServlet.kt b/server-env/src/main/java/com/android/identity/server/BaseHttpServlet.kt index 69d625241..1dd8ba906 100644 --- a/server-env/src/main/java/com/android/identity/server/BaseHttpServlet.kt +++ b/server-env/src/main/java/com/android/identity/server/BaseHttpServlet.kt @@ -1,10 +1,15 @@ package com.android.identity.server import com.android.identity.flow.handler.FlowNotifications +import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.FlowEnvironment +import com.android.identity.util.Logger import jakarta.servlet.ServletConfig import jakarta.servlet.http.HttpServlet import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import org.bouncycastle.jce.provider.BouncyCastleProvider import java.security.Security import kotlin.reflect.KClass @@ -15,6 +20,8 @@ open class BaseHttpServlet : HttpServlet() { companion object { private val environmentMap = mutableMapOf, ServerEnvironment>() + const val TAG = "BaseHttpServlet" + @Synchronized private fun initializeEnvironment( clazz: KClass<*>, @@ -67,4 +74,47 @@ open class BaseHttpServlet : HttpServlet() { open fun initializeEnvironment(env: FlowEnvironment): FlowNotifications? { return null } + + override fun service(req: HttpServletRequest, resp: HttpServletResponse) { + try { + resp.contentType = outputFormat + super.service(req, resp) + } catch (err: InvalidRequestException) { + Logger.e(TAG, "Error in ${req.requestURL}", err) + when (outputFormat) { + "application/json" -> { + val json = buildJsonObject { + put("error", JsonPrimitive("invalid_request")) + put("error_description", JsonPrimitive(err.message ?: "not provided")) + } + resp.status = 400 + resp.writer.write(json.toString()) + } + "text/html" -> { + resp.status = 400 + val text = (err.message ?: "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + resp.writer.write( + """ + + + +

Invalid Request

+

$text

+ + + """.trimIndent()) + } + else -> { + Logger.e(TAG, "Unsupported error format: $outputFormat") + resp.status = 400 + } + } + } + } + + protected open val outputFormat: String + get() = "application/json" } \ No newline at end of file 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 c5740f063..ca5fe76d6 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 @@ -5,6 +5,7 @@ import com.android.identity.cbor.Uint import com.android.identity.crypto.EcPublicKey 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.FlowEnvironment import com.android.identity.flow.server.Storage @@ -12,7 +13,6 @@ import com.android.identity.server.BaseHttpServlet import com.android.identity.util.fromBase64Url import com.android.identity.util.toBase64Url import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString @@ -94,21 +94,6 @@ abstract class BaseServlet: BaseHttpServlet() { return String(buf, 2, len) } - /** - * Formats error response as JSON. - */ - protected fun errorResponse( - resp: HttpServletResponse, - mnemonics: String, - message: String - ) { - val json = Json.encodeToString(ErrorMessage.serializer(), ErrorMessage(mnemonics, message)) - println("Error: $json") - resp.status = 400 - resp.contentType = "application/json" - resp.outputStream.write(json.toByteArray()) - } - /** * DPoP Authorization validation. */ @@ -121,32 +106,32 @@ abstract class BaseServlet: BaseHttpServlet() { val auth = req.getHeader("Authorization") if (accessToken == null) { if (auth != null) { - throw IllegalArgumentException("Unexpected authorization header") + throw InvalidRequestException("Unexpected authorization header") } } else { if (auth == null) { - throw IllegalArgumentException("Authorization header required") + throw InvalidRequestException("Authorization header required") } if (auth.substring(0, 5).lowercase() != "dpop ") { - throw IllegalArgumentException("DPoP authorization required") + throw InvalidRequestException("DPoP authorization required") } if (auth.substring(5) != accessToken) { - throw IllegalArgumentException("Stale or invalid access token") + throw InvalidRequestException("Stale or invalid access token") } } val dpop = req.getHeader("DPoP") - ?: throw IllegalArgumentException("DPoP header required") + ?: throw InvalidRequestException("DPoP header required") val parts = dpop.split('.') if (parts.size != 3) { - throw IllegalArgumentException("DPoP invalid") + throw InvalidRequestException("DPoP invalid") } checkJwtSignature(publicKey, dpop) val json = Json.parseToJsonElement(String(parts[1].fromBase64Url())) as JsonObject if (json["nonce"]?.jsonPrimitive?.content != dpopNonce) { - throw IllegalArgumentException("Stale or invalid DPoP nonce") + throw InvalidRequestException("Stale or invalid DPoP nonce") } if (json["htu"]?.jsonPrimitive?.content != req.requestURL.toString()) { - throw IllegalArgumentException("Incorrect request URI: ${req.requestURL}") + throw InvalidRequestException("Incorrect request URI: ${req.requestURL}") } } } \ No newline at end of file diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt index 965070f1e..0e40413a3 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt @@ -5,6 +5,7 @@ import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve import com.android.identity.crypto.EcPublicKeyDoubleCoordinate import com.android.identity.documenttype.knowntypes.EUPersonalID +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 @@ -31,10 +32,7 @@ class CredentialRequestServlet : BaseServlet() { val requestData = req.inputStream.readNBytes(requestLength) val params = Json.parseToJsonElement(String(requestData)) as JsonObject val code = params["code"]?.jsonPrimitive?.content - if (code == null) { - errorResponse(resp, "invalid_request", "missing parameter 'code'") - return - } + ?: throw InvalidRequestException("missing parameter 'code'") val id = codeToId(OpaqueIdType.PID_READING, code) val storage = environment.getInterface(Storage::class)!! runBlocking { diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt index d0d20eab4..76ac0007f 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt @@ -13,10 +13,12 @@ import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey import com.android.identity.crypto.X509Cert import com.android.identity.crypto.X509CertChain -import com.android.identity.document.NameSpacedData import com.android.identity.documenttype.knowntypes.EUPersonalID +import com.android.identity.flow.handler.InvalidRequestException +import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.Resources import com.android.identity.flow.server.Storage +import com.android.identity.issuance.common.cache import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator import com.android.identity.mdoc.mso.StaticAuthDataGenerator import com.android.identity.mdoc.util.MdocUtil @@ -35,7 +37,9 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -52,8 +56,7 @@ class CredentialServlet : BaseServlet() { println("credential") val authorization = req.getHeader("Authorization") if (authorization == null || authorization.substring(0, 5).lowercase() != "dpop ") { - errorResponse(resp, "invalid_request", "Authorization header invalid or missing") - return + throw InvalidRequestException("Authorization header invalid or missing") } val accessToken = authorization.substring(5) val id = codeToId(OpaqueIdType.ACCESS_TOKEN, accessToken) @@ -61,65 +64,126 @@ class CredentialServlet : BaseServlet() { val state = runBlocking { IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) } - try { - authorizeWithDpop(state.dpopKey, req, state.dpopNonce!!.toByteArray().toBase64Url(), accessToken) - } catch (err: IllegalArgumentException) { - println("bad DPoP authorization: $err") - errorResponse(resp, "authorization", err.message ?: "unknown") - return - } + authorizeWithDpop(state.dpopKey, req, state.dpopNonce!!.toByteArray().toBase64Url(), accessToken) + val nonce = state.cNonce!!.toByteArray().toBase64Url() // credential nonce state.dpopNonce = null state.cNonce = null runBlocking { storage.update("IssuanceState", "", id, ByteString(state.toCbor())) } - val requestData = req.inputStream.readNBytes(req.contentLength) - val json = Json.parseToJsonElement(String(requestData)) as JsonObject - val proof = json["proof"]?.jsonObject - if (proof == null) { - errorResponse(resp, "invalid_request", "'proof.jwt' parameter missing") - return - } - val jwt = proof["jwt"]?.jsonPrimitive?.content - if (jwt == null) { - errorResponse(resp, "invalid_request", "'proof.jwt' parameter missing") - return + val requestString = String(req.inputStream.readNBytes(req.contentLength)) + println("Request: $requestString") + val json = Json.parseToJsonElement(requestString) as JsonObject + var proofs = json["proofs"]?.jsonArray + val singleProof = proofs == null + if (proofs == null) { + val proof = json["proof"] + ?: throw InvalidRequestException("neither 'proof' or 'proofs' parameter provided") + proofs = buildJsonArray { add(proof) } + } else if (proofs.size == 0) { + throw InvalidRequestException("'proofs' is empty") } - val parts = jwt.split(".") - if (parts.size != 3) { - errorResponse(resp, "invalid_request", "invalid value for 'proof.jwt' parameter") - return + + val proofType = proofs[0].jsonObject["proof_type"]?.jsonPrimitive?.content + val authenticationKeys = when (proofType) { + "attestation" -> { + val keyAttestationCertificate = environment.cache( + KeyAttestationCertificate::class, + state.clientId + ) { configuration, resources -> + // By default using the same key/certificate as for client attestation + val certificateName = + configuration.getValue("openid4vci.key-attestation.certificate") + ?: "attestation/certificate.pem" + val certificate = + X509Cert.fromPem(resources.getStringResource(certificateName)!!) + KeyAttestationCertificate(certificate) + } + + proofs.flatMap { proof -> + val keyAttestation = proof.jsonObject["attestation"]!!.jsonPrimitive.content + checkJwtSignature( + keyAttestationCertificate.certificate.ecPublicKey, + keyAttestation + ) + val parts = keyAttestation.split(".") + + if (parts.size != 3) { + throw InvalidRequestException("invalid value for 'proof(s).attestation' parameter") + } + val body = Json.parseToJsonElement(String(parts[1].fromBase64Url())).jsonObject + if (body["nonce"]?.jsonPrimitive?.content != nonce) { + throw InvalidRequestException("invalid nonce in 'proof(s).attestation' parameter") + } + body["attested_keys"]!!.jsonArray.map { key -> + JsonWebKey(buildJsonObject { + put("jwk", key) + }).asEcPublicKey + } + } + } + "jwt" -> { + val requireKeyAttestation = environment.getInterface(Configuration::class) + ?.getValue("openid4vci.key-attestation.required") + if (requireKeyAttestation != "false") { + throw InvalidRequestException("jwt proofs are not accepted by this server") + } + proofs.map { proof -> + val jwt = proof.jsonObject["jwt"]?.jsonPrimitive?.content + ?: throw InvalidRequestException("either 'proof.attestation' or 'proof.jwt' parameter is required") + val parts = jwt.split(".") + if (parts.size != 3) { + throw InvalidRequestException("invalid value for 'proof.jwt' parameter") + } + val head = + Json.parseToJsonElement(String(parts[0].fromBase64Url())) as JsonObject + val authenticationKey = JsonWebKey(head).asEcPublicKey + checkJwtSignature(authenticationKey, jwt) + authenticationKey + } + } + else -> { + throw InvalidRequestException("unsupported proof type") + } } - val head = Json.parseToJsonElement(String(parts[0].fromBase64Url())) as JsonObject - val authenticationKey = JsonWebKey(head).asEcPublicKey val format = json["format"]?.jsonPrimitive?.content - if (format == "vc+sd-jwt") { - val vct = json["vct"]?.jsonPrimitive?.content - if (vct != EUPersonalID.EUPID_VCT) { - errorResponse(resp, "invalid_request", "invalid value for 'vct' parameter") - return + val credentials = when (format) { + "vc+sd-jwt" -> { + val vct = json["vct"]?.jsonPrimitive?.content + if (vct != EUPersonalID.EUPID_VCT) { + throw InvalidRequestException("invalid value for 'vct' parameter") + } + authenticationKeys.map { key -> + createCredentialSdJwt(state, key) + } } - val result = buildJsonObject { - put("credential", createCredentialSdJwt(state, authenticationKey)) + "mso_mdoc" -> { + val vct = json["doctype"]?.jsonPrimitive?.content + if (vct != EUPersonalID.EUPID_DOCTYPE) { + throw InvalidRequestException("invalid value for 'doctype' parameter") + } + authenticationKeys.map { key -> + createCredentialMdoc(state, key).toBase64Url() + } + } + else -> { + throw InvalidRequestException("invalid value for 'format' parameter") } - resp.contentType = "application/json" - resp.writer.write(Json.encodeToString(result)) - return } - if (format == "mso_mdoc") { - val vct = json["doctype"]?.jsonPrimitive?.content - if (vct != EUPersonalID.EUPID_DOCTYPE) { - errorResponse(resp, "invalid_request", "invalid value for 'doctype' parameter") - return + val result = if (singleProof && credentials.size == 1) { + buildJsonObject { + put("credential", credentials[0]) } - val result = buildJsonObject { - put("credential", createCredentialMdoc(state, authenticationKey).toBase64Url()) + } else { + buildJsonObject { + put("credentials", buildJsonArray { + for (credential in credentials) { + add(JsonPrimitive(credential)) + } + }) } - resp.contentType = "application/json" - resp.writer.write(Json.encodeToString(result)) - return } - errorResponse(resp, "invalid_request", "invalid value for 'format' parameter") + resp.writer.write(Json.encodeToString(result)) } private fun createCredentialSdJwt( @@ -239,4 +303,6 @@ class CredentialServlet : BaseServlet() { return issuerProvidedAuthenticationData } + + private data class KeyAttestationCertificate(val certificate: X509Cert) } \ No newline at end of file diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt index 2870b488f..f6edc1d97 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt @@ -1,5 +1,6 @@ package com.android.identity.server.openid4vci +import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.Storage import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -11,20 +12,18 @@ import kotlin.time.Duration.Companion.minutes * Wallet Server). */ class FinishAuthorizationServlet : BaseServlet() { + override val outputFormat: String + get() = "text/html" + override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { val issuerState = req.getParameter("issuer_state") - if (issuerState == null) { - println("Error") - errorResponse(resp, "invalid_request", "missing parameter 'issuer_state'") - return - } + ?: throw InvalidRequestException("missing parameter 'issuer_state'") val id = codeToId(OpaqueIdType.ISSUER_STATE, issuerState) val storage = environment.getInterface(Storage::class)!! runBlocking { val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) val redirectUri = state.redirectUri ?: "" if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) { - resp.contentType = "text/html" resp.writer.write( """ 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 907f1399f..7a6f7744b 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 @@ -1,10 +1,9 @@ package com.android.identity.server.openid4vci -import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.X509Cert +import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.Storage import com.android.identity.issuance.common.cache -import com.android.identity.issuance.wallet.ClientAttestationData import com.android.identity.sdjwt.util.JsonWebKey import com.android.identity.util.fromBase64Url import jakarta.servlet.http.HttpServletRequest @@ -39,37 +38,25 @@ class ParServlet : BaseServlet() { override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { // Read all parameters if (req.getParameter("client_assertion_type") != ASSERTION_TYPE) { - errorResponse(resp, "invalid_request", - "invalid parameter 'client_assertion_type'") - return + throw InvalidRequestException("invalid parameter 'client_assertion_type'") } if (req.getParameter("scope") != "pid") { - errorResponse(resp, "invalid_request", "invalid parameter 'pid'") - return + throw InvalidRequestException("invalid parameter 'pid'") } if (req.getParameter("response_type") != "code") { - errorResponse(resp, "invalid_request", "invalid parameter 'response_type'") - return + throw InvalidRequestException("invalid parameter 'response_type'") } if (req.getParameter("code_challenge_method") != "S256") { - errorResponse(resp, "invalid_request", "invalid parameter 'code_challenge_method'") - return + throw InvalidRequestException("invalid parameter 'code_challenge_method'") } val redirectUri = req.getParameter("redirect_uri") - if (redirectUri == null) { - errorResponse(resp, "invalid_request", "missing parameter 'redirect_uri'") - return - } + ?: throw InvalidRequestException("missing parameter 'redirect_uri'") val clientId = req.getParameter("client_id") - if (clientId == null) { - errorResponse(resp, "invalid_request", "missing parameter 'client_id'") - return - } + ?: throw InvalidRequestException("missing parameter 'client_id'") val codeChallenge = try { ByteString(req.getParameter("code_challenge").fromBase64Url()) } catch (err: Exception) { - errorResponse(resp, "invalid_request", "invalid parameter 'code_challenge'") - return + throw InvalidRequestException("invalid parameter 'code_challenge'") } val clientAssertion = req.getParameter("client_assertion") @@ -91,8 +78,7 @@ class ParServlet : BaseServlet() { // Extract session key (used in DPoP authorization for subsequent requests). val parts = sequence[0].split(".") if (parts.size != 3) { - errorResponse(resp, "invalid_assertion", "invalid JWT") - return + 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 @@ -117,6 +103,6 @@ class ParServlet : BaseServlet() { ) ).toByteArray()) } -} -data class ClientCertificate(val certificate: X509Cert) \ No newline at end of file + private data class ClientCertificate(val certificate: X509Cert) +} diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt index aa646c1d4..15473ff8f 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt @@ -2,6 +2,7 @@ package com.android.identity.server.openid4vci import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto +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 @@ -20,40 +21,44 @@ import kotlin.time.Duration.Companion.minutes * in client_assertion to [ParServlet]. Once all the checks are done it issues access token that * can be used to request a credential and possibly a refresh token that can be used to request * more access tokens. - * - * TODO: refresh tokens are currently issued, but not yet processed. */ class TokenServlet : BaseServlet() { override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { - if (req.getParameter("grant_type") != "authorization_code") { - println("bad grant type") - errorResponse(resp, "invalid_request", "invalid parameter 'grant_type'") - return + val digest: ByteString? + val id = when (req.getParameter("grant_type")) { + "authorization_code" -> { + val code = req.getParameter("code") + ?: throw InvalidRequestException("'code' parameter missing") + val codeVerifier = req.getParameter("code_verifier") + ?: throw InvalidRequestException("'code_verifier' parameter missing") + digest = ByteString(Crypto.digest(Algorithm.SHA256, codeVerifier.toByteArray())) + codeToId(OpaqueIdType.REDIRECT, code) + } + "refresh_token" -> { + val refreshToken = req.getParameter("refresh_token") + ?: throw InvalidRequestException("'refresh_token' parameter missing") + digest = null + codeToId(OpaqueIdType.REFRESH_TOKEN, refreshToken) + } + else -> throw InvalidRequestException("invalid parameter 'grant_type'") + } - val digest = Crypto.digest( - Algorithm.SHA256, req.getParameter("code_verifier").toByteArray()) - val id = codeToId(OpaqueIdType.REDIRECT, req.getParameter("code")) val storage = environment.getInterface(Storage::class)!! val state = runBlocking { IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) } - if (state.codeChallenge != ByteString(digest)) { - println("bad authorization: '${digest.toBase64Url()}' and '${state.codeChallenge!!.toByteArray().toBase64Url()}'") - errorResponse(resp, "authorization", "bad code_verifier") - return - } - try { - authorizeWithDpop(state.dpopKey, req, state.dpopNonce?.toByteArray()?.toBase64Url(), null) - } catch (err: IllegalArgumentException) { - println("bad DPoP authorization: $err") - errorResponse(resp, "authorization", err.message ?: "unknown") - return + if (digest != null) { + if (state.codeChallenge == digest) { + state.codeChallenge = null // challenge met + } else { + throw InvalidRequestException("authorization: bad code_verifier") + } } + authorizeWithDpop(state.dpopKey, req, state.dpopNonce?.toByteArray()?.toBase64Url(), null) val dpopNonce = Random.nextBytes(15) state.dpopNonce = ByteString(dpopNonce) resp.setHeader("DPoP-Nonce", dpopNonce.toBase64Url()) val cNonce = Random.nextBytes(15) - state.codeChallenge = null // challenge met state.redirectUri = null state.cNonce = ByteString(cNonce) runBlocking { 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 4fc858a33..4ff1f7c73 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 @@ -11,12 +11,6 @@ import kotlinx.serialization.Serializable //------------ JSON-formatted replies from various OpenID4VCI servlets -@Serializable -data class ErrorMessage( - val error: String, - @SerialName("error_description") val description: String -) - @Serializable data class ParResponse( @SerialName("request_uri") val requestUri: String, diff --git a/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt b/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt index 384246daa..273fe16d3 100644 --- a/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt @@ -1,6 +1,7 @@ package com.android.identity.wallet.server import com.android.identity.flow.handler.FlowNotifications +import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources import com.android.identity.flow.server.Storage @@ -53,8 +54,11 @@ class LandingServlet: BaseHttpServlet() { val record = LandingRecord.fromCbor(recordData.toByteArray()) record.resolved = req.queryString ?: "" storage.update("Landing", "", id, ByteString(record.toCbor())) + val configuration = environment.getInterface(Configuration::class)!! + val baseUrl = configuration.getValue("base_url") + val landingUrl = "$baseUrl/${ApplicationSupportState.URL_PREFIX}$id" ApplicationSupportState(record.clientId).emit(environment, - LandingUrlNotification("landing/$id")) + LandingUrlNotification(landingUrl)) resp.contentType = "text/html" val resources = environment.getInterface(Resources::class)!! resp.outputStream.write(