Skip to content

Commit

Permalink
Expose and read openid4vci server metadata, getting our wallet to wor…
Browse files Browse the repository at this point in the history
…k with arbitrary openid4vci servers.

Signed-off-by: Peter Sorotokin <[email protected]>
  • Loading branch information
sorotokin committed Oct 23, 2024
1 parent 18cc709 commit d30691f
Show file tree
Hide file tree
Showing 32 changed files with 1,150 additions and 245 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,17 @@ class FlowNotificationsLocal(
// TODO: it is not quite clear if we should delay notifications. It might make
// sense to actually remove this and ensure that everything works as expected,
// as the need for the delay is an indication of a race condition somewhere
// Update: it seems that we start listening for notifications a bit later than
// they are emitted.
CoroutineScope(Dispatchers.IO).launch {
delay(200.milliseconds)
Logger.i(TAG, "Notification emitted for $flowName")
lock.withLock { flowMap[ref] }?.emit(notification)
val flow = lock.withLock { flowMap[ref] };
if (flow == null) {
Logger.i(TAG, "Notification has been dropped for $flowName [$notification]}")
} else {
Logger.i(TAG, "Notification is being emitted for $flowName [$notification]")
flow.emit(notification)
}
}
}

Expand Down Expand Up @@ -66,7 +73,6 @@ class FlowNotificationsLocal(
val deserializer: (DataItem) -> NotificationT
) {
suspend fun emit(dataItem: DataItem) {
Logger.i(TAG, "Dispatch notification to the listener")
flow.emit(deserializer(dataItem))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,17 @@ interface ApplicationSupport : FlowNotifiable<LandingUrlNotification> {
suspend fun getLandingUrlStatus(landingUrl: String): String?

/**
* Creates OAuth JWT client assertion based on the mobile-platform-specific [KeyAttestation].
* Looks up OAuth client id for the given OpenId4VCI issuance server [targetIssuanceUrl].
*
* This is the same client id that would be used in client assertion created using
* [createJwtClientAssertion].
*/
@FlowMethod
suspend fun getClientAssertionId(targetIssuanceUrl: String): String

/**
* Creates OAuth JWT client assertion based on the mobile-platform-specific [KeyAttestation]
* for the given OpenId4VCI issuance server [targetIssuanceUrl].
*/
@FlowMethod
suspend fun createJwtClientAssertion(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,4 @@ interface WalletServer: FlowBase {
*/
@FlowMethod
suspend fun getIssuingAuthority(identifier: String): IssuingAuthority

/**
* Create the Issuing Authority from the payload of an OpenId Credential Offer (OID4VCI) that
* produces a credential issuer Uri and Credential config Id (such as pid-mso-mdoc or pid-sd-jwt).
*
* Like above, do not call this method directly, use WalletServerProvider that maintains a cache.
*/
@FlowMethod
suspend fun createIssuingAuthorityByUri(
credentialIssuerUri: String,
credentialConfigurationId: String
): IssuingAuthority
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import kotlin.reflect.cast
* This method is thread-safe. The same object is returned for all threads, however, when the
* object is being created, multiple copies might be created if there is a race condition.
*/
fun<ResourceT : Any> FlowEnvironment.cache(
suspend fun<ResourceT : Any> FlowEnvironment.cache(
clazz: KClass<ResourceT>,
key: Any = "",
factory: (Configuration, Resources) -> ResourceT): ResourceT {
factory: suspend (Configuration, Resources) -> ResourceT): ResourceT {
val configuration = getInterface(Configuration::class)!!
val resources = getInterface(Resources::class)!!
return cache
Expand All @@ -37,12 +37,12 @@ private val cache = WeakHashMap<Configuration, WeakHashMap<Resources, Environmen
private class EnvironmentCache {
val map = mutableMapOf<KClass<out Any>, MutableMap<Any, Any>>()

fun<ResourceT : Any> obtain(
suspend fun<ResourceT : Any> obtain(
configuration: Configuration,
resources: Resources,
clazz: KClass<ResourceT>,
key: Any,
factory: (Configuration, Resources) -> ResourceT
factory: suspend (Configuration, Resources) -> ResourceT
): ResourceT {
synchronized(map) {
val submap = map[clazz]
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ class FunkeProofingState(
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,
val credentialIssuerUri:String,
) {
companion object {
private const val TAG = "FunkeProofingState"
Expand Down Expand Up @@ -82,7 +83,7 @@ class FunkeProofingState(
continueWithoutPermissionButtonText = "No Thanks",
assets = mapOf()
))
} else if (FunkeUtil.USE_AUSWEIS_SDK) {
} else if (useGermanEId) {
listOf(EvidenceRequestGermanEid(proofingInfo.authorizeUrl, listOf()))
} else {
listOf(EvidenceRequestWeb(proofingInfo.authorizeUrl, proofingInfo.landingUrl))
Expand Down Expand Up @@ -196,7 +197,7 @@ class FunkeProofingState(
val code = location.substring(index + 5)
this.access = FunkeUtil.obtainToken(
env = env,
tokenUrl = "${credentialIssuerUri}/token",
tokenUrl = tokenUri,
clientId = clientId,
issuanceClientId = issuanceClientId,
authorizationCode = code,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,6 @@ import kotlin.random.Random
internal object FunkeUtil {
const val TAG = "FunkeUtil"

const val EU_PID_MDOC_DOCTYPE = "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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package com.android.identity.issuance.funke

import com.android.identity.flow.server.FlowEnvironment
import com.android.identity.issuance.common.cache
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import io.ktor.http.HttpStatusCode
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.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

// from .well-known/openid-credential-issuer
internal data class Openid4VciIssuerMetadata(
val credentialIssuer: String,
val credentialEndpoint: String,
val display: List<Openid4VciIssuerDisplay>,
val credentialConfigurations: Map<String, Openid4VciCredentialConfiguration>,
val authorizationServers: List<Openid4VciAuthorizationMetadata>
) {
companion object {
suspend fun get(env: FlowEnvironment, issuerUrl: String): Openid4VciIssuerMetadata {
return env.cache(Openid4VciIssuerMetadata::class, issuerUrl) { _, _ ->
val httpClient = env.getInterface(HttpClient::class)!!

// Fetch issuer metadata
val issuerMetadataUrl = "$issuerUrl/.well-known/openid-credential-issuer"
val issuerMetadataRequest = httpClient.get(issuerMetadataUrl) {}
if (issuerMetadataRequest.status != HttpStatusCode.OK) {
throw IllegalStateException("Invalid issuer, no $issuerMetadataUrl")
}
val credentialMetadataText = String(issuerMetadataRequest.readBytes())
val credentialMetadata = Json.parseToJsonElement(credentialMetadataText).jsonObject

// Fetch authorization metadata
val authorizationServers = credentialMetadata["authorization_servers"]?.jsonArray
val authorizationServerUrls =
authorizationServers?.map { it.jsonPrimitive.content } ?: listOf(issuerUrl)
val authorizationMetadataList = mutableListOf<Openid4VciAuthorizationMetadata>()
for (authorizationServerUrl in authorizationServerUrls) {
val authorizationMetadataUrl =
"$authorizationServerUrl/.well-known/oauth-authorization-server"
val authorizationMetadataRequest = httpClient.get(authorizationMetadataUrl) {}
if (authorizationMetadataRequest.status != HttpStatusCode.OK) {
throw IllegalStateException("Invalid authorization server, no $authorizationMetadataUrl")
}
val authorizationMetadataText = String(authorizationMetadataRequest.readBytes())
val authorizationMetadata =
extractAuthorizationServerMetadata(
Json.parseToJsonElement(authorizationMetadataText).jsonObject)
if (authorizationMetadata != null) {
authorizationMetadataList.add(authorizationMetadata)
}
}
if (authorizationMetadataList.isEmpty()) {
throw IllegalStateException("No compatible authorization server found in $issuerMetadataUrl")
}
Openid4VciIssuerMetadata(
credentialIssuer = credentialMetadata["credential_issuer"]?.jsonPrimitive?.content ?: issuerUrl,
credentialEndpoint = credentialMetadata["credential_endpoint"]!!.jsonPrimitive.content,
display = extractDisplay(credentialMetadata["display"]),
authorizationServers = authorizationMetadataList.toList(),
credentialConfigurations =
credentialMetadata["credential_configurations_supported"]!!.jsonObject.mapValues {
val obj = it.value.jsonObject
Openid4VciCredentialConfiguration(
id = it.key,
scope = obj["scope"]!!.jsonPrimitive.content,
cryptographicBindingMethod = preferred(
obj["cryptographic_binding_methods_supported"]!!.jsonArray,
SUPPORTED_BINDING_METHODS
),
credentialSigningAlgorithm = preferred(
obj["credential_signing_alg_values_supported"]!!.jsonArray,
SUPPORTED_SIGNATURE_ALGORITHMS
),
proofType = extractProofType(obj["proof_types_supported"]!!.jsonObject),
format = extractFormat(obj),
display = extractDisplay(obj["display"])
)
}
)
}
}

private fun preferred(available: JsonArray?, supported: List<String>): String? {
val availableSet = available?.map { it.jsonPrimitive.content }?.toSet() ?: return null
return supported.firstOrNull { availableSet.contains(it) }
}

private fun extractDisplay(displayJson: JsonElement?): List<Openid4VciIssuerDisplay> {
if (displayJson == null) {
return listOf()
}
return displayJson.jsonArray.map {
val displayObj = it.jsonObject
Openid4VciIssuerDisplay(
text = displayObj["name"]!!.jsonPrimitive.content,
locale = displayObj["locale"]?.jsonPrimitive?.content ?: "en",
logoUrl = displayObj["logo"]?.jsonObject?.get("uri")?.jsonPrimitive?.content
)
}
}

// Returns null if no compatible configuration could be created
private fun extractAuthorizationServerMetadata(jsonObject: JsonObject): Openid4VciAuthorizationMetadata? {
val responseType = preferred(
jsonObject["response_types_supported"]?.jsonArray,
SUPPORTED_RESPONSE_TYPES
) ?: return null
val codeChallengeMethod = preferred(
jsonObject["code_challenge_methods_supported"]?.jsonArray,
SUPPORTED_CODE_CHALLENGE_METHODS
) ?: return null
val dpopSigningAlgorithm = preferred(
jsonObject["dpop_signing_alg_values_supported"]?.jsonArray,
SUPPORTED_SIGNATURE_ALGORITHMS
) ?: return null
val authorizationEndpoint = jsonObject["authorization_endpoint"]!!.jsonPrimitive.content
return Openid4VciAuthorizationMetadata(
pushedAuthorizationRequestEndpoint = jsonObject["pushed_authorization_request_endpoint"]!!.jsonPrimitive.content,
authorizationEndpoint = authorizationEndpoint,
tokenEndpoint = jsonObject["token_endpoint"]!!.jsonPrimitive.content,
responseType = responseType,
codeChallengeMethod = codeChallengeMethod,
dpopSigningAlgorithm = dpopSigningAlgorithm,
useGermanEId = authorizationEndpoint.startsWith("https://demo.pid-issuer.bundesdruckerei.de/")
)
}

private fun extractProofType(jsonObject: JsonObject): Openid4VciProofType? {
val attestation = jsonObject["attestation"]?.jsonObject
if (attestation != null) {
val alg = preferred(
attestation["proof_signing_alg_values_supported"]?.jsonArray,
SUPPORTED_SIGNATURE_ALGORITHMS
)
if (alg != null) {
return Openid4VciProofTypeKeyAttestation(alg)
}
}
val jwt = jsonObject["jwt"]?.jsonObject
if (jwt != null) {
val alg = preferred(
jwt["proof_signing_alg_values_supported"]?.jsonArray,
SUPPORTED_SIGNATURE_ALGORITHMS
)
if (alg != null) {
return Openid4VciProofTypeJwt(alg)
}
}
return null
}

private fun extractFormat(jsonObject: JsonObject): Openid4VciFormat? {
return when (jsonObject["format"]?.jsonPrimitive?.content) {
"vc+sd-jwt" -> Openid4VciFormatSdJwt(jsonObject["vct"]!!.jsonPrimitive.content)
"mso_mdoc" -> Openid4VciFormatMdoc(jsonObject["doctype"]!!.jsonPrimitive.content)
else -> null
}
}

// Supported methods/algorithms in the order of preference
private val SUPPORTED_BINDING_METHODS = listOf("cose_key", "jwk")
private val SUPPORTED_SIGNATURE_ALGORITHMS = listOf("ES256")
private val SUPPORTED_RESPONSE_TYPES = listOf("code")
private val SUPPORTED_CODE_CHALLENGE_METHODS = listOf("S256")
}
}

// from .well-known/oauth-authorization-server
internal data class Openid4VciAuthorizationMetadata(
val pushedAuthorizationRequestEndpoint: String,
val authorizationEndpoint: String,
val tokenEndpoint: String,
val responseType: String,
val codeChallengeMethod: String,
val dpopSigningAlgorithm: String,

// Heuristic, only needed to support the hackish way bundesdruckerei.de does authorization
// using Ausweis App instead of the browser-based workflow. It would be better if it was
// exposed in server metadata somehow.
val useGermanEId: Boolean
)

internal data class Openid4VciIssuerDisplay(
val text: String,
val locale: String,
val logoUrl: String?
)

// Create a configuration object even if it is not fully supported (unsupported fields will have
// null values), so that we can have clear error messages.
internal data class Openid4VciCredentialConfiguration(
val id: String,
val scope: String?,
val cryptographicBindingMethod: String?,
val credentialSigningAlgorithm: String?,
val proofType: Openid4VciProofType?,
val format: Openid4VciFormat?,
val display: List<Openid4VciIssuerDisplay>
) {
val isSupported: Boolean get() = scope != null && cryptographicBindingMethod != null &&
credentialSigningAlgorithm != null && proofType != null && format != null
}

internal sealed class Openid4VciFormat {
abstract val id: String
}

internal data class Openid4VciFormatMdoc(val docType: String) : Openid4VciFormat() {
override val id: String get() = "mso_mdoc"
}

internal data class Openid4VciFormatSdJwt(val vct: String) : Openid4VciFormat() {
override val id: String get() = "vc+sd-jwt"
}

internal sealed class Openid4VciProofType {
abstract val id: String
}

internal data class Openid4VciProofTypeJwt(
val signingAlgorithm: String
) : Openid4VciProofType() {
override val id: String get() = "jwt"
}

internal data class Openid4VciProofTypeKeyAttestation(
val signingAlgorithm: String
) : Openid4VciProofType() {
override val id: String get() = "attestation"
}

internal fun JsonObjectBuilder.putFormat(format: Openid4VciFormat) {
put("format", JsonPrimitive(format.id))
when (format) {
is Openid4VciFormatSdJwt -> {
put("vct", JsonPrimitive(format.vct))
}

is Openid4VciFormatMdoc -> {
put("doctype", JsonPrimitive(format.docType))
}

null -> throw IllegalStateException("Credential format was not specified")
}
}
Loading

0 comments on commit d30691f

Please sign in to comment.