diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt new file mode 100644 index 000000000..19df9cdd7 --- /dev/null +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt @@ -0,0 +1,308 @@ +package com.android.identity.documenttype.knowntypes + +import com.android.identity.cbor.toDataItem +import com.android.identity.cbor.toDataItemFullDate +import com.android.identity.documenttype.DocumentAttributeType +import com.android.identity.documenttype.DocumentType +import com.android.identity.documenttype.Icon + +/** + * Object containing the metadata of the EU Certificate of Residency (COR) document. + * + * TODO: see if this document type still exists and how exactly it is defined. This + * definition is ad hoc and added to facilitate interoperability testing. + */ +object EUCertificateOfResidence { + const val DOCTYPE = "eu.europa.ec.eudi.cor.1" + const val NAMESPACE = "eu.europa.ec.eudi.cor.1" + const val VCT = "https://example.eudi.ec.europa.eu/cor/1" + + /** + * Build the EU Personal ID Document Type. + */ + fun getDocumentType(): DocumentType { + return DocumentType.Builder("EU Certificate of Residency") + .addMdocDocumentType(DOCTYPE) + .addVcDocumentType(VCT) + .addAttribute( + DocumentAttributeType.String, + "family_name", + "Family Name", + "Current last name(s), surname(s), or primary identifier of the COR holder", + true, + NAMESPACE, + Icon.PERSON, + SampleData.FAMILY_NAME.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "given_name", + "Given Names", + "Current first name(s), other name(s), or secondary identifier of the COR holder", + true, + NAMESPACE, + Icon.PERSON, + SampleData.GIVEN_NAME.toDataItem() + ) + .addAttribute( + DocumentAttributeType.Date, + "birth_date", + "Date of Birth", + "Day, month, and year on which the COR holder was born. If unknown, approximate date of birth.", + true, + NAMESPACE, + Icon.TODAY, + SampleData.birthDate.toDataItemFullDate() + ) + .addAttribute( + DocumentAttributeType.Boolean, + "age_over_18", + "Older Than 18", + "Age over 18?", + false, + NAMESPACE, + Icon.TODAY, + SampleData.AGE_OVER_18.toDataItem() + ) + .addAttribute( + DocumentAttributeType.Boolean, + "age_over_21", + "Older Than 21", + "Age over 21?", + false, + NAMESPACE, + Icon.TODAY, + SampleData.AGE_OVER_21.toDataItem() + ) + .addAttribute( + DocumentAttributeType.Date, + "arrival_date", + "Date of Arrival", + "Day, month, and year on which the COR holder arrived to the EU.", + false, + NAMESPACE, + Icon.DATE_RANGE, + SampleData.issueDate.toDataItemFullDate() + ) + .addAttribute( + DocumentAttributeType.String, + "resident_address", + "Resident Address", + "The full address of the place where the COR holder currently resides and/or may be contacted (street/house number, municipality etc.)", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_ADDRESS.toDataItem() + ) + .addAttribute( + DocumentAttributeType.StringOptions(Options.COUNTRY_ISO_3166_1_ALPHA_2), + "resident_country", + "Resident Country", + "The country where the PID User currently resides, as an Alpha-2 country code as specified in ISO 3166-1", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_COUNTRY.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "resident_state", + "Resident State", + "The state, province, district, or local area where the PID User currently resides.", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_STATE.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "resident_city", + "Resident City", + "The city where the COR holder currently resides", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_CITY.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "resident_postal_code", + "Resident Postal Code", + "The postal code of the place where the COR holder currently resides", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_POSTAL_CODE.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "resident_street", + "Resident Street", + "The name of the street where the PID User currently resides.", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_STREET.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "resident_house_number", + "Resident House Number", + "The house number where the PID User currently resides, including any affix or suffix", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_HOUSE_NUMBER.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "birth_place", + "Place of Birth", + "The place where the COR holder was born.", + false, + NAMESPACE, + Icon.PLACE, + SampleData.RESIDENT_CITY.toDataItem() + ) + .addAttribute( + DocumentAttributeType.IntegerOptions(Options.SEX_ISO_IEC_5218), + "gender", + "Gender", + "COR holder’s gender", + false, + NAMESPACE, + Icon.EMERGENCY, + SampleData.SEX_ISO218.toDataItem() + ) + .addAttribute( + DocumentAttributeType.StringOptions(Options.COUNTRY_ISO_3166_1_ALPHA_2), + "nationality", + "Nationality", + "Alpha-2 country code as specified in ISO 3166-1, representing the nationality of the PID User.", + true, + NAMESPACE, + Icon.LANGUAGE, + SampleData.NATIONALITY.toDataItem() + ) + .addAttribute( + DocumentAttributeType.Date, + "issuance_date", + "Date of Issue", + "Date (and possibly time) when the PID was issued.", + true, + NAMESPACE, + Icon.DATE_RANGE, + SampleData.issueDate.toDataItemFullDate() + ) + .addAttribute( + DocumentAttributeType.Date, + "expiry_date", + "Date of Expiry", + "Date (and possibly time) when the PID will expire.", + true, + NAMESPACE, + Icon.CALENDAR_CLOCK, + SampleData.expiryDate.toDataItemFullDate() + ) + .addAttribute( + DocumentAttributeType.String, + "issuing_authority", + "Issuing Authority", + "Name of the administrative authority that has issued this PID instance, or the " + + "ISO 3166 Alpha-2 country code of the respective Member State if there is" + + "no separate authority authorized to issue PIDs.", + true, + NAMESPACE, + Icon.ACCOUNT_BALANCE, + SampleData.ISSUING_AUTHORITY_EU_PID.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "document_number", + "Document Number", + "A number for the PID, assigned by the PID Provider.", + false, + NAMESPACE, + Icon.NUMBERS, + SampleData.DOCUMENT_NUMBER.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "administrative_number", + "Administrative Number", + "A number assigned by the PID Provider for audit control or other purposes.", + false, + NAMESPACE, + Icon.NUMBERS, + SampleData.ADMINISTRATIVE_NUMBER.toDataItem() + ) + .addAttribute( + DocumentAttributeType.String, + "issuing_jurisdiction", + "Issuing Jurisdiction", + "Country subdivision code of the jurisdiction that issued the PID, as defined in " + + "ISO 3166-2:2020, Clause 8. The first part of the code SHALL be the same " + + "as the value for issuing_country.", + false, + NAMESPACE, + Icon.ACCOUNT_BALANCE, + SampleData.ISSUING_JURISDICTION.toDataItem() + ) + .addAttribute( + DocumentAttributeType.StringOptions(Options.COUNTRY_ISO_3166_1_ALPHA_2), + "issuing_country", + "Issuing Country", + "Alpha-2 country code, as defined in ISO 3166-1, of the issuing authority’s " + + "country or territory", + true, + NAMESPACE, + Icon.ACCOUNT_BALANCE, + SampleData.ISSUING_COUNTRY.toDataItem() + ) + .addSampleRequest( + id = "age_over_18", + displayName = "Age Over 18", + mdocDataElements = mapOf( + NAMESPACE to mapOf( + "age_over_18" to false, + ) + ), + vcClaims = listOf("age_over_18") + ) + .addSampleRequest( + id = "mandatory", + displayName = "Mandatory Data Elements", + mdocDataElements = mapOf( + NAMESPACE to mapOf( + "family_name" to false, + "given_name" to false, + "birth_date" to false, + "age_over_18" to false, + "issuance_date" to false, + "expiry_date" to false, + "issuing_authority" to false, + "issuing_country" to false + ) + ), + vcClaims = listOf( + "family_name", + "given_name", + "birth_date", + "age_over_18", + "issuance_date", + "expiry_date", + "issuing_authority", + "issuing_country" + ) + ) + .addSampleRequest( + id = "full", + displayName = "All Data Elements", + mdocDataElements = mapOf( + NAMESPACE to mapOf() + ), + vcClaims = listOf() + ) + .build() + } +} \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestPreauthorizedCode.kt b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestPreauthorizedCode.kt new file mode 100644 index 000000000..1b2ef5dac --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceRequestPreauthorizedCode.kt @@ -0,0 +1,7 @@ +package com.android.identity.issuance.evidence + +/** + * Request pre-authorized code from the client. Pre-authorized code is given to a wallet app + * as a part of an OpenId4VCI credential offer. + */ +class EvidenceRequestPreauthorizedCode : EvidenceRequest() \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt new file mode 100644 index 000000000..84df820ca --- /dev/null +++ b/identity-issuance/src/main/java/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt @@ -0,0 +1,9 @@ +package com.android.identity.issuance.evidence + +/** + * Provides OpenId4VCI pre-authorized code to the issuer. + */ +data class EvidenceResponsePreauthorizedCode( + val code: String, // preauthorized code + val txCode: String? // transaction code entered by the user (may or may not be required) +) : EvidenceResponse() \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt index c06c10b3b..b799ed960 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt @@ -17,14 +17,13 @@ import kotlin.time.Duration.Companion.seconds class FunkeAccess( val accessToken: String, val accessTokenExpiration: Instant, - var dpopNonce: String, + var dpopNonce: String?, var cNonce: String?, var refreshToken: String?, ) { companion object { suspend fun parseResponse(tokenResponse: HttpResponse): FunkeAccess { val dpopNonce = tokenResponse.headers["DPoP-Nonce"] - ?: throw IllegalArgumentException("No DPoP nonce in token response") val tokenString = String(tokenResponse.readBytes()) val token = Json.parseToJsonElement(tokenString) as JsonObject val accessToken = getField(token, "access_token").content 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 f9bda84c5..40cab0696 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 @@ -11,6 +11,7 @@ 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.EUCertificateOfResidence import com.android.identity.documenttype.knowntypes.EUPersonalID import com.android.identity.documenttype.knowntypes.PhotoID import com.android.identity.flow.annotation.FlowJoin @@ -143,7 +144,7 @@ class FunkeIssuingAuthorityState( } } if (cardArt == null) { - val artPath = "funke/card_art_funke_generic.png" + val artPath = "generic/card_art.png" cardArt = resources.getRawResource(artPath)!!.toByteArray() } val requireUserAuthenticationToViewDocument = false @@ -179,6 +180,7 @@ class FunkeIssuingAuthorityState( addDocumentType(EUPersonalID.getDocumentType()) addDocumentType(DrivingLicense.getDocumentType()) addDocumentType(PhotoID.getDocumentType()) + addDocumentType(EUCertificateOfResidence.getDocumentType()) } } @@ -243,11 +245,10 @@ 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] var openid4VpRequest: String? = null - if (proofingInfo.authSession != null && proofingInfo.openid4VpPresentation != null) { + val proofingInfo = performPushedAuthorizationRequest(env) + if (proofingInfo?.authSession != null && proofingInfo.openid4VpPresentation != null) { val httpClient = env.getInterface(HttpClient::class)!! val presentationResponse = httpClient.get(proofingInfo.openid4VpPresentation) {} if (presentationResponse.status == HttpStatusCode.OK) { @@ -262,17 +263,20 @@ class FunkeIssuingAuthorityState( )?.let { WalletApplicationCapabilities.fromCbor(it.toByteArray()) } ?: throw IllegalStateException("WalletApplicationCapabilities not found") + val useGermanId = metadata.authorizationServers.isNotEmpty() + && metadata.authorizationServers[0].useGermanEId return FunkeProofingState( - credentialIssuerUri = credentialIssuerUri, - clientId = clientId, - issuanceClientId = issuanceClientId, - documentId = documentId, - proofingInfo = proofingInfo, - applicationCapabilities = applicationCapabilities, - // Don't show TOS when using browser API - tosAcknowleged = !authorizationMetadata.useGermanEId, - openid4VpRequest = openid4VpRequest - ) + clientId = clientId, + credentialConfigurationId = credentialConfigurationId, + issuanceClientId = issuanceClientId, + documentId = documentId, + credentialIssuerUri = credentialIssuerUri, + proofingInfo = proofingInfo, + applicationCapabilities = applicationCapabilities, + tokenUri = metadata.tokenEndpoint, + openid4VpRequest = openid4VpRequest, + useGermanEId = useGermanId + ) } @FlowJoin @@ -294,7 +298,8 @@ class FunkeIssuingAuthorityState( issuerDocument.state = DocumentCondition.READY if (issuerDocument.documentConfiguration == null) { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - if (metadata.authorizationServers[0].useGermanEId) { + if (metadata.authorizationServers.isNotEmpty() && + metadata.authorizationServers[0].useGermanEId) { val isCloudSecureArea = issuerDocument.secureAreaIdentifier!!.startsWith("CloudSecureArea?") issuerDocument.documentConfiguration = @@ -573,13 +578,13 @@ class FunkeIssuingAuthorityState( } } - private suspend fun performPushedAuthorizationRequest( - env: FlowEnvironment - ): ProofingInfo { + private suspend fun performPushedAuthorizationRequest(env: FlowEnvironment): ProofingInfo? { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + if (metadata.authorizationServers.isEmpty()) { + return null + } val config = metadata.credentialConfigurations[credentialConfigurationId]!! val authorizationMetadata = metadata.authorizationServers[0] - val pkceCodeVerifier = Random.Default.nextBytes(32).toBase64Url() val codeChallenge = Crypto.digest(Algorithm.SHA256, pkceCodeVerifier.toByteArray()).toBase64Url() @@ -733,6 +738,12 @@ class FunkeIssuingAuthorityState( ) } is Openid4VciFormatMdoc -> { + val documentType = documentTypeRepository.getDocumentTypeForMdoc(config.format.docType) + val staticData = if (documentType != null) { + fillInSampleData(documentType).build() + } else { + NameSpacedData.Builder().build() + } DocumentConfiguration( base.displayName, base.typeDisplayName, @@ -740,9 +751,7 @@ class FunkeIssuingAuthorityState( base.requireUserAuthenticationToViewDocument, MdocDocumentConfiguration( config.format.docType, - staticData = fillInSampleData( - documentTypeRepository.getDocumentTypeForMdoc(config.format.docType)!! - ).build() + staticData = staticData ), null ) @@ -773,7 +782,6 @@ class FunkeIssuingAuthorityState( document: FunkeIssuerDocument ) { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - val authorizationMetadata = metadata.authorizationServers[0] var access = document.access!! val nowPlusSlack = Clock.System.now() + 30.seconds if (access.cNonce != null && nowPlusSlack < access.accessTokenExpiration) { @@ -786,7 +794,7 @@ class FunkeIssuingAuthorityState( env = env, clientId = clientId, issuanceClientId = issuanceClientId, - tokenUrl = authorizationMetadata.tokenEndpoint, + tokenUrl = metadata.tokenEndpoint, refreshToken = refreshToken, accessToken = access.accessToken ) 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 8c53c2da8..b80b9ff66 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 @@ -15,6 +15,7 @@ import com.android.identity.issuance.evidence.EvidenceRequestGermanEid import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp +import com.android.identity.issuance.evidence.EvidenceRequestPreauthorizedCode import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceRequestSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceRequestWeb @@ -23,6 +24,7 @@ import com.android.identity.issuance.evidence.EvidenceResponseGermanEid import com.android.identity.issuance.evidence.EvidenceResponseMessage import com.android.identity.issuance.evidence.EvidenceResponseNotificationPermission import com.android.identity.issuance.evidence.EvidenceResponseOpenid4Vp +import com.android.identity.issuance.evidence.EvidenceResponsePreauthorizedCode import com.android.identity.issuance.evidence.EvidenceResponseQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceResponseSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceResponseWeb @@ -42,18 +44,20 @@ import kotlinx.serialization.json.jsonPrimitive import java.net.URI import java.net.URLEncoder - @FlowState( flowInterface = ProofingFlow::class ) @CborSerializable class FunkeProofingState( val credentialIssuerUri: String, + val credentialConfigurationId: String, val clientId: String, val issuanceClientId: String, val documentId: String, - val proofingInfo: ProofingInfo, + val proofingInfo: ProofingInfo?, val applicationCapabilities: WalletApplicationCapabilities, + val tokenUri: String, + val useGermanEId: Boolean = false, var access: FunkeAccess? = null, var secureAreaIdentifier: String? = null, var secureAreaSetupDone: Boolean = false, @@ -69,50 +73,66 @@ class FunkeProofingState( suspend fun getEvidenceRequests(env: FlowEnvironment): List { return if (access == null) { if (!tosAcknowleged) { - val message = env.getInterface(Resources::class)!! - .getStringResource("funke/tos.html")!! - listOf(EvidenceRequestMessage( - message = message, - assets = emptyMap(), - acceptButtonText = "Continue", - rejectButtonText = "Cancel" - )) + val message = if (useGermanEId) { + env.getInterface(Resources::class)!! + .getStringResource("funke/tos.html")!! + } else { + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val issuingAuthorityName = metadata.display[0].text + val documentName = metadata.credentialConfigurations[credentialConfigurationId]!!.display[0].text + env.getInterface(Resources::class)!! + .getStringResource("generic/tos.html")!! + .replace("\$ISSUER_NAME", issuingAuthorityName) + .replace("\$ID_NAME", documentName) + } + listOf( + EvidenceRequestMessage( + message = message, + assets = emptyMap(), + acceptButtonText = "Continue", + rejectButtonText = "Cancel" + ) + ) } else if (!notificationPermissonRequested) { - listOf(EvidenceRequestNotificationPermission( - permissionNotGrantedMessage = """ - ## Receive notifications? - - If there are updates to your document the issuer will send an updated document - to your device. If you are interested, we can send a notification to make you aware - of when this happens. This requires granting a permission. - - If you previously denied this permission, attempting to grant it again might not do - anything and you may need to go into Settings and manually enable - notifications for this application. - """.trimIndent(), - grantPermissionButtonText = "Grant Permission", - continueWithoutPermissionButtonText = "No Thanks", - assets = mapOf() - )) + listOf( + EvidenceRequestNotificationPermission( + permissionNotGrantedMessage = """ + ## Receive notifications? + + If there are updates to your document the issuer will send an updated document + to your device. If you are interested, we can send a notification to make you aware + of when this happens. This requires granting a permission. + + If you previously denied this permission, attempting to grant it again might not do + anything and you may need to go into Settings and manually enable + notifications for this application. + """.trimIndent(), + grantPermissionButtonText = "Grant Permission", + continueWithoutPermissionButtonText = "No Thanks", + assets = mapOf() + )) } else { + val list = mutableListOf(EvidenceRequestPreauthorizedCode()) val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - val authorizationMetadata = metadata.authorizationServers[0] - val authorizeUrl = "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { - add("client_id", issuanceClientId) - add("request_uri", proofingInfo.requestUri!!) - } - if (authorizationMetadata.useGermanEId) { - listOf(EvidenceRequestGermanEid(authorizeUrl, listOf())) - } else if (openid4VpRequest != null) { - val uri = URI(authorizationMetadata.authorizationChallengeEndpoint!!) - val origin = uri.scheme + ":" + uri.authority - listOf( - EvidenceRequestOpenid4Vp(origin, openid4VpRequest!!), - EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl) - ) - } else { - listOf(EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl)) + if (proofingInfo != null && metadata.authorizationServers.isNotEmpty()) { + val authorizationMetadata = metadata.authorizationServers[0] + val authorizeUrl = + "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { + add("client_id", issuanceClientId) + add("request_uri", proofingInfo.requestUri!!) + } + if (authorizationMetadata.useGermanEId) { + list.add(EvidenceRequestGermanEid(authorizeUrl, listOf())) + } else { + if (openid4VpRequest != null) { + val uri = URI(authorizationMetadata.authorizationChallengeEndpoint!!) + val origin = uri.scheme + ":" + uri.authority + list.add(EvidenceRequestOpenid4Vp(origin, openid4VpRequest!!)) + } + list.add(EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl)) + } } + return list } } else if (secureAreaIdentifier == null) { listOf( @@ -190,6 +210,19 @@ class FunkeProofingState( is EvidenceResponseOpenid4Vp -> { processOpenid4VpResponse(env, evidenceResponse.response) } + is EvidenceResponsePreauthorizedCode -> { + this.access = FunkeUtil.obtainToken( + env = env, + tokenUrl = tokenUri, + clientId = clientId, + issuanceClientId = issuanceClientId, + preauthorizedCode = evidenceResponse.code, + txCode = evidenceResponse.txCode, + codeVerifier = proofingInfo?.pkceCodeVerifier, + dpopNonce = null + ) + Logger.i(TAG, "Token request: success") + } else -> throw IllegalArgumentException("Unexpected evidence type") } } @@ -235,7 +268,7 @@ class FunkeProofingState( clientId = clientId, issuanceClientId = issuanceClientId, authorizationCode = authCode, - codeVerifier = proofingInfo.pkceCodeVerifier, + codeVerifier = proofingInfo?.pkceCodeVerifier, dpopNonce = dpopNonce ) Logger.i(TAG, "Token request: success") @@ -263,7 +296,7 @@ class FunkeProofingState( val dpop = FunkeUtil.generateDPoP(env, clientId, authorizationMetadata.authorizationChallengeEndpoint!!, null, null) val challengeRequest = FormUrlEncoder { - add("auth_session", proofingInfo.authSession!!) + add("auth_session", proofingInfo!!.authSession!!) add("presentation_during_issuance_session", presentationCode) }.toString() val challengeResponse = httpClient.post( 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 9a7849f68..3c3bf975a 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 @@ -90,18 +90,20 @@ internal object FunkeUtil { refreshToken: String? = null, accessToken: String? = null, authorizationCode: String? = null, + preauthorizedCode: String? = null, + txCode: String? = null, // pin or other transaction code codeVerifier: String? = null, dpopNonce: String? = null ): FunkeAccess { - if (refreshToken == null && authorizationCode == null) { - throw IllegalArgumentException("Neither authorization code, nor refresh token provided") + if (refreshToken == null && authorizationCode == null && preauthorizedCode == null) { + throw IllegalArgumentException("No authorizations provided") } val httpClient = env.getInterface(HttpClient::class)!! var currentDpopNonce = dpopNonce // When dpop nonce is null, this loop will run twice, first request will return with error, // but will provide fresh, dpop nonce and the second request will get fresh access data. while (true) { - val dpop = FunkeUtil.generateDPoP(env, clientId, tokenUrl, currentDpopNonce, null) + val dpop = generateDPoP(env, clientId, tokenUrl, currentDpopNonce, null) val tokenRequest = FormUrlEncoder { if (refreshToken != null) { add("grant_type", "refresh_token") @@ -110,6 +112,12 @@ internal object FunkeUtil { if (authorizationCode != null) { add("grant_type", "authorization_code") add("code", authorizationCode) + } else if (preauthorizedCode != null) { + add("grant_type", "urn:ietf:params:oauth:grant-type:pre-authorized_code") + add("pre-authorized_code", preauthorizedCode) + if (txCode != null) { + add("tx_code", txCode) + } } if (codeVerifier != null) { add("code_verifier", codeVerifier) diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt index 2d6b8e98b..e82421792 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt @@ -2,6 +2,7 @@ package com.android.identity.issuance.funke import com.android.identity.flow.server.FlowEnvironment import com.android.identity.issuance.common.cache +import com.android.identity.util.Logger import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.readBytes @@ -22,9 +23,12 @@ internal data class Openid4VciIssuerMetadata( val credentialEndpoint: String, val display: List, val credentialConfigurations: Map, - val authorizationServers: List + val authorizationServers: List, + val tokenEndpoint: String // fallback token endpoint, not standard ) { companion object { + const val TAG = "Openid4VciIssuerMetadata" + suspend fun get(env: FlowEnvironment, issuerUrl: String): Openid4VciIssuerMetadata { return env.cache(Openid4VciIssuerMetadata::class, issuerUrl) { _, _ -> val httpClient = env.getInterface(HttpClient::class)!! @@ -48,7 +52,10 @@ internal data class Openid4VciIssuerMetadata( "$authorizationServerUrl/.well-known/oauth-authorization-server" val authorizationMetadataRequest = httpClient.get(authorizationMetadataUrl) {} if (authorizationMetadataRequest.status != HttpStatusCode.OK) { - throw IllegalStateException("Invalid authorization server, no $authorizationMetadataUrl") + if (authorizationServerUrl != issuerUrl) { + Logger.e(TAG, "Invalid authorization server '$authorizationServerUrl'") + } + continue } val authorizationMetadataText = String(authorizationMetadataRequest.readBytes()) val authorizationMetadata = @@ -58,8 +65,11 @@ internal data class Openid4VciIssuerMetadata( authorizationMetadataList.add(authorizationMetadata) } } - if (authorizationMetadataList.isEmpty()) { - throw IllegalStateException("No compatible authorization server found in $issuerMetadataUrl") + val tokenEndpoint = if (authorizationMetadataList.isEmpty()) { + credentialMetadata["token_endpoint"]?.jsonPrimitive?.content + ?: "$issuerUrl/token" + } else { + authorizationMetadataList[0].tokenEndpoint } Openid4VciIssuerMetadata( credentialIssuer = credentialMetadata["credential_issuer"]?.jsonPrimitive?.content ?: issuerUrl, @@ -71,7 +81,7 @@ internal data class Openid4VciIssuerMetadata( val obj = it.value.jsonObject Openid4VciCredentialConfiguration( id = it.key, - scope = obj["scope"]!!.jsonPrimitive.content, + scope = obj["scope"]?.jsonPrimitive?.content, cryptographicBindingMethod = preferred( obj["cryptographic_binding_methods_supported"]!!.jsonArray, SUPPORTED_BINDING_METHODS @@ -84,7 +94,8 @@ internal data class Openid4VciIssuerMetadata( format = extractFormat(obj), display = extractDisplay(obj["display"]) ) - } + }, + tokenEndpoint = tokenEndpoint ) } } @@ -210,7 +221,7 @@ internal data class Openid4VciCredentialConfiguration( val format: Openid4VciFormat?, val display: List ) { - val isSupported: Boolean get() = scope != null && cryptographicBindingMethod != null && + val isSupported: Boolean get() = cryptographicBindingMethod != null && credentialSigningAlgorithm != null && proofType != null && format != null } diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/mso/MobileSecurityObjectParser.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/mso/MobileSecurityObjectParser.kt index c35d26584..9268c840a 100644 --- a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/mso/MobileSecurityObjectParser.kt +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/mso/MobileSecurityObjectParser.kt @@ -18,6 +18,7 @@ package com.android.identity.mdoc.mso 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.crypto.EcPublicKey import kotlinx.datetime.Instant @@ -201,19 +202,22 @@ class MobileSecurityObjectParser( validUntil = Instant.fromEpochMilliseconds( validityInfo["validUntil"].asDateTimeString.toEpochMilliseconds()) - if (validityInfo.getOrNull("expectedUpdate") != null) { + if (validityInfo.getOrNull("expectedUpdate") is Tagged) { expectedUpdate = Instant.fromEpochMilliseconds( validityInfo["expectedUpdate"].asDateTimeString.toEpochMilliseconds()) } else { expectedUpdate = null } + /* require(validFrom >= signed) { "The validFrom timestamp should be equal or later than the signed timestamp" } require(validUntil > validFrom) { "The validUntil timestamp should be later than the validFrom timestamp" } + + */ } fun parse(encodedMobileSecurityObject: ByteArray) { diff --git a/server/src/main/resources/resources/generic/card_art.png b/server/src/main/resources/resources/generic/card_art.png new file mode 100644 index 000000000..64b670a66 Binary files /dev/null and b/server/src/main/resources/resources/generic/card_art.png differ diff --git a/server/src/main/resources/resources/generic/tos.html b/server/src/main/resources/resources/generic/tos.html new file mode 100644 index 000000000..1eb503c87 --- /dev/null +++ b/server/src/main/resources/resources/generic/tos.html @@ -0,0 +1,4 @@ +

Provisioning $ID_NAME credential

+

In the following screens, information will be collected for provisioning of $ID_NAME + from $ISSUER_NAME.

+

The created $ID_NAME will be bound to this device.

diff --git a/server/src/main/webapp/openid4vci/index.html b/server/src/main/webapp/openid4vci/index.html index baf56b5a2..ebb1fc2f3 100644 --- a/server/src/main/webapp/openid4vci/index.html +++ b/server/src/main/webapp/openid4vci/index.html @@ -6,6 +6,8 @@ OpenId4VCI Sample server +

URL:

+

Link

\ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt b/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt index 94366ebf1..0f7c2691b 100644 --- a/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt +++ b/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt @@ -169,6 +169,10 @@ internal class LocalDevelopmentEnvironment( R.drawable.funke_logo, Bitmap.CompressFormat.PNG ) + "generic/card_art.png" -> bitmapData( + R.drawable.card_art_generic, + Bitmap.CompressFormat.PNG + ) "img_erika_portrait.jpf" -> ByteString(getRawResourceAsBytes(R.raw.img_erika_portrait)) "img_erika_signature.jpf" -> @@ -199,6 +203,8 @@ internal class LocalDevelopmentEnvironment( context.resources.getString(R.string.utopia_local_issuing_authority_photoid_tos) "funke/tos.html" -> context.resources.getString(R.string.funke_issuing_authority_tos) + "generic/tos.html" -> + context.resources.getString(R.string.generic_issuing_authority_tos) else -> null } } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt index 8ba84d878..9f272a8a9 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/MainActivity.kt @@ -26,17 +26,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.MutableLiveData import androidx.navigation.compose.rememberNavController import com.android.identity_credential.wallet.credentialoffer.extractCredentialIssuerData -import com.android.identity_credential.wallet.credentialoffer.initiateCredentialOfferIssuance +import com.android.identity_credential.wallet.navigation.WalletDestination import com.android.identity_credential.wallet.navigation.WalletNavigation import com.android.identity_credential.wallet.navigation.navigateTo import com.android.identity_credential.wallet.ui.theme.IdentityCredentialTheme import com.android.identity_credential.wallet.util.getUrlQueryFromCustomSchemeUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : FragmentActivity() { companion object { @@ -46,6 +49,7 @@ class MainActivity : FragmentActivity() { private lateinit var application: WalletApplication private val qrEngagementViewModel: QrEngagementViewModel by viewModels() private val provisioningViewModel: ProvisioningViewModel by viewModels() + private val routeRequest = MutableLiveData(null) private val permissionTracker: PermissionTracker = if (Build.VERSION.SDK_INT >= 31) { PermissionTracker(this, mapOf( @@ -89,31 +93,14 @@ class MainActivity : FragmentActivity() { color = MaterialTheme.colorScheme.background ) { val navController = rememberNavController() - // observe whether a new intent was received with credential offer url - val credentialOfferIntentPayload by provisioningViewModel.newCredentialOfferIntentReceived.collectAsState() - // if not null, execute once - LaunchedEffect(credentialOfferIntentPayload) { - if (credentialOfferIntentPayload != null) { - val credentialIssuerUri = credentialOfferIntentPayload!!.first - val credentialIssuerConfigurationId = - credentialOfferIntentPayload!!.second - initiateCredentialOfferIssuance( - walletServerProvider = application.walletServerProvider, - provisioningViewModel = provisioningViewModel, - settingsModel = application.settingsModel, - documentStore = application.documentStore, - onNavigate = { routeWithArgs -> - navigateTo(navController, routeWithArgs) - }, - credentialIssuerUri = credentialIssuerUri, - credentialIssuerConfigurationId = credentialIssuerConfigurationId, - ) - // reset the state (consume the Url) - provisioningViewModel.onNewCredentialOfferIntent(null, null) + val route = routeRequest.observeAsState() + LaunchedEffect(route.value) { + if (route.value != null) { + navigateTo(navController, routeRequest.value!!) + routeRequest.value = null } } - WalletNavigation( navController, application = application, @@ -152,12 +139,19 @@ class MainActivity : FragmentActivity() { if (intent.action == Intent.ACTION_VIEW) { // perform recomposition only if deep link url starts with oid4vci credential offer scheme if (intent.dataString?.startsWith(WalletApplication.OID4VCI_CREDENTIAL_OFFER_URL_SCHEME) == true) { - val decodedQuery = getUrlQueryFromCustomSchemeUrl(intent.dataString!!) - extractCredentialIssuerData(decodedQuery).let { (credentialIssuerUri, credentialConfigurationId) -> - provisioningViewModel.onNewCredentialOfferIntent( - credentialIssuerUri, - credentialConfigurationId - ) + CoroutineScope(Dispatchers.Main).launch { + val query = getUrlQueryFromCustomSchemeUrl(intent.dataString!!) + val offer = extractCredentialIssuerData(query) + if (offer != null) { + provisioningViewModel.start( + walletServerProvider = application.walletServerProvider, + documentStore = application.documentStore, + settingsModel = application.settingsModel, + issuerIdentifier = null, + openid4VciCredentialOffer = offer + ) + routeRequest.value = WalletDestination.ProvisionDocument.route + } } } } 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 2c49e5eb6..6c6ed0444 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 @@ -19,15 +19,14 @@ import com.android.identity.issuance.RegistrationResponse import com.android.identity.issuance.evidence.EvidenceRequest import com.android.identity.issuance.evidence.EvidenceRequestIcaoNfcTunnel import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp +import com.android.identity.issuance.evidence.EvidenceRequestPreauthorizedCode import com.android.identity.issuance.evidence.EvidenceResponse import com.android.identity.issuance.evidence.EvidenceResponseIcaoNfcTunnel +import com.android.identity.issuance.evidence.EvidenceResponsePreauthorizedCode import com.android.identity.issuance.remote.WalletServerProvider import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock @@ -55,30 +54,7 @@ class ProvisioningViewModel : ViewModel() { private lateinit var issuer: IssuingAuthority - /* - Backing field + StateFlow that is observed on MainActivity's composable whose value is updated - from onNewIntent() when a OID4VCI Credential Offer deep link is intercepted in MainActivity. - */ - private val _newCredentialOfferIntentReceived = MutableStateFlow?>(null) - val newCredentialOfferIntentReceived: StateFlow?> = - _newCredentialOfferIntentReceived.asStateFlow() - - /** - * Trigger a recomposition of MainActivity from onNewIntent() after an OID4VCI deep link is - * intercepted. If both [credentialIssuerUri] and [credentialConfigurationId] are [null] then - * set observable value to [null] rather than Pair(null,null). - */ - fun onNewCredentialOfferIntent( - credentialIssuerUri: String?, - credentialConfigurationId: String? - ) { - if (credentialIssuerUri != null && credentialConfigurationId != null) { - _newCredentialOfferIntentReceived.value = - Pair(credentialIssuerUri, credentialConfigurationId) - } else { - _newCredentialOfferIntentReceived.value = null - } - } + var openid4VciCredentialOffer: Openid4VciCredentialOffer? = null fun reset() { state.value = State.IDLE @@ -110,18 +86,17 @@ class ProvisioningViewModel : ViewModel() { settingsModel: SettingsModel, // PID-based mdoc or sd-jwt issuerIdentifier: String?, - // OID4VCI credential offer - credentialIssuerUri: String? = null, - credentialIssuerConfigurationId: String? = null, + openid4VciCredentialOffer: Openid4VciCredentialOffer? = null, ) { this.documentStore = documentStore this.settingsModel = settingsModel + this.openid4VciCredentialOffer = openid4VciCredentialOffer viewModelScope.launch(Dispatchers.IO) { try { - if (credentialIssuerUri != null) { + if (openid4VciCredentialOffer != null) { issuer = walletServerProvider.createOpenid4VciIssuingAuthorityByUri( - credentialIssuerUri, - credentialIssuerConfigurationId!! + openid4VciCredentialOffer.issuerUri, + openid4VciCredentialOffer.configurationId ) } else { issuer = walletServerProvider.getIssuingAuthority(issuerIdentifier!!) @@ -156,6 +131,7 @@ class ProvisioningViewModel : ViewModel() { evidenceRequests = proofingFlow!!.getEvidenceRequests() currentEvidenceRequestIndex = 0 Logger.d(TAG, "ers0 ${evidenceRequests!!.size}") + if (evidenceRequests!!.size == 0) { state.value = State.PROOFING_COMPLETE document!!.let { @@ -204,14 +180,29 @@ class ProvisioningViewModel : ViewModel() { evidenceRequests = proofingFlow!!.getEvidenceRequests() currentEvidenceRequestIndex = 0 + Logger.d(TAG, "ers1 ${evidenceRequests!!.size}") - if (evidenceRequests!!.size == 0) { + if (evidenceRequests!!.isEmpty()) { state.value = State.PROOFING_COMPLETE document!!.refreshState(walletServerProvider) documentStore!!.addDocument(document!!) proofingFlow!!.complete() document!!.refreshState(walletServerProvider) } else { + if (evidenceRequests!![0] is EvidenceRequestPreauthorizedCode) { + if (openid4VciCredentialOffer?.preauthorizedCode != null) { + Logger.d(TAG, "handling pre-authorized code") + proofingFlow!!.sendEvidence( + EvidenceResponsePreauthorizedCode( + code = openid4VciCredentialOffer!!.preauthorizedCode!!, + txCode = null // We don't support it yet + ) + ) + evidenceRequests = proofingFlow!!.getEvidenceRequests() + } else { + currentEvidenceRequestIndex++ + } + } selectViableEvidenceRequest() state.value = State.EVIDENCE_REQUESTS_READY } @@ -364,4 +355,10 @@ class ProvisioningViewModel : ViewModel() { CredentialFormat.SD_JWT_VC -> documentConfiguration.sdJwtVcDocumentConfiguration != null } } + + data class Openid4VciCredentialOffer( + val issuerUri: String, + val configurationId: String, + val preauthorizedCode: String? + ) } \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt index 10097c2b1..da33c9ae9 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt @@ -36,6 +36,7 @@ import com.android.identity.documenttype.DocumentTypeRepository import com.android.identity.documenttype.knowntypes.DrivingLicense import com.android.identity.documenttype.knowntypes.EUPersonalID import com.android.identity.crypto.X509Cert +import com.android.identity.documenttype.knowntypes.EUCertificateOfResidence import com.android.identity.documenttype.knowntypes.PhotoID import com.android.identity.issuance.DocumentExtensions.documentConfiguration import com.android.identity.issuance.WalletApplicationCapabilities @@ -131,6 +132,7 @@ class WalletApplication : Application() { documentTypeRepository.addDocumentType(DrivingLicense.getDocumentType()) documentTypeRepository.addDocumentType(EUPersonalID.getDocumentType()) documentTypeRepository.addDocumentType(PhotoID.getDocumentType()) + documentTypeRepository.addDocumentType(EUCertificateOfResidence.getDocumentType()) // init storage val storageFile = Path(applicationContext.noBackupFilesDir.path, "identity.bin") diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt b/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt index 3a02bdb0d..bf9c01341 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt @@ -1,50 +1,68 @@ package com.android.identity_credential.wallet.credentialoffer -import com.android.identity.document.DocumentStore -import com.android.identity.issuance.remote.WalletServerProvider +import com.android.identity.util.Logger import com.android.identity_credential.wallet.ProvisioningViewModel -import com.android.identity_credential.wallet.SettingsModel -import com.android.identity_credential.wallet.navigation.WalletDestination -import org.json.JSONObject - -/** - * Initiate the process of setting a dynamic Credential Issuer defined in the credential offer - * payload that produces [credentialIssuerUri] Url of Issuer for obtaining credentials and - * [credentialIssuerConfigurationId] for the Credential Id (such as "pid-mso-mdoc" or "pid-sd-jwt"). - */ -fun initiateCredentialOfferIssuance( - walletServerProvider: WalletServerProvider, - provisioningViewModel: ProvisioningViewModel, - settingsModel: SettingsModel, - documentStore: DocumentStore, - onNavigate: (String) -> Unit, - credentialIssuerUri: String, - credentialIssuerConfigurationId: String, -) { - provisioningViewModel.start( - walletServerProvider = walletServerProvider, - documentStore = documentStore, - settingsModel = settingsModel, - issuerIdentifier = null, - credentialIssuerUri = credentialIssuerUri, - credentialIssuerConfigurationId = credentialIssuerConfigurationId - ) - onNavigate(WalletDestination.ProvisionDocument.route) -} +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.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.URLDecoder /** * Parse the Url Query component of an OID4VCI credential offer Url (from a deep link or Qr scan) * and return a [Pair] containing the Credential Issuer Uri and Credential (Config) Id that are * used for initiating the OID4VCI Credential Offer Issuance flow above [initiateCredentialOfferIssuance]. */ -fun extractCredentialIssuerData(urlQueryComponent: String): Pair { - val jsonPayload = JSONObject("{$urlQueryComponent}") - val credentialOffer = jsonPayload.getJSONObject("credential_offer") - // extract Credential Issuer Uri and Credential (Config) Id - val credentialIssuerUri = credentialOffer.getString("credential_issuer") - val credentialConfigurationIds = - credentialOffer.getJSONArray("credential_configuration_ids") - // TODO should there be a future implementation addressing all specified ids in credential_configuration_ids ? - val credentialConfigurationId = credentialConfigurationIds.getString(0) - return Pair(credentialIssuerUri, credentialConfigurationId) +suspend fun extractCredentialIssuerData( + urlQueryComponent: String +): ProvisioningViewModel.Openid4VciCredentialOffer? { + try { + val params = urlQueryComponent.split('&').map { + val index = it.indexOf('=') + val name = if (index < 0) "" else it.substring(0, index) + Pair( + URLDecoder.decode(name, "UTF-8"), + URLDecoder.decode(it.substring(index + 1), "UTF-8") + ) + }.toMap() + var credentialOfferString = params["credential_offer"] + if (credentialOfferString == null) { + val url = params["credential_offer_uri"] + if (url == null) { + Logger.e("CredentialOffer", "Could not parse offer") + return null + } + val response = HttpClient().get(url) {} + if (response.status != HttpStatusCode.OK) { + Logger.e("CredentialOffer", "Error retrieving '$url'") + return null + } + credentialOfferString = String(response.readBytes()) + } + val json = Json.parseToJsonElement(credentialOfferString).jsonObject + // extract Credential Issuer Uri and Credential (Config) Id + val credentialIssuerUri = json["credential_issuer"]!!.jsonPrimitive.content + val credentialConfigurationIds = json["credential_configuration_ids"]!!.jsonArray + // Right now only use the first configuration id + val credentialConfigurationId = credentialConfigurationIds[0].jsonPrimitive.content + var preauthorizedCode: String? = null + if (json.containsKey("grants")) { + val codeObject = json["grants"]!!.jsonObject["urn:ietf:params:oauth:grant-type:pre-authorized_code"] + if (codeObject != null) { + preauthorizedCode = codeObject.jsonObject["pre-authorized_code"]?.jsonPrimitive?.content + } + } + return ProvisioningViewModel.Openid4VciCredentialOffer( + credentialIssuerUri, + credentialConfigurationId, + preauthorizedCode + ) + } catch (err: Exception) { + Logger.e("CredentialOffer", "Parsing error", err) + return null + } } \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt index a3e81266a..68a77b9a4 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt @@ -39,11 +39,13 @@ import com.android.identity_credential.wallet.R import com.android.identity_credential.wallet.SettingsModel import com.android.identity_credential.wallet.WalletApplication import com.android.identity_credential.wallet.credentialoffer.extractCredentialIssuerData -import com.android.identity_credential.wallet.credentialoffer.initiateCredentialOfferIssuance import com.android.identity_credential.wallet.navigation.WalletDestination import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton import com.android.identity_credential.wallet.ui.qrscanner.ScanQrDialog import com.android.identity_credential.wallet.util.getUrlQueryFromCustomSchemeUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch private const val TAG = "AddToWalletScreen" @@ -135,24 +137,21 @@ fun AddToWalletScreen( // filter only for OID4VCI Url schemes. if (qrCodeTextUrl.startsWith(WalletApplication.OID4VCI_CREDENTIAL_OFFER_URL_SCHEME)) { // scanned text is expected to be an encoded Url - val decodedQuery = getUrlQueryFromCustomSchemeUrl(qrCodeTextUrl) - // extract Credential Issuer Uri (issuing authority path) and credential id (pid-mso-mdoc, pid-sd-jwt) - extractCredentialIssuerData(decodedQuery).let { (credentialIssuerUri, credentialConfigurationId) -> - // initiate getting issuing authority dynamically from specified Issuer Uri and Credential Id - initiateCredentialOfferIssuance( - walletServerProvider = walletServerProvider, - provisioningViewModel = provisioningViewModel, - settingsModel = settingsModel, - documentStore = documentStore, - onNavigate = { route -> - // hoist the actual navigation on the parent composable - // because calling onNavigate.invoke(route) does not - // trigger a recomposition - navigateToOnComposable.value = route - }, - credentialIssuerUri = credentialIssuerUri, - credentialIssuerConfigurationId = credentialConfigurationId, - ) + CoroutineScope(Dispatchers.Main).launch { + val query = getUrlQueryFromCustomSchemeUrl(qrCodeTextUrl) + // extract Credential Issuer Uri (issuing authority path) and credential id (pid-mso-mdoc, pid-sd-jwt) + val offer = extractCredentialIssuerData(query) + if (offer != null) { + // initiate getting issuing authority dynamically from specified Issuer Uri and Credential Id + provisioningViewModel.start( + walletServerProvider = walletServerProvider, + settingsModel = settingsModel, + documentStore = documentStore, + issuerIdentifier = null, + openid4VciCredentialOffer = offer, + ) + onNavigate(WalletDestination.ProvisionDocument.route) + } } } }, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt index c7c976627..86e5732c8 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt @@ -15,9 +15,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -40,6 +38,7 @@ import com.android.identity.issuance.evidence.EvidenceRequestIcaoPassiveAuthenti import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp +import com.android.identity.issuance.evidence.EvidenceRequestPreauthorizedCode import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceRequestSelfieVideo @@ -56,6 +55,7 @@ import com.android.identity.mdoc.response.DeviceResponseGenerator import com.android.identity.securearea.SecureAreaRepository import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Constants +import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url import com.android.identity.util.toBase64Url import com.android.identity_credential.wallet.PermissionTracker @@ -82,6 +82,7 @@ import org.json.JSONObject import java.util.StringTokenizer import kotlin.random.Random +private const val TAG = "ProvisionCredentialScreen" @Composable fun ProvisionDocumentScreen( @@ -272,6 +273,22 @@ fun ProvisionDocumentScreen( application = application ) } + + is EvidenceRequestPreauthorizedCode -> { + // should have been processed by the model internally + Logger.e(TAG, "Unexpected evidence request type: EvidenceRequestPreauthorizedCode") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + text = stringResource(R.string.provisioning_request_unexpected) + ) + } + } } } diff --git a/wallet/src/main/res/drawable/card_art_generic.png b/wallet/src/main/res/drawable/card_art_generic.png new file mode 100644 index 000000000..64b670a66 Binary files /dev/null and b/wallet/src/main/res/drawable/card_art_generic.png differ diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index 8f5a60fdc..183e64b8e 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -281,6 +281,7 @@ Submitting evidence… Error provisioning: %1$s Unexpected state: %1$s + Unexpected request type Reused Credential @@ -499,6 +500,14 @@ this device.

]]> + + Provisioning $ID_NAME credential +

In the following screens, information will be collected for provisioning of $ID_NAME + from $ISSUER_NAME.

+

The created $ID_NAME will be bound to this device.

+ ]]>
+ Scan Credential Offer QR code for OpenID for Verifiable Credential Issuance (draft #14)