Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

OpenId4VCI interoperability fixes. #823

Merged
merged 1 commit into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ package com.android.identity.issuance.evidence
* Request pre-authorized code from the client. Pre-authorized code is given to a wallet app
* as a part of an OpenId4VCI credential offer.
*/
class EvidenceRequestPreauthorizedCode : EvidenceRequest()
class EvidenceRequestCredentialOffer : EvidenceRequest()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.android.identity.issuance.evidence

/**
* Provides OpenId4VCI credential offer data.
*/
data class EvidenceResponseCredentialOffer(
val credentialOffer: Openid4VciCredentialOffer
) : EvidenceResponse()

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.android.identity.issuance.evidence

import com.android.identity.cbor.annotation.CborSerializable

/**
* Credential offer as described in OpenId4Vci spec:
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters
*/
@CborSerializable
sealed class Openid4VciCredentialOffer(
val issuerUri: String,
val configurationId: String,
val authorizationServer: String?
) {
companion object
}

/**
* Credential offer with Grant Type `urn:ietf:params:oauth:grant-type:pre-authorized_code`.
*/
class Openid4VciCredentialOfferPreauthorizedCode(
issuerUri: String,
configurationId: String,
authorizationServer: String?,
val preauthorizedCode: String,
val txCode: Openid4VciTxCode?
) : Openid4VciCredentialOffer(issuerUri, configurationId, authorizationServer)

/**
* Credential offer with Grant Type `authorization_code`.
*/
class Openid4VciCredentialOfferAuthorizationCode(
issuerUri: String,
configurationId: String,
authorizationServer: String?,
val issuerState: String?
) : Openid4VciCredentialOffer(issuerUri, configurationId, authorizationServer)


/**
* Describes tx_code parameter (see OpenId4Vci spec referenced above).
*/
@CborSerializable
data class Openid4VciTxCode(
val description: String,
val isNumeric: Boolean,
val length: Int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a separate length field, or is this the length of the "message" string? What happens if there's a mismatch?

If it's not the length of the message field, please add comments indicating what each of these fields means.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added references to the spec.

)
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ class FunkeAccess(
val accessTokenExpiration: Instant,
var dpopNonce: String?,
var cNonce: String?,
var tokenEndpoint: String,
var refreshToken: String?,
) {
companion object {
suspend fun parseResponse(tokenResponse: HttpResponse): FunkeAccess {
suspend fun parseResponse(tokenEndpoint: String, tokenResponse: HttpResponse): FunkeAccess {
val dpopNonce = tokenResponse.headers["DPoP-Nonce"]
val tokenString = String(tokenResponse.readBytes())
val token = Json.parseToJsonElement(tokenString) as JsonObject
Expand All @@ -31,7 +32,8 @@ class FunkeAccess(
val duration = getField(token, "expires_in").long
val accessTokenExpiration = Clock.System.now() + duration.seconds
val refreshToken = token["refresh_token"]?.jsonPrimitive?.content
return FunkeAccess(accessToken, accessTokenExpiration, dpopNonce, cNonce, refreshToken)
return FunkeAccess(accessToken, accessTokenExpiration, dpopNonce,
cNonce, tokenEndpoint, refreshToken)
}

private fun getField(jsonElement: JsonObject, name: String): JsonPrimitive {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import com.android.identity.documenttype.DocumentTypeRepository
import com.android.identity.documenttype.knowntypes.DrivingLicense
import com.android.identity.documenttype.knowntypes.EUCertificateOfResidence
import com.android.identity.documenttype.knowntypes.EUPersonalID
import com.android.identity.documenttype.knowntypes.GermanPersonalID
import com.android.identity.documenttype.knowntypes.PhotoID
import com.android.identity.documenttype.knowntypes.UtopiaNaturalization
import com.android.identity.flow.annotation.FlowJoin
Expand Down Expand Up @@ -187,7 +186,6 @@ class FunkeIssuingAuthorityState(

val documentTypeRepository = DocumentTypeRepository().apply {
addDocumentType(EUPersonalID.getDocumentType())
addDocumentType(GermanPersonalID.getDocumentType())
addDocumentType(DrivingLicense.getDocumentType())
addDocumentType(PhotoID.getDocumentType())
addDocumentType(EUCertificateOfResidence.getDocumentType())
Expand Down Expand Up @@ -257,15 +255,6 @@ class FunkeIssuingAuthorityState(
@FlowMethod
suspend fun proof(env: FlowEnvironment, documentId: String): FunkeProofingState {
val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri)
var openid4VpRequest: String? = null
val proofingInfo = performPushedAuthorizationRequest(env)
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",
Expand All @@ -274,19 +263,13 @@ class FunkeIssuingAuthorityState(
)?.let {
WalletApplicationCapabilities.fromCbor(it.toByteArray())
} ?: throw IllegalStateException("WalletApplicationCapabilities not found")
val useGermanId = metadata.authorizationServers.isNotEmpty()
&& metadata.authorizationServers[0].useGermanEId
return FunkeProofingState(
clientId = clientId,
credentialConfigurationId = credentialConfigurationId,
issuanceClientId = issuanceClientId,
documentId = documentId,
credentialIssuerUri = credentialIssuerUri,
proofingInfo = proofingInfo,
applicationCapabilities = applicationCapabilities,
tokenUri = metadata.tokenEndpoint,
openid4VpRequest = openid4VpRequest,
useGermanEId = useGermanId
applicationCapabilities = applicationCapabilities
)
}

Expand All @@ -309,8 +292,8 @@ class FunkeIssuingAuthorityState(
issuerDocument.state = DocumentCondition.READY
if (issuerDocument.documentConfiguration == null) {
val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri)
if (metadata.authorizationServers.isNotEmpty() &&
metadata.authorizationServers[0].useGermanEId) {
if (metadata.authorizationServerList.isNotEmpty() &&
metadata.authorizationServerList[0].useGermanEId) {
val isCloudSecureArea =
issuerDocument.secureAreaIdentifier!!.startsWith("CloudSecureArea?")
issuerDocument.documentConfiguration =
Expand Down Expand Up @@ -673,108 +656,6 @@ class FunkeIssuingAuthorityState(
}
}

private suspend fun performPushedAuthorizationRequest(env: FlowEnvironment): ProofingInfo? {
val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri)
if (metadata.authorizationServers.isEmpty()) {
return null
}
val config = metadata.credentialConfigurations[credentialConfigurationId]!!
val authorizationMetadata = metadata.authorizationServers[0]
val pkceCodeVerifier = Random.Default.nextBytes(32).toBase64Url()
val codeChallenge = Crypto.digest(Algorithm.SHA256, pkceCodeVerifier.toByteArray()).toBase64Url()

// NB: applicationSupport will only be non-null when running this code locally in the
// Android Wallet app.
val applicationSupport = env.getInterface(ApplicationSupport::class)
val parRedirectUrl: String
val landingUrl: String
if (authorizationMetadata.useGermanEId) {
landingUrl = ""
// Does not matter, but must be https
parRedirectUrl = "https://secure.redirect.com"
} else {
landingUrl = applicationSupport?.createLandingUrl() ?:
ApplicationSupportState(clientId).createLandingUrl(env)
parRedirectUrl = landingUrl
}

val clientKeyInfo = FunkeUtil.communicationKey(env, clientId)
val clientAssertion = if (applicationSupport != null) {
// Required when applicationSupport is exposed
val assertionMaker = env.getInterface(DeviceAssertionMaker::class)!!
applicationSupport.createJwtClientAssertion(
clientKeyInfo.attestation,
assertionMaker.makeDeviceAssertion(AssertionDPoPKey(
clientKeyInfo.publicKey,
credentialIssuerUri
))
)
} else {
ApplicationSupportState(clientId).createJwtClientAssertion(
env,
clientKeyInfo.publicKey,
credentialIssuerUri
)
}

val req = FormUrlEncoder {
add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-client-attestation")
if (config.scope != null) {
add("scope", config.scope)
}
add("response_type", "code")
add("code_challenge_method", "S256")
add("redirect_uri", parRedirectUrl)
add("client_assertion", clientAssertion)
add("code_challenge", codeChallenge)
add("client_id", issuanceClientId)
}
val httpClient = env.getInterface(HttpClient::class)!!
// 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())
}
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(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")
}
}
val authSession = parsedResponse["auth_session"]
val requestUri = parsedResponse["request_uri"]
val presentation = parsedResponse["presentation"]
return ProofingInfo(
requestUri = requestUri?.jsonPrimitive?.content,
authSession = authSession?.jsonPrimitive?.content,
pkceCodeVerifier = pkceCodeVerifier,
landingUrl = landingUrl,
openid4VpPresentation = presentation?.jsonPrimitive?.content
)
}

private suspend fun generateFunkeDocumentConfiguration(
env: FlowEnvironment,
isCloudSecureArea: Boolean
Expand Down Expand Up @@ -904,7 +785,7 @@ class FunkeIssuingAuthorityState(
env = env,
clientId = clientId,
issuanceClientId = issuanceClientId,
tokenUrl = metadata.tokenEndpoint,
tokenUrl = access.tokenEndpoint,
refreshToken = refreshToken,
accessToken = access.accessToken
)
Expand Down
Loading
Loading