diff --git a/identity-flow/src/main/java/com/android/identity/flow/handler/FlowNotificationsLocal.kt b/identity-flow/src/main/java/com/android/identity/flow/handler/FlowNotificationsLocal.kt index 542e4624e..b86e58496 100644 --- a/identity-flow/src/main/java/com/android/identity/flow/handler/FlowNotificationsLocal.kt +++ b/identity-flow/src/main/java/com/android/identity/flow/handler/FlowNotificationsLocal.kt @@ -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) + } } } @@ -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)) } } 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 c4131417f..80470a790 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 @@ -28,7 +28,17 @@ interface ApplicationSupport : FlowNotifiable { 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( diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/WalletServer.kt b/identity-issuance/src/main/java/com/android/identity/issuance/WalletServer.kt index 2dbf0c296..f9e07e2f7 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/WalletServer.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/WalletServer.kt @@ -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 } diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt b/identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt index 24d0ce189..670cacdd3 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt @@ -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 FlowEnvironment.cache( +suspend fun FlowEnvironment.cache( clazz: KClass, 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 @@ -37,12 +37,12 @@ private val cache = WeakHashMap, MutableMap>() - fun obtain( + suspend fun obtain( configuration: Configuration, resources: Resources, clazz: KClass, key: Any, - factory: (Configuration, Resources) -> ResourceT + factory: suspend (Configuration, Resources) -> ResourceT ): ResourceT { synchronized(map) { val submap = map[clazz] 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 580560f61..20b65a345 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 @@ -10,7 +10,9 @@ import com.android.identity.crypto.EcPublicKey import com.android.identity.document.NameSpacedData import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.knowntypes.DrivingLicense import com.android.identity.documenttype.knowntypes.EUPersonalID +import com.android.identity.documenttype.knowntypes.PhotoID import com.android.identity.flow.annotation.FlowJoin import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState @@ -41,21 +43,20 @@ import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url import com.android.identity.util.toBase64Url import io.ktor.client.HttpClient +import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.readBytes import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString 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 import kotlin.time.Duration.Companion.days @@ -66,40 +67,96 @@ import kotlin.time.Duration.Companion.seconds ) @CborSerializable class FunkeIssuingAuthorityState( + // NB: since this object is used to emit notifications, it cannot change, as its state + // serves as notification key. So it generally should not have var members, only val. val clientId: String, - val credentialFormat: CredentialFormat, - var issuanceClientId: String = "", // client id in OpenID4VCI protocol val credentialIssuerUri: String, // credential offer issuing authority path + val credentialConfigurationId: String, + val issuanceClientId: String // client id in OpenID4VCI protocol ) : AbstractIssuingAuthorityState() { + + init { + // It should not be possible, but double-check. + check(credentialIssuerUri.indexOf('#') < 0) + check(credentialConfigurationId.indexOf('#') < 0) + } + companion object { private const val TAG = "FunkeIssuingAuthorityState" private const val DOCUMENT_TABLE = "FunkeIssuerDocument" - fun getConfiguration(env: FlowEnvironment, credentialFormat: CredentialFormat): IssuingAuthorityConfiguration { - val id = when (credentialFormat) { - CredentialFormat.SD_JWT_VC -> "funkeSdJwtVc" - CredentialFormat.MDOC_MSO -> "funkeMdocMso" - } - val variant = when (credentialFormat) { - CredentialFormat.SD_JWT_VC -> "SD-JWT" - CredentialFormat.MDOC_MSO -> "mDoc" - } - return env.cache(IssuingAuthorityConfiguration::class, id) { configuration, resources -> - val logoPath = "funke/logo.png" - val logo = resources.getRawResource(logoPath)!! - val artPath = "funke/card_art_funke_generic.png" - val art = resources.getRawResource(artPath)!! + suspend fun getConfiguration( + env: FlowEnvironment, + issuerUrl: String, + credentialConfigurationId: String + ): IssuingAuthorityConfiguration { + val id = "openid4vci#$issuerUrl#$credentialConfigurationId" + return env.cache(IssuingAuthorityConfiguration::class, id) { _, resources -> + val metadata = Openid4VciIssuerMetadata.get(env, issuerUrl) + val config = metadata.credentialConfigurations[credentialConfigurationId] + ?: throw IllegalArgumentException("Unknown configuration '$credentialConfigurationId' at '$issuerUrl'") + if (!config.isSupported) { + throw IllegalArgumentException("Unsupported configuration '$credentialConfigurationId' at '$issuerUrl'") + } + val httpClient = env.getInterface(HttpClient::class)!! + val display = if (metadata.display.isEmpty()) null else metadata.display[0] + val configDisplay = if (config.display.isEmpty()) null else config.display[0] + var logo: ByteArray? = null + if (display?.logoUrl != null) { + val logoRequest = httpClient.get(display.logoUrl) {} + if (logoRequest.status == HttpStatusCode.OK && + logoRequest.contentType()?.contentType == "image") { + logo = logoRequest.readBytes() + } else { + Logger.e(TAG, "Could not fetch logo from '${display.logoUrl}") + } + } + if (logo == null) { + val logoPath = "funke/logo.png" + logo = resources.getRawResource(logoPath)!!.toByteArray() + } + + val docType = documentTypeRepository.documentTypes.firstOrNull { documentType -> + return@firstOrNull when (config.format) { + is Openid4VciFormatMdoc -> + documentType.mdocDocumentType?.docType == config.format.docType + is Openid4VciFormatSdJwt -> + documentType.vcDocumentType?.type == config.format.vct + null -> false + } + } + + val documentType = docType?.displayName ?: "Generic EAA" + val documentDescription = configDisplay?.text ?: documentType + val issuingAuthorityDescription = "$documentDescription (${config.format?.id})" + + var cardArt: ByteArray? = null + if (configDisplay?.logoUrl != null) { + val artRequest = httpClient.get(configDisplay.logoUrl) {} + if (artRequest.status == HttpStatusCode.OK && + artRequest.contentType()?.contentType == "image") { + cardArt = artRequest.readBytes() + } else { + Logger.e(TAG, + "Could not fetch credential image from '${configDisplay.logoUrl}") + } + } + if (cardArt == null) { + val artPath = "funke/card_art_funke_generic.png" + cardArt = resources.getRawResource(artPath)!!.toByteArray() + } val requireUserAuthenticationToViewDocument = false + val keyAttestation = config.proofType is Openid4VciProofTypeKeyAttestation IssuingAuthorityConfiguration( identifier = id, - issuingAuthorityName = "SPRIND Funke EUDI Wallet Prototype PID Issuer", - issuingAuthorityLogo = logo.toByteArray(), - issuingAuthorityDescription = "Personal ID - $variant", + issuingAuthorityName = display?.text ?: issuerUrl, + issuingAuthorityLogo = logo, + issuingAuthorityDescription = issuingAuthorityDescription, pendingDocumentInformation = DocumentConfiguration( - displayName = "Pending", - typeDisplayName = "EU Personal ID", - cardArt = art.toByteArray(), + displayName = documentDescription, + typeDisplayName = documentType, + cardArt = cardArt, requireUserAuthenticationToViewDocument = requireUserAuthenticationToViewDocument, mdocConfiguration = null, sdJwtVcDocumentConfiguration = null @@ -108,11 +165,11 @@ class FunkeIssuingAuthorityState( // credential being issued, so request only one. With key attestation we can // use batch issuance (useful for unlinkability). numberOfCredentialsToRequest = - if (FunkeUtil.USE_KEY_ATTESTATION) { 3 } else { 1 }, + if (keyAttestation) { 3 } else { 1 }, // With key attestation credentials can be requested in the background as they // are used up, so direct wallet to avoid reusing them (for unlinkability). maxUsesPerCredentials = - if (FunkeUtil.USE_KEY_ATTESTATION) { 1 } else { Int.MAX_VALUE }, + if (keyAttestation) { 1 } else { Int.MAX_VALUE }, minCredentialValidityMillis = 30 * 24 * 3600L, ) } @@ -120,12 +177,14 @@ class FunkeIssuingAuthorityState( val documentTypeRepository = DocumentTypeRepository().apply { addDocumentType(EUPersonalID.getDocumentType()) + addDocumentType(DrivingLicense.getDocumentType()) + addDocumentType(PhotoID.getDocumentType()) } } @FlowMethod - fun getConfiguration(env: FlowEnvironment): IssuingAuthorityConfiguration { - return getConfiguration(env, credentialFormat) + suspend fun getConfiguration(env: FlowEnvironment): IssuingAuthorityConfiguration { + return getConfiguration(env, credentialIssuerUri, credentialConfigurationId) } @FlowMethod @@ -185,6 +244,8 @@ class FunkeIssuingAuthorityState( @FlowMethod suspend fun proof(env: FlowEnvironment, documentId: String): FunkeProofingState { val proofingInfo = performPushedAuthorizationRequest(env) + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val authorizationMetadata = metadata.authorizationServers[0] val storage = env.getInterface(Storage::class)!! val applicationCapabilities = storage.get( "WalletApplicationCapabilities", @@ -199,7 +260,10 @@ class FunkeIssuingAuthorityState( documentId = documentId, proofingInfo = proofingInfo, applicationCapabilities = applicationCapabilities, - credentialIssuerUri = credentialIssuerUri + tokenUri = authorizationMetadata.tokenEndpoint, + useGermanEId = authorizationMetadata.useGermanEId, + // Don't show TOS when using browser API + tosAcknowleged = !authorizationMetadata.useGermanEId ) } @@ -221,8 +285,16 @@ class FunkeIssuingAuthorityState( check(issuerDocument.state == DocumentCondition.CONFIGURATION_AVAILABLE) issuerDocument.state = DocumentCondition.READY if (issuerDocument.documentConfiguration == null) { - val isCloudSecureArea = issuerDocument.secureAreaIdentifier!!.startsWith("CloudSecureArea?") - issuerDocument.documentConfiguration = generateDocumentConfiguration(env, isCloudSecureArea) + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + if (metadata.authorizationServers[0].useGermanEId) { + val isCloudSecureArea = + issuerDocument.secureAreaIdentifier!!.startsWith("CloudSecureArea?") + issuerDocument.documentConfiguration = + generateFunkeDocumentConfiguration(env, isCloudSecureArea) + } else { + issuerDocument.documentConfiguration = + generateGenericDocumentConfiguration(env) + } } updateIssuerDocument(env, documentId, issuerDocument) return issuerDocument.documentConfiguration!! @@ -234,6 +306,8 @@ class FunkeIssuingAuthorityState( documentId: String ): AbstractRequestCredentials { val document = loadIssuerDocument(env, documentId) + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val credentialConfiguration = metadata.credentialConfigurations[credentialConfigurationId]!! refreshAccessIfNeeded(env, documentId, document) @@ -269,7 +343,7 @@ class FunkeIssuingAuthorityState( ) ) } - return if (FunkeUtil.USE_KEY_ATTESTATION) { + return if (credentialConfiguration.proofType is Openid4VciProofTypeKeyAttestation) { RequestCredentialsUsingKeyAttestation(documentId, configuration, cNonce) } else { RequestCredentialsUsingProofOfPossession( @@ -285,29 +359,31 @@ class FunkeIssuingAuthorityState( @FlowJoin suspend fun completeRequestCredentials(env: FlowEnvironment, state: AbstractRequestCredentials) { // Create appropriate request to OpenID4VCI issuer to issue credential(s) + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val credentialConfiguration = metadata.credentialConfigurations[credentialConfigurationId]!! + val (request, publicKeys) = when (state) { is RequestCredentialsUsingProofOfPossession -> - createRequestUsingProofOfPossession(state) + createRequestUsingProofOfPossession(state, credentialConfiguration) is RequestCredentialsUsingKeyAttestation -> - createRequestUsingKeyAttestation(env, state) + createRequestUsingKeyAttestation(env, state, credentialConfiguration) else -> throw IllegalStateException("Unsupported RequestCredential type") } // Send the request val document = loadIssuerDocument(env, state.documentId) - val credentialUrl = "$credentialIssuerUri/credential" val access = document.access!! val dpop = FunkeUtil.generateDPoP( env, clientId, - credentialUrl, + metadata.credentialEndpoint, access.dpopNonce, access.accessToken ) Logger.e(TAG,"Credential request: $request") val httpClient = env.getInterface(HttpClient::class)!! - val credentialResponse = httpClient.post(credentialUrl) { + val credentialResponse = httpClient.post(metadata.credentialEndpoint) { headers { append("Authorization", "DPoP ${access.accessToken}") append("DPoP", dpop) @@ -348,18 +424,20 @@ class FunkeIssuingAuthorityState( val now = Clock.System.now() // TODO: where do we get this from? Get the real data from the credential val expiration = Clock.System.now() + 14.days - when (state.format!!) { - CredentialFormat.SD_JWT_VC -> + when (credentialConfiguration.format) { + is Openid4VciFormatSdJwt -> CredentialData(publicKey, now, expiration, CredentialFormat.SD_JWT_VC, credential.toByteArray()) - CredentialFormat.MDOC_MSO -> + is Openid4VciFormatMdoc -> CredentialData(publicKey, now, expiration, CredentialFormat.MDOC_MSO, credential.fromBase64Url()) + null -> throw IllegalStateException("Unexpected credential format") } }) updateIssuerDocument(env, state.documentId, document, true) } private fun createRequestUsingProofOfPossession( - state: RequestCredentialsUsingProofOfPossession + state: RequestCredentialsUsingProofOfPossession, + configuration: Openid4VciCredentialConfiguration ): Pair> { val proofs = state.credentialRequests!!.map { JsonPrimitive(it.proofOfPossessionJwtHeaderAndBody + "." + it.proofOfPossessionJwtSignature) @@ -379,7 +457,7 @@ class FunkeIssuingAuthorityState( put("jwt", JsonArray(proofs)) }) } - putFormat(state.format) + putFormat(configuration.format!!) } return Pair(request, publicKeys) @@ -387,7 +465,8 @@ class FunkeIssuingAuthorityState( private suspend fun createRequestUsingKeyAttestation( env: FlowEnvironment, - state: RequestCredentialsUsingKeyAttestation + state: RequestCredentialsUsingKeyAttestation, + configuration: Openid4VciCredentialConfiguration ): Pair> { // NB: applicationSupport will only be non-null in the environment when running this code // locally in the Android Wallet app. @@ -407,7 +486,7 @@ class FunkeIssuingAuthorityState( put("attestation", JsonPrimitive(keyAttestation)) put("proof_type", JsonPrimitive("attestation")) }) - putFormat(state.format) + putFormat(configuration.format!!) } return Pair(request, platformAttestations.map { it.publicKey }) @@ -481,6 +560,7 @@ class FunkeIssuingAuthorityState( val bytes = document.toCbor() storage.update(DOCUMENT_TABLE, clientId, documentId, ByteString(bytes)) if (emitNotification) { + Logger.i(TAG, "Emitting notification for $documentId") emit(env, IssuingAuthorityNotification(documentId)) } } @@ -488,6 +568,10 @@ class FunkeIssuingAuthorityState( private suspend fun performPushedAuthorizationRequest( env: FlowEnvironment, ): ProofingInfo { + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + 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() @@ -496,7 +580,7 @@ class FunkeIssuingAuthorityState( val applicationSupport = env.getInterface(ApplicationSupport::class) val parRedirectUrl: String val landingUrl: String - if (FunkeUtil.USE_AUSWEIS_SDK) { + if (authorizationMetadata.useGermanEId) { landingUrl = "" // Does not matter, but must be https parRedirectUrl = "https://secure.redirect.com" @@ -516,11 +600,11 @@ class FunkeIssuingAuthorityState( credentialIssuerUri ) - issuanceClientId = extractIssuanceClientId(clientAssertion) - val req = FormUrlEncoder { add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-client-attestation") - add("scope", "pid") + if (config.scope != null) { + add("scope", config.scope) + } add("response_type", "code") add("code_challenge_method", "S256") add("redirect_uri", parRedirectUrl) @@ -529,7 +613,7 @@ class FunkeIssuingAuthorityState( add("client_id", issuanceClientId) } val httpClient = env.getInterface(HttpClient::class)!! - val response = httpClient.post("${credentialIssuerUri}/par") { + val response = httpClient.post(authorizationMetadata.pushedAuthorizationRequestEndpoint) { headers { append("Content-Type", "application/x-www-form-urlencoded") } @@ -548,7 +632,7 @@ class FunkeIssuingAuthorityState( } Logger.i(TAG, "Request uri: ${requestUri.content}") return ProofingInfo( - authorizeUrl = "${credentialIssuerUri}/authorize?" + FormUrlEncoder { + authorizeUrl = "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { add("client_id", issuanceClientId) add("request_uri", requestUri.content) }, @@ -557,22 +641,15 @@ class FunkeIssuingAuthorityState( ) } - private fun extractIssuanceClientId(jwtAssertion: String): String { - val jwtParts = jwtAssertion.split('.') - if (jwtParts.size != 3) { - throw IllegalStateException("Invalid client assertion") - } - val body = Json.parseToJsonElement(String(jwtParts[1].fromBase64Url())).jsonObject - return body["iss"]!!.jsonPrimitive.content - } - - private suspend fun generateDocumentConfiguration( + private suspend fun generateFunkeDocumentConfiguration( env: FlowEnvironment, isCloudSecureArea: Boolean ): DocumentConfiguration { + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val config = metadata.credentialConfigurations[credentialConfigurationId]!! val resources = env.getInterface(Resources::class)!! - return when (credentialFormat) { - CredentialFormat.SD_JWT_VC -> { + return when (config.format) { + is Openid4VciFormatSdJwt -> { val art = resources.getRawResource( if (isCloudSecureArea) { "funke/card_art_funke_sdjwt_c1.png" @@ -585,10 +662,10 @@ class FunkeIssuingAuthorityState( art.toByteArray(), false, null, - SdJwtVcDocumentConfiguration(FunkeUtil.SD_JWT_VCT) + SdJwtVcDocumentConfiguration(config.format.vct) ) } - CredentialFormat.MDOC_MSO -> { + is Openid4VciFormatMdoc -> { val art = resources.getRawResource( if (isCloudSecureArea) { "funke/card_art_funke_mdoc_c1.png" @@ -601,14 +678,51 @@ class FunkeIssuingAuthorityState( art.toByteArray(), false, MdocDocumentConfiguration( - EUPersonalID.EUPID_DOCTYPE, + config.format.docType, + staticData = fillInSampleData( + documentTypeRepository.getDocumentTypeForMdoc(config.format.docType)!! + ).build() + ), + null + ) + } + else -> throw IllegalStateException("Invalid credential format") + } + } + + private suspend fun generateGenericDocumentConfiguration( + env: FlowEnvironment + ): DocumentConfiguration { + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val config = metadata.credentialConfigurations[credentialConfigurationId]!! + val base = getConfiguration(env).pendingDocumentInformation + return when (config.format) { + is Openid4VciFormatSdJwt -> { + DocumentConfiguration( + base.displayName, + base.typeDisplayName, + base.cardArt, + base.requireUserAuthenticationToViewDocument, + null, + SdJwtVcDocumentConfiguration(config.format.vct) + ) + } + is Openid4VciFormatMdoc -> { + DocumentConfiguration( + base.displayName, + base.typeDisplayName, + base.cardArt, + base.requireUserAuthenticationToViewDocument, + MdocDocumentConfiguration( + config.format.docType, staticData = fillInSampleData( - documentTypeRepository.getDocumentTypeForMdoc(EUPersonalID.EUPID_DOCTYPE)!! + documentTypeRepository.getDocumentTypeForMdoc(config.format.docType)!! ).build() ), null ) } + else -> throw IllegalStateException("Invalid credential format") } } @@ -633,8 +747,10 @@ class FunkeIssuingAuthorityState( documentId: String, document: FunkeIssuerDocument ) { + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val authorizationMetadata = metadata.authorizationServers[0] var access = document.access!! - val nowPlusSlack = Clock.System.now() + 10.seconds + val nowPlusSlack = Clock.System.now() + 30.seconds if (access.cNonce != null && nowPlusSlack < access.accessTokenExpiration) { // No need to refresh. return @@ -645,7 +761,7 @@ class FunkeIssuingAuthorityState( env = env, clientId = clientId, issuanceClientId = issuanceClientId, - tokenUrl = "${credentialIssuerUri}/token", + tokenUrl = authorizationMetadata.tokenEndpoint, refreshToken = refreshToken, accessToken = access.accessToken ) @@ -658,18 +774,4 @@ 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/FunkeProofingState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt index e69349e39..acedd5416 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt @@ -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" @@ -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)) @@ -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, 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 264554dc2..9a7849f68 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 @@ -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 { diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt new file mode 100644 index 000000000..97fbd8e62 --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt @@ -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, + val credentialConfigurations: Map, + val authorizationServers: List +) { + 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() + 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? { + val availableSet = available?.map { it.jsonPrimitive.content }?.toSet() ?: return null + return supported.firstOrNull { availableSet.contains(it) } + } + + private fun extractDisplay(displayJson: JsonElement?): List { + 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 +) { + 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") + } +} diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt index 67f95aff4..e0ddcdac8 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt @@ -114,7 +114,7 @@ class IssuingAuthorityState( private const val TYPE_DRIVING_LICENSE = "DrivingLicense" private const val TYPE_PHOTO_ID = "PhotoId" - fun getConfiguration(env: FlowEnvironment, id: String): IssuingAuthorityConfiguration { + suspend fun getConfiguration(env: FlowEnvironment, id: String): IssuingAuthorityConfiguration { return env.cache(IssuingAuthorityConfiguration::class, id) { configuration, resources -> val settings = WalletServerSettings(configuration) val prefix = "issuingAuthority.$id" diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt index 0ea9a402f..7fe0637b0 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt @@ -49,7 +49,7 @@ class ProofingState( } @FlowMethod - fun getEvidenceRequests(env: FlowEnvironment): List { + suspend fun getEvidenceRequests(env: FlowEnvironment): List { if (done) { return listOf() } @@ -128,7 +128,7 @@ class ProofingState( } } - private fun getGraph(env: FlowEnvironment): ProofingGraph { + private suspend fun getGraph(env: FlowEnvironment): ProofingGraph { val storage = env.getInterface(Storage::class)!! val walletApplicationCapabilities = runBlocking { storage.get( 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 fda7ccd42..77d320ad4 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 @@ -75,7 +75,7 @@ class ApplicationSupportState( } @FlowMethod - fun createJwtClientAssertion( + suspend fun createJwtClientAssertion( env: FlowEnvironment, attestation: KeyAttestation, targetIssuanceUrl: String ): String { val settings = WalletServerSettings(env.getInterface(Configuration::class)!!) @@ -93,7 +93,12 @@ class ApplicationSupportState( } @FlowMethod - fun createJwtKeyAttestation( + fun getClientAssertionId(env: FlowEnvironment, targetIssuanceUrl: String): String { + return FUNKE_CLIENT_ID + } + + @FlowMethod + suspend fun createJwtKeyAttestation( env: FlowEnvironment, keyAttestations: List, nonce: String @@ -165,7 +170,7 @@ class ApplicationSupportState( } // Not exposed as RPC! - fun createJwtClientAssertion( + suspend fun createJwtClientAssertion( env: FlowEnvironment, clientPublicKey: EcPublicKey, targetIssuanceUrl: String 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 bcc191f27..b46349f87 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,6 +21,7 @@ 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.Openid4VciIssuerMetadata import com.android.identity.issuance.funke.RequestCredentialsUsingKeyAttestation import com.android.identity.issuance.funke.RequestCredentialsUsingProofOfPossession import com.android.identity.issuance.funke.register @@ -30,6 +31,7 @@ import com.android.identity.issuance.hardcoded.RegistrationState import com.android.identity.issuance.hardcoded.RequestCredentialsState import com.android.identity.issuance.hardcoded.register import com.android.identity.issuance.register +import com.android.identity.util.Logger import kotlinx.io.bytestring.buildByteString import kotlin.random.Random @@ -115,7 +117,7 @@ class WalletServerState( } @FlowMethod - fun getIssuingAuthorityConfigurations(env: FlowEnvironment): List { + suspend fun getIssuingAuthorityConfigurations(env: FlowEnvironment): List { check(clientId.isNotEmpty()) val settings = WalletServerSettings(env.getInterface(Configuration::class)!!) val issuingAuthorityList = settings.getStringList("issuingAuthorityList") @@ -126,57 +128,44 @@ class WalletServerState( IssuingAuthorityState.getConfiguration(env, idElem) } } - return fromConfig + listOf( - FunkeIssuingAuthorityState.getConfiguration(env, CredentialFormat.SD_JWT_VC), - FunkeIssuingAuthorityState.getConfiguration(env, CredentialFormat.MDOC_MSO) - ) - } - - @FlowMethod - fun getIssuingAuthority(env: FlowEnvironment, identifier: String): AbstractIssuingAuthorityState { - check(clientId.isNotEmpty()) - return when (identifier) { - "funkeSdJwtVc" -> FunkeIssuingAuthorityState( - clientId = clientId, - credentialFormat = CredentialFormat.SD_JWT_VC, - credentialIssuerUri = FUNKE_BASE_URL - ) - - "funkeMdocMso" -> FunkeIssuingAuthorityState( - clientId = clientId, - credentialFormat = CredentialFormat.MDOC_MSO, - credentialIssuerUri = FUNKE_BASE_URL - ) - - else -> IssuingAuthorityState(clientId, identifier) + try { + // Add everything that Funke server exposes + val funkeMetadata = Openid4VciIssuerMetadata.get(env, FUNKE_BASE_URL) + val fromFunkeServer = funkeMetadata.credentialConfigurations.keys.map { id -> + FunkeIssuingAuthorityState.getConfiguration(env, FUNKE_BASE_URL, id) + } + return fromConfig + fromFunkeServer + } catch (err: Exception) { + Logger.e(TAG, "Could not reach server $FUNKE_BASE_URL", err) + return fromConfig } } - /** - * Returns the Issuing Authority State [AbstractIssuingAuthorityState] created with a specific - * issuing authority Uri for mdoc or sd-jwt credential, such as from OID4VCI credential offer - * deep link / Qr code. - */ @FlowMethod - fun createIssuingAuthorityByUri( + suspend fun getIssuingAuthority( env: FlowEnvironment, - credentialIssuerUri: String, - credentialConfigurationId: String + identifier: String ): AbstractIssuingAuthorityState { - return when (credentialConfigurationId) { - "pid-sd-jwt" -> FunkeIssuingAuthorityState( - clientId = clientId, - credentialFormat = CredentialFormat.SD_JWT_VC, - credentialIssuerUri = credentialIssuerUri - ) - - "pid-mso-mdoc" -> FunkeIssuingAuthorityState( + check(clientId.isNotEmpty()) + if (identifier.startsWith("openid4vci#")) { + val parts = identifier.split("#") + if (parts.size != 3) { + throw IllegalStateException("Invalid openid4vci id") + } + val credentialIssuerUri = parts[1] + val credentialConfigurationId = parts[2] + // NB: applicationSupport will only be non-null when running this code locally in the + // Android Wallet app. + val applicationSupport = env.getInterface(ApplicationSupport::class) + val issuanceClientId = applicationSupport?.getClientAssertionId(credentialIssuerUri) + ?: ApplicationSupportState(clientId).getClientAssertionId(env, credentialIssuerUri) + return FunkeIssuingAuthorityState( clientId = clientId, - credentialFormat = CredentialFormat.MDOC_MSO, - credentialIssuerUri = credentialIssuerUri + credentialIssuerUri = credentialIssuerUri, + credentialConfigurationId = credentialConfigurationId, + issuanceClientId = issuanceClientId ) - - else -> IssuingAuthorityState(clientId, credentialConfigurationId) } + return IssuingAuthorityState(clientId, identifier) } } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactory.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactory.kt new file mode 100644 index 000000000..3258cbe49 --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactory.kt @@ -0,0 +1,63 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.flow.handler.InvalidRequestException +import com.android.identity.flow.server.FlowEnvironment +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal interface CredentialFactory { + val offerId: String + val scope: String + val format: Openid4VciFormat + val proofSigningAlgorithms: List + val cryptographicBindingMethods: List + val credentialSigningAlgorithms: List + val name: String // human-readable name + val logo: String? // relative URL for the image + + suspend fun makeCredential(environment: FlowEnvironment, state: IssuanceState, + authenticationKey: EcPublicKey): String + + companion object { + val byOfferId: Map + val supportedScopes: Set + + init { + val makers = mutableListOf( + CredentialFactoryMdl(), + CredentialFactorySdJwtSample() + ) + byOfferId = makers.associateBy { it.offerId } + supportedScopes = makers.map { it.scope }.toSet() + } + + val DEFAULT_PROOF_SIGNING_ALGORITHMS = listOf("ES256") + val DEFAULT_CREDENTIAL_SIGNING_ALGORITHMS = listOf("ES256") + } +} + +internal sealed class Openid4VciFormat { + abstract val id: String + + companion object { + fun fromJson(json: JsonObject): Openid4VciFormat { + return when (val format = json["format"]?.jsonPrimitive?.content) { + "vc+sd-jwt" -> Openid4VciFormatSdJwt(json["vct"]!!.jsonPrimitive.content) + "mso_mdoc" -> Openid4VciFormatMdoc(json["doctype"]!!.jsonPrimitive.content) + else -> throw InvalidRequestException("Unsupported format '$format'") + } + } + } +} + +internal data class Openid4VciFormatMdoc(val docType: String) : Openid4VciFormat() { + override val id: String get() = "mso_mdoc" +} + +internal val openId4VciFormatMdl = Openid4VciFormatMdoc(DrivingLicense.MDL_DOCTYPE) + +internal data class Openid4VciFormatSdJwt(val vct: String) : Openid4VciFormat() { + override val id: String get() = "vc+sd-jwt" +} diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactoryMdl.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactoryMdl.kt new file mode 100644 index 000000000..da4a3dc67 --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactoryMdl.kt @@ -0,0 +1,174 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborArray +import com.android.identity.cbor.DataItem +import com.android.identity.cbor.Tagged +import com.android.identity.cbor.Tstr +import com.android.identity.cbor.toDataItem +import com.android.identity.cose.Cose +import com.android.identity.cose.CoseLabel +import com.android.identity.cose.CoseNumberLabel +import com.android.identity.crypto.Algorithm +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.DrivingLicense +import com.android.identity.documenttype.knowntypes.EUPersonalID +import com.android.identity.flow.server.FlowEnvironment +import com.android.identity.flow.server.Resources +import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator +import com.android.identity.mdoc.mso.StaticAuthDataGenerator +import com.android.identity.mdoc.util.MdocUtil +import com.android.identity.util.toBase64Url +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.random.Random +import kotlin.time.Duration.Companion.days + +internal class CredentialFactoryMdl : CredentialFactory { + override val offerId: String + get() = "mDL" + + override val scope: String + get() = "mDL" + + override val format: Openid4VciFormat + get() = openId4VciFormatMdl + + override val proofSigningAlgorithms: List + get() = CredentialFactory.DEFAULT_PROOF_SIGNING_ALGORITHMS + + override val cryptographicBindingMethods: List + get() = listOf("cose_key") + + override val credentialSigningAlgorithms: List + get() = CredentialFactory.DEFAULT_CREDENTIAL_SIGNING_ALGORITHMS + + override val name: String + get() = "Example Driver License (mDL)" + + override val logo: String + get() = "card-mdl.png" + + override suspend fun makeCredential( + environment: FlowEnvironment, + state: IssuanceState, + authenticationKey: EcPublicKey + ): String { + val now = Clock.System.now() + + // Create AuthKeys and MSOs, make sure they're valid for 30 days. Also make + // sure to not use fractional seconds as 18013-5 calls for this (clauses 7.1 + // and 9.1.2.4) + // + val timeSigned = Instant.fromEpochSeconds(now.epochSeconds, 0) + val validFrom = Instant.fromEpochSeconds(now.epochSeconds, 0) + val validUntil = validFrom + 30.days + + // Generate an MSO and issuer-signed data for this authentication key. + val docType = DrivingLicense.MDL_DOCTYPE + val msoGenerator = MobileSecurityObjectGenerator( + "SHA-256", + docType, + authenticationKey + ) + msoGenerator.setValidityInfo(timeSigned, validFrom, validUntil, null) + + val credentialData = NameSpacedData.Builder() + + // As we do not have driver license database, just make up some data to fill mDL + // for demo purposes. Take what we can from the PID that was presented as evidence. + val source = state.credentialData!! + val mdocType = DrivingLicense.getDocumentType() + .mdocDocumentType!!.namespaces[DrivingLicense.MDL_NAMESPACE]!! + for (elementName in source.getDataElementNames(EUPersonalID.EUPID_NAMESPACE)) { + val value = source.getDataElement(EUPersonalID.EUPID_NAMESPACE, elementName) + if (mdocType.dataElements.containsKey(elementName)) { + credentialData.putEntry(DrivingLicense.MDL_NAMESPACE, elementName, value) + } + } + val useMalePhoto = source.hasDataElement(EUPersonalID.EUPID_NAMESPACE, "gender") && + source.getDataElementNumber(EUPersonalID.EUPID_NAMESPACE, "gender") == 1L + val photoResource = if (useMalePhoto) "openid4vci/male.jpg" else "openid4vci/female.jpg" + val photoBytes = environment.getInterface(Resources::class)!!.getRawResource(photoResource) + credentialData.putEntryByteString( + DrivingLicense.MDL_NAMESPACE, "portrait", photoBytes!!.toByteArray()) + + credentialData.putEntry( + DrivingLicense.MDL_NAMESPACE, + "driving_privileges", + Cbor.encode(CborArray.builder() + .addMap() + .put("vehicle_category_code", "A") + .put("issue_date", Tagged(1004, Tstr("2018-08-09"))) + .put("expiry_date", Tagged(1004, Tstr("2028-09-01"))) + .end() + .addMap() + .put("vehicle_category_code", "B") + .put("issue_date", Tagged(1004, Tstr("2017-02-23"))) + .put("expiry_date", Tagged(1004, Tstr("2028-09-01"))) + .end() + .end() + .build()) + ) + + val randomProvider = Random.Default + val issuerNameSpaces = MdocUtil.generateIssuerNameSpaces( + credentialData.build(), + randomProvider, + 16, + null + ) + for (nameSpaceName in issuerNameSpaces.keys) { + val digests = MdocUtil.calculateDigestsForNameSpace( + nameSpaceName, + issuerNameSpaces, + Algorithm.SHA256 + ) + msoGenerator.addDigestIdsForNamespace(nameSpaceName, digests) + } + + val resources = environment.getInterface(Resources::class)!! + val documentSigningKeyCert = X509Cert.fromPem( + resources.getStringResource("ds_certificate.pem")!!) + val documentSigningKey = EcPrivateKey.fromPem( + resources.getStringResource("ds_private_key.pem")!!, + documentSigningKeyCert.ecPublicKey + ) + + val mso = msoGenerator.generate() + val taggedEncodedMso = Cbor.encode(Tagged(Tagged.ENCODED_CBOR, Bstr(mso))) + val protectedHeaders = mapOf(Pair( + CoseNumberLabel(Cose.COSE_LABEL_ALG), + Algorithm.ES256.coseAlgorithmIdentifier.toDataItem() + )) + val unprotectedHeaders = mapOf(Pair( + CoseNumberLabel(Cose.COSE_LABEL_X5CHAIN), + X509CertChain(listOf( + X509Cert(documentSigningKeyCert.encodedCertificate) + ) + ).toDataItem() + )) + val encodedIssuerAuth = Cbor.encode( + Cose.coseSign1Sign( + documentSigningKey, + taggedEncodedMso, + true, + Algorithm.ES256, + protectedHeaders, + unprotectedHeaders + ).toDataItem() + ) + + val issuerProvidedAuthenticationData = StaticAuthDataGenerator( + issuerNameSpaces, + encodedIssuerAuth + ).generate() + + return issuerProvidedAuthenticationData.toBase64Url() + } +} \ No newline at end of file diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactorySdJwtSample.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactorySdJwtSample.kt new file mode 100644 index 000000000..73afad697 --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialFactorySdJwtSample.kt @@ -0,0 +1,87 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.EcPrivateKey +import com.android.identity.crypto.EcPublicKey +import com.android.identity.crypto.X509Cert +import com.android.identity.documenttype.knowntypes.EUPersonalID +import com.android.identity.flow.server.FlowEnvironment +import com.android.identity.flow.server.Resources +import com.android.identity.sdjwt.Issuer +import com.android.identity.sdjwt.SdJwtVcGenerator +import com.android.identity.sdjwt.util.JsonWebKey +import kotlinx.datetime.Clock +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.random.Random +import kotlin.time.Duration.Companion.days + +internal class CredentialFactorySdJwtSample : CredentialFactory { + override val offerId: String + get() = "sample" + + override val scope: String + get() = "sample_sd_jwt" + + override val format: Openid4VciFormat + get() = FORMAT + + override val proofSigningAlgorithms: List + get() = CredentialFactory.DEFAULT_PROOF_SIGNING_ALGORITHMS + + override val cryptographicBindingMethods: List + get() = listOf("jwk") + + override val credentialSigningAlgorithms: List + get() = CredentialFactory.DEFAULT_CREDENTIAL_SIGNING_ALGORITHMS + + override val name: String + get() = "Example EAA (SD-JWT)" + + override val logo: String + get() = "card-generic.png" + + override suspend fun makeCredential( + environment: FlowEnvironment, + state: IssuanceState, + authenticationKey: EcPublicKey + ): String { + val data = state.credentialData!! + val identityAttributes = buildJsonObject { + put("given_name", JsonPrimitive(data.getDataElementString(EUPersonalID.EUPID_NAMESPACE, "given_name"))) + put("family_name", JsonPrimitive(data.getDataElementString(EUPersonalID.EUPID_NAMESPACE, "family_name"))) + } + + val sdJwtVcGenerator = SdJwtVcGenerator( + random = Random, + payload = identityAttributes, + docType = EUPersonalID.EUPID_VCT, + issuer = Issuer("https://example-issuer.com", Algorithm.ES256, "key-1") + ) + + val now = Clock.System.now() + + val timeSigned = now + val validFrom = now + val validUntil = validFrom + 30.days + + sdJwtVcGenerator.publicKey = JsonWebKey(authenticationKey) + sdJwtVcGenerator.timeSigned = timeSigned + sdJwtVcGenerator.timeValidityBegin = validFrom + sdJwtVcGenerator.timeValidityEnd = validUntil + + val resources = environment.getInterface(Resources::class)!! + val documentSigningKeyCert = + X509Cert.fromPem(resources.getStringResource("ds_certificate.pem")!!) + val documentSigningKey = EcPrivateKey.fromPem( + resources.getStringResource("ds_private_key.pem")!!, + documentSigningKeyCert.ecPublicKey) + val sdJwt = sdJwtVcGenerator.generateSdJwt(documentSigningKey) + + return sdJwt.toString() + } + + companion object { + val FORMAT = Openid4VciFormatSdJwt("example") + } +} \ No newline at end of file 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 76ac0007f..53caeb927 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 @@ -16,6 +16,7 @@ import com.android.identity.crypto.X509CertChain 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.FlowEnvironment import com.android.identity.flow.server.Resources import com.android.identity.flow.server.Storage import com.android.identity.issuance.common.cache @@ -35,6 +36,7 @@ import kotlinx.datetime.Instant import kotlinx.io.bytestring.ByteString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray @@ -74,34 +76,56 @@ class CredentialServlet : BaseServlet() { 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 format = Openid4VciFormat.fromJson(json) + val factory = CredentialFactory.byOfferId.values.find { factory -> + factory.format == format && factory.scope == state.scope + } + if (factory == null) { + throw IllegalStateException( + "No credential can be created for scope '${state.scope}' and the given format") + } + val proofsObj = json["proofs"]?.jsonObject + val singleProof = proofsObj == null + val proofs: JsonArray + val proofType: String + if (proofsObj == 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") + proofType = proof.jsonObject["proof_type"]?.jsonPrimitive?.content!! + proofs = buildJsonArray { add(proof.jsonObject[proofType]!!) } + } else { + proofType = if (proofsObj.containsKey("jwt")) { + "jwt" + } else if (proofsObj.containsKey("attestation")) { + "attestation" + } else { + throw InvalidRequestException("Unsupported proof type") + } + proofs = proofsObj[proofType]!!.jsonArray + if (proofs.size == 0) { + throw InvalidRequestException("'proofs' is empty") + } } - 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) + val keyAttestationCertificate = runBlocking { + 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 + val keyAttestation = proof.jsonPrimitive.content checkJwtSignature( keyAttestationCertificate.certificate.ecPublicKey, keyAttestation @@ -123,9 +147,7 @@ class CredentialServlet : BaseServlet() { } } "jwt" -> { - val requireKeyAttestation = environment.getInterface(Configuration::class) - ?.getValue("openid4vci.key-attestation.required") - if (requireKeyAttestation != "false") { + if (!isStandaloneProofOfPossessionAccepted(environment)) { throw InvalidRequestException("jwt proofs are not accepted by this server") } proofs.map { proof -> @@ -146,30 +168,14 @@ class CredentialServlet : BaseServlet() { throw InvalidRequestException("unsupported proof type") } } - val format = json["format"]?.jsonPrimitive?.content - 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) - } - } - "mso_mdoc" -> { - val vct = json["doctype"]?.jsonPrimitive?.content - if (vct != EUPersonalID.EUPID_DOCTYPE) { - throw InvalidRequestException("invalid value for 'doctype' parameter") - } + + val credentials = + runBlocking { authenticationKeys.map { key -> - createCredentialMdoc(state, key).toBase64Url() + factory.makeCredential(environment, state, key) } } - else -> { - throw InvalidRequestException("invalid value for 'format' parameter") - } - } + val result = if (singleProof && credentials.size == 1) { buildJsonObject { put("credential", credentials[0]) @@ -305,4 +311,23 @@ class CredentialServlet : BaseServlet() { } private data class KeyAttestationCertificate(val certificate: X509Cert) + + companion object { + /** + * Checks if this server accepts proof of possession **without** key attestation. + * + * Our code does not support proof of possession with key attestation (only standalone + * key attestation or standalone proof of possession). Standalone proof of possession + * is disabled by default, as it does not really guarantee where the private key is + * stored. Don't enable this unless you are sure you understand the tradeoffs involved! + * + * NB: Proof of possession **with** key attestation, if implemented, could be just enabled + * unconditionally, as it does not have this issue (as long as key attestation is required). + */ + internal fun isStandaloneProofOfPossessionAccepted(environment: FlowEnvironment): Boolean { + val allowProofOfPossession = environment.getInterface(Configuration::class) + ?.getValue("openid4vci.allow-proof-of-possession") + return allowProofOfPossession == "true" + } + } } \ 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 f6edc1d97..d516b6855 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 @@ -22,7 +22,7 @@ class FinishAuthorizationServlet : BaseServlet() { val storage = environment.getInterface(Storage::class)!! runBlocking { val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) - val redirectUri = state.redirectUri ?: "" + val redirectUri = state.redirectUri ?: throw IllegalStateException("No redirect url") if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) { 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 7a6f7744b..0d08d30af 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 @@ -40,8 +40,9 @@ class ParServlet : BaseServlet() { if (req.getParameter("client_assertion_type") != ASSERTION_TYPE) { throw InvalidRequestException("invalid parameter 'client_assertion_type'") } - if (req.getParameter("scope") != "pid") { - throw InvalidRequestException("invalid parameter 'pid'") + val scope = req.getParameter("scope") ?: "" + if (!CredentialFactory.supportedScopes.contains(scope)) { + throw InvalidRequestException("invalid parameter 'scope'") } if (req.getParameter("response_type") != "code") { throw InvalidRequestException("invalid parameter 'response_type'") @@ -62,16 +63,18 @@ class ParServlet : BaseServlet() { // Check that client assertion is signed by the trusted client. val sequence = clientAssertion.split("~") - val clientCertificate = environment.cache( - ClientCertificate::class, - clientId - ) { configuration, resources -> - // So in real life this should be parameterized by clientId, as different clients will - // have different public keys. - val certificateName = configuration.getValue("attestation.certificate") - ?: "attestation/certificate.pem" - val certificate = X509Cert.fromPem(resources.getStringResource(certificateName)!!) - ClientCertificate(certificate) + val clientCertificate = runBlocking { + environment.cache( + ClientCertificate::class, + clientId + ) { configuration, resources -> + // So in real life this should be parameterized by clientId, as different clients will + // have different public keys. + val certificateName = configuration.getValue("attestation.certificate") + ?: "attestation/certificate.pem" + val certificate = X509Cert.fromPem(resources.getStringResource(certificateName)!!) + ClientCertificate(certificate) + } } checkJwtSignature(clientCertificate.certificate.ecPublicKey, sequence[0]) @@ -85,7 +88,7 @@ class ParServlet : BaseServlet() { // Create a session val storage = environment.getInterface(Storage::class)!! - val state = IssuanceState(clientId, dpopKey, redirectUri, codeChallenge) + val state = IssuanceState(clientId, scope, dpopKey, redirectUri, codeChallenge) val id = runBlocking { storage.insert("IssuanceState", "", ByteString(state.toCbor())) } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt new file mode 100644 index 000000000..73a5fd7c8 --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOauthAuthorizationServlet.kt @@ -0,0 +1,30 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.flow.server.Configuration +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +class WellKnownOauthAuthorizationServlet : BaseServlet() { + override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { + val configuration = environment.getInterface(Configuration::class)!! + val baseUrl = configuration.getValue("base_url") + "/openid4vci" + resp.writer.write(buildJsonObject { + put("issuer", JsonPrimitive(baseUrl)) + put("authorization_endpoint", JsonPrimitive("$baseUrl/authorize")) + put("token_endpoint", JsonPrimitive("$baseUrl/token")) + put("pushed_authorization_request_endpoint", JsonPrimitive("$baseUrl/par")) + put("require_pushed_authorization_requests", JsonPrimitive(true)) + put("token_endpoint_auth_methods_supported", + buildJsonArray { add(JsonPrimitive("none")) }) + put("response_types_supported", + buildJsonArray { add(JsonPrimitive("code")) }) + put("code_challenge_methods_supported", + buildJsonArray { add(JsonPrimitive("S256")) }) + put("dpop_signing_alg_values_supported", + buildJsonArray { add(JsonPrimitive("ES256")) }) + }.toString()) + } +} \ No newline at end of file diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt new file mode 100644 index 000000000..4ea826f3c --- /dev/null +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/WellKnownOpenidCredentialIssuerServlet.kt @@ -0,0 +1,89 @@ +package com.android.identity.server.openid4vci + +import com.android.identity.flow.server.Configuration +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +class WellKnownOpenidCredentialIssuerServlet : BaseServlet() { + companion object { + const val PREFIX = "openid4vci.issuer" + } + override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { + val configuration = environment.getInterface(Configuration::class)!! + val baseUrl = configuration.getValue("base_url") + "/openid4vci" + resp.writer.write(buildJsonObject { + put("credential_issuer", JsonPrimitive(baseUrl)) + put("credential_endpoint", JsonPrimitive("$baseUrl/credential")) + put("authorization_servers", buildJsonArray { + add(JsonPrimitive(baseUrl)) + }) + put("display", buildJsonArray { + add(buildJsonObject { + put("name", JsonPrimitive("Open Wallet Sample Issuer")) + put("locale", JsonPrimitive("en-US")) + put("logo", buildJsonObject { + put("uri", JsonPrimitive("$baseUrl/logo.png")) + }) + }) + }) + put("batch_credential_issuance", buildJsonObject { + val batchSize = + configuration.getValue("$PREFIX.batch_size")?.toIntOrNull() ?: 12 + put("batch_size", JsonPrimitive(batchSize)) + }) + put("credential_configurations_supported", buildJsonObject { + for (credentialFactory in CredentialFactory.byOfferId.values) { + put(credentialFactory.offerId, buildJsonObject { + put("scope", JsonPrimitive(credentialFactory.scope)) + val format = credentialFactory.format + put("format", JsonPrimitive(format.id)) + when (format) { + is Openid4VciFormatMdoc -> put("doctype", JsonPrimitive(format.docType)) + is Openid4VciFormatSdJwt -> put("vct", JsonPrimitive(format.vct)) + } + put("proof_types_supported", buildJsonObject { + if (CredentialServlet.isStandaloneProofOfPossessionAccepted(environment)) { + put("jwt", buildJsonObject { + put("proof_signing_alg_values_supported", + JsonArray(credentialFactory.proofSigningAlgorithms.map { + JsonPrimitive(it) + }) + ) + }) + } + put("attestation", buildJsonObject { + put("proof_signing_alg_values_supported", + JsonArray(credentialFactory.proofSigningAlgorithms.map { + JsonPrimitive(it) + })) + }) + }) + put("cryptographic_binding_methods_supported", + JsonArray(credentialFactory.cryptographicBindingMethods.map { + JsonPrimitive(it) + })) + put("credential_signing_alg_values_supported", + JsonArray(credentialFactory.credentialSigningAlgorithms.map { + JsonPrimitive(it) + })) + put("display", buildJsonArray { + add(buildJsonObject { + put("name", JsonPrimitive(credentialFactory.name)) + put("locale", JsonPrimitive("en-US")) + if (credentialFactory.logo != null) { + put("logo", buildJsonObject { + put("uri", JsonPrimitive("$baseUrl/${credentialFactory.logo}")) + }) + } + }) + }) + }) + } + }) + }.toString()) + } +} \ No newline at end of file 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 4ff1f7c73..56ca5875d 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 @@ -32,6 +32,7 @@ data class TokenResponse( @CborSerializable data class IssuanceState( val clientId: String, + val scope: String, val dpopKey: EcPublicKey, var redirectUri: String?, var codeChallenge: ByteString?, diff --git a/server/src/main/resources/resources/openid4vci/female.jpg b/server/src/main/resources/resources/openid4vci/female.jpg new file mode 100644 index 000000000..b5af19912 Binary files /dev/null and b/server/src/main/resources/resources/openid4vci/female.jpg differ diff --git a/server/src/main/resources/resources/openid4vci/male.jpg b/server/src/main/resources/resources/openid4vci/male.jpg new file mode 100644 index 000000000..1ae067807 Binary files /dev/null and b/server/src/main/resources/resources/openid4vci/male.jpg differ diff --git a/server/src/main/webapp/WEB-INF/web.xml b/server/src/main/webapp/WEB-INF/web.xml index bdcc5010b..cd874d691 100644 --- a/server/src/main/webapp/WEB-INF/web.xml +++ b/server/src/main/webapp/WEB-INF/web.xml @@ -343,4 +343,27 @@ /openid4vci/credential_request + + WellKnownOpenidCredentialIssuanceServlet + WellKnownOpenidCredentialIssuanceServlet + com.android.identity.server.openid4vci.WellKnownOpenidCredentialIssuerServlet + 10 + + + + WellKnownOpenidCredentialIssuanceServlet + /openid4vci/.well-known/openid-credential-issuer + + + + WellKnownOauthAuthorizationServlet + WellKnownOauthAuthorizationServlet + com.android.identity.server.openid4vci.WellKnownOauthAuthorizationServlet + 10 + + + + WellKnownOauthAuthorizationServlet + /openid4vci/.well-known/oauth-authorization-server + diff --git a/server/src/main/webapp/openid4vci/card-generic.png b/server/src/main/webapp/openid4vci/card-generic.png new file mode 100644 index 000000000..3d691df3f Binary files /dev/null and b/server/src/main/webapp/openid4vci/card-generic.png differ diff --git a/server/src/main/webapp/openid4vci/card-mdl.png b/server/src/main/webapp/openid4vci/card-mdl.png new file mode 100644 index 000000000..7330faf88 Binary files /dev/null and b/server/src/main/webapp/openid4vci/card-mdl.png differ diff --git a/server/src/main/webapp/openid4vci/display_metadata.js b/server/src/main/webapp/openid4vci/display_metadata.js new file mode 100644 index 000000000..27cd3040a --- /dev/null +++ b/server/src/main/webapp/openid4vci/display_metadata.js @@ -0,0 +1,42 @@ +displayMetadata() + +async function displayMetadata() { + let body = document.body; + let issuance = await (await fetch(".well-known/openid-credential-issuer")).json(); + let hi = document.createElement("img"); + hi.setAttribute("src", issuance.display[0].logo?.uri); + hi.setAttribute("style", "width:20%; float: right"); + body.appendChild(hi); + let h1 = document.createElement("h1"); + h1.textContent = issuance.display[0].name; + body.appendChild(h1); + let list = document.createElement("ul"); + let configs = issuance.credential_configurations_supported; + let h2 = document.createElement("h2"); + h2.textContent = "Credentials available from this server"; + body.appendChild(h2); + for (let configId in configs) { + let config = configs[configId]; + let item = document.createElement("li"); + list.appendChild(item); + let a = document.createElement("a"); + item.appendChild(a); + let h3 = document.createElement("h3"); + a.appendChild(h3); + h3.textContent = config.display[0].name; + let img = document.createElement("img"); + img.setAttribute("src", config.display[0].logo?.uri); + img.setAttribute("style", "width:80%;margin-bottom:2em"); + a.appendChild(img); + let url = location.href.substring(0, location.href.lastIndexOf("/")); + let offer = { + credential_issuer: url, + credential_configuration_ids: [configId], + grants: { + authorization_code:{} + } + }; + a.href = "openid-credential-offer://?credential_offer=" + encodeURIComponent(JSON.stringify(offer)) + } + body.appendChild(list) +} \ No newline at end of file diff --git a/server/src/main/webapp/openid4vci/index.html b/server/src/main/webapp/openid4vci/index.html new file mode 100644 index 000000000..baf56b5a2 --- /dev/null +++ b/server/src/main/webapp/openid4vci/index.html @@ -0,0 +1,11 @@ + + + + + + OpenId4VCI Sample server + + + + + \ No newline at end of file diff --git a/server/src/main/webapp/openid4vci/logo.png b/server/src/main/webapp/openid4vci/logo.png new file mode 100644 index 000000000..4bcb80d1a Binary files /dev/null and b/server/src/main/webapp/openid4vci/logo.png differ diff --git a/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt b/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt index 0b31cbaf4..846f6116e 100644 --- a/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt +++ b/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt @@ -191,13 +191,24 @@ class WalletServerProvider( */ suspend fun getIssuingAuthority(issuingAuthorityId: String): IssuingAuthority { val instance = waitForWalletServer() - instanceLock.withLock { - var issuingAuthority = issuingAuthorityMap[issuingAuthorityId] - if (issuingAuthority == null) { - issuingAuthority = instance.getIssuingAuthority(issuingAuthorityId) - issuingAuthorityMap[issuingAuthorityId] = issuingAuthority + var delay = RECONNECT_DELAY_INITIAL + while (true) { + try { + instanceLock.withLock { + var issuingAuthority = issuingAuthorityMap[issuingAuthorityId] + if (issuingAuthority == null) { + issuingAuthority = instance.getIssuingAuthority(issuingAuthorityId) + issuingAuthorityMap[issuingAuthorityId] = issuingAuthority + } + return issuingAuthority + } + } catch (err: HttpTransport.ConnectionException) { + delay(delay) + delay *= 2 + if (delay > RECONNECT_DELAY_MAX) { + delay = RECONNECT_DELAY_MAX + } } - return issuingAuthority } } @@ -205,16 +216,15 @@ class WalletServerProvider( * Creates an Issuing Authority by the [credentialIssuerUri] and [credentialConfigurationId], * caching instances. If unable to connect, suspend and wait until connecting is possible. */ - suspend fun createIssuingAuthorityByUri(credentialIssuerUri:String, credentialConfigurationId: String): IssuingAuthority { - val instance = waitForWalletServer() - instanceLock.withLock { - var issuingAuthority = issuingAuthorityMap[credentialConfigurationId] - if (issuingAuthority == null) { - issuingAuthority = instance.createIssuingAuthorityByUri(credentialIssuerUri, credentialConfigurationId) - issuingAuthorityMap[credentialConfigurationId] = issuingAuthority - } - return issuingAuthority - } + suspend fun createOpenid4VciIssuingAuthorityByUri( + credentialIssuerUri:String, + credentialConfigurationId: String + ): IssuingAuthority { + // Not allowed per spec, but double-check, so there are no surprises. + check(credentialIssuerUri.indexOf('#') < 0) + check(credentialConfigurationId.indexOf('#') < 0) + val id = "openid4vci#$credentialIssuerUri#$credentialConfigurationId" + return getIssuingAuthority(id) } /** diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt index 513511b8a..5523b1a86 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt @@ -103,7 +103,7 @@ class ProvisioningViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { try { if (credentialIssuerUri != null) { - issuer = walletServerProvider.createIssuingAuthorityByUri( + issuer = walletServerProvider.createOpenid4VciIssuingAuthorityByUri( credentialIssuerUri, credentialIssuerConfigurationId!! ) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt index 1b22dfc0f..52d54c880 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/navigation/WalletDestination.kt @@ -4,6 +4,9 @@ import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.navArgument +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.Charset sealed class WalletDestination(val routeEnum: Route) { @@ -121,7 +124,7 @@ sealed class WalletDestination(val routeEnum: Route) { argsStrList.forEach { argPairStr -> // find matching pair and return the string value if (argPairStr.startsWith("${argument.name}=")) { - return argPairStr.split("=")[1] + return URLDecoder.decode(argPairStr.split("=")[1], "UTF-8") } } return null @@ -147,7 +150,7 @@ sealed class WalletDestination(val routeEnum: Route) { if (argumentValue is List<*>) { argumentValue.joinToString() } else { - argumentValue + argumentValue.toString() } val argName = when (this) { is DocumentInfo -> { @@ -164,7 +167,8 @@ sealed class WalletDestination(val routeEnum: Route) { throw Exception("Error! Attempted to pass argument '$enumArgumentObj' to WalletDestination '$this' but the Argument object is not defined in 'getRouteWithArguments'") } } - ret += "$argName=$argVal&" + val encodedArg = URLEncoder.encode(argVal, "UTF-8") + ret += "$argName=$encodedArg&" } return ret }