From 39a22ad7f6e6006688c43912a338148be4185075 Mon Sep 17 00:00:00 2001 From: Peter Sorotokin Date: Fri, 3 Jan 2025 21:37:18 -0800 Subject: [PATCH] Implemented DeviceAttestation and DeviceAssertion validation. Signed-off-by: Peter Sorotokin --- ...roidKeystoreSecureAreaDocumentStoreTest.kt | 2 +- identity-csa/build.gradle.kts | 1 + .../securearea/cloud/CloudSecureAreaServer.kt | 16 +- .../identity/issuance/WalletServerSettings.kt | 14 +- .../issuance/authenticationUtilities.kt | 186 +------------- .../wallet/ApplicationSupportState.kt | 5 +- .../issuance/wallet/AuthenticationState.kt | 16 +- identity/build.gradle.kts | 10 + .../com/android/identity/android/TestUtil.kt | 9 + .../AndroidKeystoreSecureAreaTest.kt | 10 +- .../AndroidKeystoreCreateKeySettings.kt | 3 +- .../securearea/AndroidKeystoreKeyInfo.kt | 0 .../AndroidKeystoreKeyUnlockData.kt | 0 .../securearea/AndroidKeystoreSecureArea.kt | 3 +- .../securearea/ScreenLockRequiredException.kt | 0 .../securearea/UserAuthenticationType.kt | 0 .../identity/device/DeviceCheck.android.kt | 10 +- .../kotlin/com/android/identity/asn1/ASN1.kt | 6 + .../device/DeviceAssertionException.kt | 9 + .../identity/device/DeviceAttestation.kt | 18 +- .../device/DeviceAttestationAndroid.kt | 35 ++- .../device/DeviceAttestationException.kt | 9 + .../identity/device/DeviceAttestationIos.kt | 235 +++++++++++++++++- .../identity/device/DeviceAttestationJvm.kt | 10 +- .../device/DeviceAttestationResult.kt | 4 +- .../device/DeviceAttestationValidationData.kt | 44 ++++ .../android/identity/device/DeviceCheck.kt | 9 +- .../util/AndroidAttestationExtensionParser.kt | 129 +++++----- .../com/android/identity/util/binaryUtils.kt | 13 + .../identity/util/validateKeyAttestation.kt | 144 +++++++++++ .../device/DeviceAttestationAndroidTest.kt | 184 ++++++++++++++ .../device/DeviceAttestationIosTest.kt | 138 ++++++++++ .../util/ValidateKeyAttestationTest.kt | 58 +++++ .../wallet/server/CloudSecureAreaServlet.kt | 2 +- 34 files changed, 1044 insertions(+), 288 deletions(-) create mode 100644 identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/TestUtil.kt rename {identity-android/src/androidTest/java => identity/src/androidInstrumentedTest/kotlin}/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt (98%) rename {identity-android/src/main/java => identity/src/androidMain/kotlin}/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt (98%) rename {identity-android/src/main/java => identity/src/androidMain/kotlin}/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt (100%) rename {identity-android/src/main/java => identity/src/androidMain/kotlin}/com/android/identity/android/securearea/AndroidKeystoreKeyUnlockData.kt (100%) rename {identity-android/src/main/java => identity/src/androidMain/kotlin}/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt (99%) rename {identity-android/src/main/java => identity/src/androidMain/kotlin}/com/android/identity/android/securearea/ScreenLockRequiredException.kt (100%) rename {identity-android/src/main/java => identity/src/androidMain/kotlin}/com/android/identity/android/securearea/UserAuthenticationType.kt (100%) create mode 100644 identity/src/commonMain/kotlin/com/android/identity/device/DeviceAssertionException.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationException.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationValidationData.kt rename identity/src/{javaSharedMain => commonMain}/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt (64%) create mode 100644 identity/src/commonMain/kotlin/com/android/identity/util/binaryUtils.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/util/validateKeyAttestation.kt create mode 100644 identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationAndroidTest.kt create mode 100644 identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationIosTest.kt create mode 100644 identity/src/commonTest/kotlin/com/android/identity/util/ValidateKeyAttestationTest.kt diff --git a/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt b/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt index bb2697e6c..f30ace211 100644 --- a/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt +++ b/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt @@ -85,7 +85,7 @@ class AndroidKeystoreSecureAreaDocumentStoreTest { Assert.assertFalse(pendingCredential.isCertified) val attestation = pendingCredential.attestation val parser = - AndroidAttestationExtensionParser(attestation.certChain!!.certificates[0].javaX509Certificate) + AndroidAttestationExtensionParser(attestation.certChain!!.certificates[0]) Assert.assertArrayEquals( authKeyChallenge, parser.attestationChallenge diff --git a/identity-csa/build.gradle.kts b/identity-csa/build.gradle.kts index d934d4a4c..35f3e3b6e 100644 --- a/identity-csa/build.gradle.kts +++ b/identity-csa/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(project(":identity")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.io.bytestring) implementation(project(":processor-annotations")) ksp(project(":processor")) diff --git a/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt b/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt index 5f201fc5c..0863072ff 100644 --- a/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt +++ b/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt @@ -155,15 +155,12 @@ class CloudSecureAreaServer( // Finally, check the Attestation Extension... try { - val parser = AndroidAttestationExtensionParser(x509certs[0]) + val parser = AndroidAttestationExtensionParser(attestation.certificates.first()) // Challenge must match... - check( - Arrays.equals( - cloudChallenge, - parser.attestationChallenge - ) - ) { "Challenge didn't match what was expected" } + check(cloudChallenge.contentEquals(parser.attestationChallenge)) { + "Challenge didn't match what was expected" + } if (requireVerifiedBootGreen) { // Verified Boot state must VERIFIED @@ -176,7 +173,7 @@ class CloudSecureAreaServer( check (parser.applicationSignatureDigests.size == requireAppSignatureCertificateDigests.size) { "Number Signing certificates mismatch" } for (n in 0.. - get() = getStringList("androidRequireAppSignatureCertificateDigests") + val androidRequireAppSignatureCertificateDigests: List + get() = getStringList("androidRequireAppSignatureCertificateDigests").map { + ByteString(it.fromBase64Url()) + } val cloudSecureAreaEnabled: Boolean get() = getBool("cloudSecureAreaEnabled", false) diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt b/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt index a0f02feb1..306aa4c1e 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt @@ -1,193 +1,19 @@ package com.android.identity.issuance -import com.android.identity.crypto.Algorithm -import com.android.identity.crypto.Crypto -import com.android.identity.crypto.EcSignature +import com.android.identity.cbor.Cbor import com.android.identity.crypto.X509Cert -import com.android.identity.crypto.X509CertChain -import com.android.identity.crypto.javaX509Certificate -import com.android.identity.crypto.javaX509Certificates import com.android.identity.device.AssertionBindingKeys import com.android.identity.device.DeviceAssertion import com.android.identity.device.DeviceAttestation -import com.android.identity.device.DeviceAttestationAndroid import com.android.identity.device.DeviceAttestationIos -import com.android.identity.device.DeviceAttestationJvm import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.issuance.common.cache -import com.android.identity.securearea.AttestationExtension import com.android.identity.securearea.KeyAttestation -import com.android.identity.util.AndroidAttestationExtensionParser -import com.android.identity.util.Logger -import com.android.identity.util.fromHex -import com.android.identity.util.toHex +import com.android.identity.util.isCloudKeyAttestation +import com.android.identity.util.validateAndroidKeyAttestation +import com.android.identity.util.validateCloudKeyAttestation import kotlinx.io.bytestring.ByteString -import org.bouncycastle.asn1.ASN1InputStream -import org.bouncycastle.asn1.ASN1OctetString - -// TODO: move as much of this as possible into com.android.identity.device (and perhaps -// com.android.identity.crypto) package. - -private const val TAG = "authenticationUtilities" - -fun validateAndroidKeyAttestation( - chain: X509CertChain, - nonce: ByteString?, - requireGmsAttestation: Boolean, - requireVerifiedBootGreen: Boolean, - requireAppSignatureCertificateDigests: List, -) { - check(chain.validate()) { - "Certificate chain did not validate" - } - val x509certs = chain.javaX509Certificates - val rootCertificatePublicKey = x509certs.last().publicKey - - if (requireGmsAttestation) { - // Must match the well-known Google root - check( - GOOGLE_ROOT_ATTESTATION_KEY contentEquals rootCertificatePublicKey.encoded - ) { "Unexpected attestation root" } - } - - // Finally, check the Attestation Extension... - try { - val parser = AndroidAttestationExtensionParser(x509certs[0]) - - // Challenge must match... - check(nonce == null || nonce == ByteString(parser.attestationChallenge)) { - "Challenge didn't match what was expected" - } - - if (requireVerifiedBootGreen) { - // Verified Boot state must VERIFIED - check( - parser.verifiedBootState == - AndroidAttestationExtensionParser.VerifiedBootState.GREEN - ) { "Verified boot state is not GREEN" } - } - - if (requireAppSignatureCertificateDigests.isNotEmpty()) { - check (parser.applicationSignatureDigests.size == requireAppSignatureCertificateDigests.size) - { "Number Signing certificates mismatch" } - for (n in 0.. -) { - check(chain.validate()) { - "Certificate chain did not validate" - } - val certificates = chain.certificates - val leafX509Cert = certificates.first().javaX509Certificate - val extensionDerEncodedString = leafX509Cert.getExtensionValue(AttestationExtension.ATTESTATION_OID) - ?: throw IllegalStateException( - "No attestation extension at OID ${AttestationExtension.ATTESTATION_OID}") - - val attestationExtension = try { - val asn1InputStream = ASN1InputStream(extensionDerEncodedString); - (asn1InputStream.readObject() as ASN1OctetString).octets - } catch (e: Exception) { - throw IllegalStateException("Error decoding attestation extension", e) - } - - val challengeInAttestation = ByteString(AttestationExtension.decode(attestationExtension)) - if (challengeInAttestation != nonce) { - throw IllegalStateException("Challenge in attestation does match expected nonce") - } - - val rootPublicKey = ByteString(certificates.last().javaX509Certificate.publicKey.encoded) - check(trustedRootKeys.contains(rootPublicKey)) { - "Unexpected cloud attestation root" - } -} - -fun validateIosDeviceAttestation(attestation: DeviceAttestationIos) { - // TODO, assume valid for now -} - -fun validateDeviceAttestation( - attestation: DeviceAttestation, - clientId: String, - settings: WalletServerSettings -) { - when (attestation) { - is DeviceAttestationAndroid -> { - validateAndroidKeyAttestation( - attestation.certificateChain, - null, // TODO: enable: ByteString(clientId.toByteArray()), - settings.androidRequireGmsAttestation, - settings.androidRequireVerifiedBootGreen, - settings.androidRequireAppSignatureCertificateDigests - ) - } - is DeviceAttestationIos -> { - validateIosDeviceAttestation(attestation) - } - is DeviceAttestationJvm -> - throw IllegalArgumentException("JVM attestations are not accepted") - } -} - -fun validateDeviceAssertion(attestation: DeviceAttestation, assertion: DeviceAssertion) { - try { - when (attestation) { - is DeviceAttestationAndroid -> { - val signature = - EcSignature.fromCoseEncoded(assertion.platformAssertion.toByteArray()) - if (!Crypto.checkSignature( - publicKey = attestation.certificateChain.certificates.first().ecPublicKey, - message = assertion.assertionData.toByteArray(), - algorithm = Algorithm.ES256, - signature = signature - ) - ) { - throw IllegalArgumentException("DeviceAssertion validation failed") - } - } - - is DeviceAttestationIos -> { - // accept for now - } - - is DeviceAttestationJvm -> - throw IllegalArgumentException("JVM attestations are not accepted") - } - } catch(err: Exception) { - err.printStackTrace() - throw err - } -} suspend fun validateDeviceAssertionBindingKeys( env: FlowEnvironment, @@ -201,7 +27,7 @@ suspend fun validateDeviceAssertionBindingKeys( // No ApplicationSupport is indication that we are running on the server, not // locally in app. Device assertion validation is only meaningful or possible // on the server. - validateDeviceAssertion(deviceAttestation, deviceAssertion) + deviceAttestation.validateAssertion(deviceAssertion) } val assertion = deviceAssertion.assertion as AssertionBindingKeys check(nonce == null || nonce == assertion.nonce) @@ -251,7 +77,7 @@ private suspend fun getCloudSecureAreaTrustedRootKeys( ?: "cloud_secure_area/certificate.pem" val certificate = X509Cert.fromPem(resources.getStringResource(certificateName)!!) CloudSecureAreaTrustedRootKeys( - trustedKeys = setOf(ByteString(certificate.javaX509Certificate.publicKey.encoded)) + trustedKeys = setOf(ByteString(Cbor.encode(certificate.ecPublicKey.toDataItem()))) ) } } 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 5e5775e31..bd32494f8 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 @@ -18,12 +18,11 @@ import com.android.identity.issuance.LandingUrlUnknownException import com.android.identity.issuance.WalletServerSettings import com.android.identity.issuance.common.cache import com.android.identity.issuance.funke.toJson -import com.android.identity.issuance.validateAndroidKeyAttestation -import com.android.identity.issuance.validateDeviceAssertion import com.android.identity.issuance.validateDeviceAssertionBindingKeys import com.android.identity.securearea.KeyAttestation import com.android.identity.util.Logger import com.android.identity.util.toBase64Url +import com.android.identity.util.validateAndroidKeyAttestation import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString import kotlinx.serialization.json.JsonArray @@ -92,7 +91,7 @@ class ApplicationSupportState( val clientRecord = ClientRecord.fromCbor( storage.get("Clients", "", clientId)!!.toByteArray()) - validateDeviceAssertion(clientRecord.deviceAttestation, keyAssertion) + clientRecord.deviceAttestation.validateAssertion(keyAssertion) val assertion = keyAssertion.assertion as AssertionDPoPKey diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt index d02827392..7a9928e22 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt @@ -3,6 +3,7 @@ package com.android.identity.issuance.wallet import com.android.identity.cbor.annotation.CborSerializable import com.android.identity.device.AssertionNonce import com.android.identity.device.DeviceAttestation +import com.android.identity.device.DeviceAttestationValidationData import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.Configuration @@ -14,8 +15,6 @@ import com.android.identity.issuance.ClientChallenge import com.android.identity.issuance.WalletServerCapabilities import com.android.identity.issuance.WalletServerSettings import com.android.identity.issuance.toCbor -import com.android.identity.issuance.validateDeviceAssertion -import com.android.identity.issuance.validateDeviceAttestation import com.android.identity.util.toBase64Url import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString @@ -62,12 +61,21 @@ class AuthenticationState( if (this.deviceAttestation != null) { throw IllegalStateException("Client already registered") } - validateDeviceAttestation(attestation, clientId, settings) + attestation.validate(DeviceAttestationValidationData( + clientId = clientId, + iosReleaseBuild = settings.iosReleaseBuild, + iosAppIdentifier = settings.iosAppIdentifier, + androidGmsAttestation = settings.androidRequireGmsAttestation, + androidVerifiedBootGreen = settings.androidRequireVerifiedBootGreen, + androidAppSignatureCertificateDigests = listOf() + )) val clientData = ByteString(ClientRecord(attestation).toCbor()) this.deviceAttestation = attestation storage.insert("Clients", "", clientData, key = clientId) } - validateDeviceAssertion(this.deviceAttestation!!, auth.assertion) + + this.deviceAttestation!!.validateAssertion(auth.assertion) + if ((auth.assertion.assertion as AssertionNonce).nonce != this.nonce) { throw IllegalArgumentException("nonce mismatch") } diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index dec851d05..685e6d661 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -106,11 +106,21 @@ kotlin { val androidMain by getting { dependsOn(javaSharedMain) dependencies { + implementation(libs.androidx.biometrics) implementation(libs.bouncy.castle.bcprov) implementation(libs.bouncy.castle.bcpkix) implementation(libs.tink) } } + + val androidInstrumentedTest by getting { + dependencies { + implementation(libs.androidx.test.junit) + implementation(libs.androidx.espresso.core) + implementation(libs.compose.junit4) + } + } + } } diff --git a/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/TestUtil.kt b/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/TestUtil.kt new file mode 100644 index 000000000..6ce9d124b --- /dev/null +++ b/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/TestUtil.kt @@ -0,0 +1,9 @@ +package com.android.identity.android + +import android.os.Build + +object TestUtil { + val isRunningOnEmulator: Boolean by lazy { + Build.PRODUCT.startsWith("sdk") + } +} \ No newline at end of file diff --git a/identity-android/src/androidTest/java/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt b/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt similarity index 98% rename from identity-android/src/androidTest/java/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt rename to identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt index 18ee7d021..d27c74c3e 100644 --- a/identity-android/src/androidTest/java/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt +++ b/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt @@ -25,7 +25,6 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import androidx.test.InstrumentationRegistry import com.android.identity.android.TestUtil -import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve @@ -34,6 +33,7 @@ import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.KeyInfo import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose +import com.android.identity.storage.GenericStorageEngine import com.android.identity.util.AndroidAttestationExtensionParser import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.Assert @@ -70,7 +70,7 @@ class AndroidKeystoreSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() val storageFile = Path(context.dataDir.path, "testdata.bin") SystemFileSystem.delete(storageFile, false) - val storageEngine = AndroidStorageEngine.Builder(context, storageFile).build() + val storageEngine = GenericStorageEngine(storageFile) ks = AndroidKeystoreSecureArea(context, storageEngine) } @@ -635,7 +635,7 @@ class AndroidKeystoreSecureAreaTest { // Check the attestation extension val parser = AndroidAttestationExtensionParser( - keyInfo.attestation.certChain!!.certificates[0].javaX509Certificate + keyInfo.attestation.certChain!!.certificates[0] ) Assert.assertArrayEquals(challenge, parser.attestationChallenge) val securityLevel = parser.keymasterSecurityLevel @@ -746,7 +746,7 @@ class AndroidKeystoreSecureAreaTest { // Check the attestation extension val parser = AndroidAttestationExtensionParser( - keyInfo.attestation.certChain!!.certificates[0].javaX509Certificate + keyInfo.attestation.certChain!!.certificates[0] ) Assert.assertArrayEquals(challenge, parser.attestationChallenge) val securityLevel = parser.keymasterSecurityLevel @@ -773,7 +773,7 @@ class AndroidKeystoreSecureAreaTest { Assert.assertEquals(setOf(KeyPurpose.SIGN), keyInfo.keyPurposes) Assert.assertEquals(EcCurve.P256, keyInfo.publicKey.curve) val parser = AndroidAttestationExtensionParser( - keyInfo.attestation.certChain!!.certificates[0].javaX509Certificate + keyInfo.attestation.certChain!!.certificates[0] ) Assert.assertArrayEquals(challenge, parser.attestationChallenge) diff --git a/identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt similarity index 98% rename from identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt rename to identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt index 2b83592d5..7dc437a27 100644 --- a/identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreCreateKeySettings.kt @@ -149,7 +149,8 @@ class AndroidKeystoreCreateKeySettings private constructor( require(UserAuthenticationType.encodeSet(userAuthenticationTypes) != 0L) { "userAuthenticationType must be set when user authentication is required" } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - require(UserAuthenticationType.encodeSet(userAuthenticationTypes) == + require( + UserAuthenticationType.encodeSet(userAuthenticationTypes) == UserAuthenticationType.LSKF.flagValue or UserAuthenticationType.BIOMETRIC.flagValue) { "Only LSKF and Strong Biometric supported on this API level" } diff --git a/identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt similarity index 100% rename from identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt rename to identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt diff --git a/identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreKeyUnlockData.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyUnlockData.kt similarity index 100% rename from identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreKeyUnlockData.kt rename to identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyUnlockData.kt diff --git a/identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt similarity index 99% rename from identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt rename to identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt index a29ad0da9..68471b3a8 100644 --- a/identity-android/src/main/java/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt @@ -321,7 +321,8 @@ class AndroidKeystoreSecureArea( } // Need to generate the data which getKeyInfo() reads from disk. - val settingsBuilder = AndroidKeystoreCreateKeySettings.Builder("".toByteArray(StandardCharsets.UTF_8)) + val settingsBuilder = + AndroidKeystoreCreateKeySettings.Builder("".toByteArray(StandardCharsets.UTF_8)) // attestation val attestationCerts = mutableListOf() diff --git a/identity-android/src/main/java/com/android/identity/android/securearea/ScreenLockRequiredException.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/ScreenLockRequiredException.kt similarity index 100% rename from identity-android/src/main/java/com/android/identity/android/securearea/ScreenLockRequiredException.kt rename to identity/src/androidMain/kotlin/com/android/identity/android/securearea/ScreenLockRequiredException.kt diff --git a/identity-android/src/main/java/com/android/identity/android/securearea/UserAuthenticationType.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/UserAuthenticationType.kt similarity index 100% rename from identity-android/src/main/java/com/android/identity/android/securearea/UserAuthenticationType.kt rename to identity/src/androidMain/kotlin/com/android/identity/android/securearea/UserAuthenticationType.kt diff --git a/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt b/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt index 10d7d81d0..fe280af18 100644 --- a/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt @@ -1,5 +1,6 @@ package com.android.identity.device +import com.android.identity.android.securearea.AndroidKeystoreCreateKeySettings import com.android.identity.crypto.Algorithm import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.SecureArea @@ -17,9 +18,9 @@ actual object DeviceCheck { clientId: String ): DeviceAttestationResult { val alias = "deviceCheck_" + Random.nextBytes(9).toBase64Url() - // TODO: utilize clientId once we have access to AndroidSecureArea APIs here - // and start checking it on the server - secureArea.createKey(alias, CreateKeySettings()) + val keySettings = AndroidKeystoreCreateKeySettings.Builder(clientId.encodeToByteArray()) + .build() + secureArea.createKey(alias, keySettings) val keyInfo = secureArea.getKeyInfo(alias) return DeviceAttestationResult( deviceAttestationId = alias, @@ -37,7 +38,8 @@ actual object DeviceCheck { alias = deviceAttestationId, signatureAlgorithm = Algorithm.ES256, dataToSign = assertionData, - keyUnlockData = null) + keyUnlockData = null + ) return DeviceAssertion( assertionData = ByteString(assertionData), platformAssertion = ByteString(signature.toCoseEncoded()) diff --git a/identity/src/commonMain/kotlin/com/android/identity/asn1/ASN1.kt b/identity/src/commonMain/kotlin/com/android/identity/asn1/ASN1.kt index 8aa86bc6a..ef2bdd894 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/asn1/ASN1.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/asn1/ASN1.kt @@ -105,6 +105,12 @@ object ASN1 { } internal fun decode(derEncoded: ByteArray, offset: Int): Pair { + if (offset >= derEncoded.size) { + // TODO: review if we should move this check somewhere elsewhere. + // We hit this code when parsing Android key attestation on the Pixel 3a emulator + // (exercised in DeviceAttestationAndroidTest). + return Pair(derEncoded.size, null) + } val (lengthOffset, idOctets) = decodeIdentifierOctets(derEncoded, offset) val (contentOffset, length) = decodeLength(derEncoded, lengthOffset) val content = derEncoded.sliceArray(IntRange(contentOffset, contentOffset + length - 1)) diff --git a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAssertionException.kt b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAssertionException.kt new file mode 100644 index 000000000..5a5398783 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAssertionException.kt @@ -0,0 +1,9 @@ +package com.android.identity.device + +/** + * Exception thrown when [DeviceAssertion] validation fails. + */ +class DeviceAssertionException( + message: String, + cause: Throwable? = null +): Exception(message, cause) \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestation.kt b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestation.kt index 5aaa7d988..8c5db0a25 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestation.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestation.kt @@ -5,10 +5,24 @@ import com.android.identity.cbor.annotation.CborSerializable /** * A platform-issued statement vouching for the integrity of the wallet app. * - * A device attestation can be validated on server (which is **not** running on the platform - * that produced [DeviceAttestation]). + * Validity checks are cross-platform, as we need to be able to run them on the server + * (e.g. one does not have to be on iOS to validate [DeviceAttestationIos]). */ @CborSerializable sealed class DeviceAttestation { + /** + * Check the validity of this [DeviceAttestation]. + * + * If validity cannot be confirmed, [DeviceAttestationException] is thrown. + */ + abstract fun validate(validationData: DeviceAttestationValidationData) + + /** + * Check the validity of [assertion] in the context of this [DeviceAttestation]. + * + * If validity cannot be confirmed, [DeviceAssertionException] is thrown. + */ + abstract fun validateAssertion(assertion: DeviceAssertion) + companion object } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationAndroid.kt b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationAndroid.kt index a4a7024d4..3db685450 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationAndroid.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationAndroid.kt @@ -1,6 +1,11 @@ package com.android.identity.device +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcSignature import com.android.identity.crypto.X509CertChain +import com.android.identity.util.validateAndroidKeyAttestation +import kotlinx.io.bytestring.encodeToByteString /** * On Android we create a private key in secure area and use its key attestation as the @@ -8,4 +13,32 @@ import com.android.identity.crypto.X509CertChain */ data class DeviceAttestationAndroid( val certificateChain: X509CertChain -) : DeviceAttestation() \ No newline at end of file +) : DeviceAttestation() { + override fun validate(validationData: DeviceAttestationValidationData) { + try { + validateAndroidKeyAttestation( + certificateChain, + validationData.clientId.encodeToByteString(), + validationData.androidGmsAttestation, + validationData.androidVerifiedBootGreen, + validationData.androidAppSignatureCertificateDigests + ) + } catch (err: Exception) { + throw DeviceAttestationException("Failed Android device attestation", err) + } + } + + override fun validateAssertion(assertion: DeviceAssertion) { + val signature = + EcSignature.fromCoseEncoded(assertion.platformAssertion.toByteArray()) + if (!Crypto.checkSignature( + publicKey = certificateChain.certificates.first().ecPublicKey, + message = assertion.assertionData.toByteArray(), + algorithm = Algorithm.ES256, + signature = signature + ) + ) { + throw DeviceAssertionException("DeviceAssertion signature validation failed") + } + } +} \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationException.kt b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationException.kt new file mode 100644 index 000000000..c8e86f493 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationException.kt @@ -0,0 +1,9 @@ +package com.android.identity.device + +/** + * Exception thrown when [DeviceAttestation] validation fails. + */ +class DeviceAttestationException( + message: String, + cause: Throwable? = null +): Exception(message, cause) \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationIos.kt b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationIos.kt index 073e00248..1cfc4f2d2 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationIos.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceAttestationIos.kt @@ -1,8 +1,241 @@ package com.android.identity.device +import com.android.identity.asn1.ASN1 +import com.android.identity.asn1.ASN1OctetString +import com.android.identity.asn1.ASN1Sequence +import com.android.identity.asn1.ASN1TaggedObject +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborArray +import com.android.identity.cbor.CborMap +import com.android.identity.cbor.Tstr +import com.android.identity.cose.CoseKey +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.crypto.EcPublicKeyDoubleCoordinate +import com.android.identity.crypto.EcSignature +import com.android.identity.crypto.X509Cert +import com.android.identity.crypto.X509CertChain +import com.android.identity.util.readInt16 +import com.android.identity.util.readInt32 import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.ByteStringBuilder +import kotlinx.io.bytestring.encodeToByteString /** On iOS device attestation is the result of Apple's DeviceCheck API. */ data class DeviceAttestationIos( val blob: ByteString -): DeviceAttestation() \ No newline at end of file +): DeviceAttestation() { + override fun validate(validationData: DeviceAttestationValidationData) { + val attestationDict = Cbor.decode(blob.toByteArray()) + val format = attestationDict["fmt"] + val attStmt = attestationDict["attStmt"] + val authDataItem = attestationDict["authData"] + if (format !is Tstr || format.asTstr != "apple-appattest" || + attStmt !is CborMap || authDataItem !is Bstr) { + throw DeviceAttestationException("Invalid attestation format") + } + val receiptItem = attStmt["receipt"] + val x5cItem = attStmt["x5c"] + if (receiptItem !is Bstr || x5cItem !is CborArray) { + throw DeviceAttestationException("Invalid attestation format") + } + val x5c = x5cItem.asArray.map { + try { + X509Cert.fromDataItem(it) + } catch (err: IllegalArgumentException) { + throw DeviceAttestationException("Invalid certificate format", err) + } + } + try { + if (!X509CertChain(x5c).validate()) { + throw DeviceAttestationException("Invalid certificate chain") + } + } catch (e: Throwable) { + throw DeviceAttestationException("Error validating certificate chain", e) + } + if (!x5c.last().verify(APPLE_ROOT_CERTIFICATE.ecPublicKey)) { + throw IllegalArgumentException("Invalid certificate chain") + } + + // Web Authentication "Authenticator" Data defined here + // https://www.w3.org/TR/webauthn/#sctn-authenticator-data + val authData = authDataItem.value + + // First, validate authData integrity, calculate the hash + val clientHash = + Crypto.digest(Algorithm.SHA256, validationData.clientId.encodeToByteArray()) + val composite = ByteStringBuilder().apply { + append(authData) + append(clientHash) + }.toByteString() + val authDataHash = Crypto.digest(Algorithm.SHA256, composite.toByteArray()) + + // Extract the expected hash value from the leaf certificate + val ext = x5c.first().getExtensionValue("1.2.840.113635.100.8.2") + ?: throw DeviceAttestationException("Required extension is missing") + val extAsn1 = ASN1.decode(ext) + ?: throw DeviceAttestationException("ASN.1 parsing failed") + val seq = extAsn1 as ASN1Sequence + if (seq.elements.size != 1) { + throw DeviceAttestationException("Extension format error") + } + val asn1obj = seq.elements.first() + val tagged = asn1obj as ASN1TaggedObject + val octetString = ASN1.decode(tagged.content) as ASN1OctetString + + // Compare the actual hash and its expected value + if (!octetString.value.contentEquals(authDataHash)) { + throw DeviceAttestationException("AuthData or clientId integrity error") + } + + // Now, parse and validate the content of authData + val auth = parseAuthData(authData) + val appIdentifier = validationData.iosAppIdentifier + if (appIdentifier != null) { + // If app identifier is given (it must be given for production environment!), + // validate it + val appHash = Crypto.digest(Algorithm.SHA256, appIdentifier.encodeToByteArray()) + if (auth.appIdHash != ByteString(appHash)) { + throw DeviceAttestationException("Application id mismatch") + } + } else { + if (validationData.iosReleaseBuild) { + throw IllegalArgumentException( + "iosAppIdentifier must be given if requireReleaseBuild is true") + } + } + + if (auth.signCount != 0) { + throw DeviceAttestationException("Not a freshly-created attestation") + } + if (auth.aaguid != releaseAaguid && + (validationData.iosReleaseBuild || auth.aaguid != debugAaguid)) { + if (auth.aaguid == debugAaguid) { + throw DeviceAttestationException("Release build required") + } else { + throw DeviceAttestationException("Unexpected aaguid value") + } + } + + val publicKeyBytes = + (x5c.first().ecPublicKey as EcPublicKeyDoubleCoordinate).asUncompressedPointEncoding + val expectedKeyIdentifier = ByteString(Crypto.digest(Algorithm.SHA256, publicKeyBytes)) + if (auth.keyIdentifier != expectedKeyIdentifier) { + throw DeviceAttestationException("Key identifier mismatch") + } + } + + override fun validateAssertion(assertion: DeviceAssertion) { + val assertionDict = Cbor.decode(assertion.platformAssertion.toByteArray()) + val signatureItem = assertionDict["signature"] + val authenticatorDataItem = assertionDict["authenticatorData"] + if (signatureItem !is Bstr || authenticatorDataItem !is Bstr) { + throw DeviceAssertionException("Invalid assertion format") + } + val authData = authenticatorDataItem.value + + val signature = EcSignature.fromDerEncoded(256, signatureItem.value) + + val attestationDict = Cbor.decode(blob.toByteArray()) + val attestationData = attestationDict["authData"].asBstr + val credentialIdLength = attestationData.readInt16(53) + val credentialPublicKeyOffset = 55 + credentialIdLength + val (_, publicKeyItem) = Cbor.decode(attestationData, credentialPublicKeyOffset) + val publicKey = CoseKey.fromDataItem(publicKeyItem).ecPublicKey + + val auth = parseAuthData(attestationData) + if (auth.appIdHash != ByteString(authData.sliceArray(0..31))) { + throw DeviceAssertionException("Application id mismatch") + } + + // Validate authData and assertion.assertionData integrity. + val clientHash = Crypto.digest(Algorithm.SHA256, assertion.assertionData.toByteArray()) + val composite = ByteStringBuilder().apply { + append(authData) + append(clientHash) + }.toByteString() + val hash = Crypto.digest(Algorithm.SHA256, composite.toByteArray()) + val valid = try { + Crypto.checkSignature(publicKey, hash, Algorithm.ES256, signature) + } catch (err: Throwable) { + throw DeviceAssertionException("Error validating signature", err) + } + if (!valid) { + throw DeviceAssertionException("Signature is not valid") + } + } + + class ParsedAuthData( + val appIdHash: ByteString, + val signCount: Int, + val aaguid: ByteString, + val keyIdentifier: ByteString, + val publicKey: EcPublicKey + ) + + companion object { + val APPLE_ROOT_CERTIFICATE = X509Cert.fromPem(""" + -----BEGIN CERTIFICATE----- + MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw + JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK + QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa + Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv + biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y + bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh + NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au + Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ + MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw + CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn + 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV + oyFraWVIyd/dganmrduC1bmTBGwD + -----END CERTIFICATE----- + """.trimIndent()) + + val releaseAaguid = ByteStringBuilder().apply { + append("appattest".encodeToByteArray()) + repeat(7) { + append(0.toByte()) + } + }.toByteString() + + val debugAaguid = "appattestdevelop".encodeToByteString() + + private fun parseAuthData(authData: ByteArray): ParsedAuthData { + val rpIdHash = ByteString(authData.sliceArray(0..31)) + val flags = authData[32].toInt() and 0xFF + val signCount = authData.readInt32(33) + if (signCount != 0) { + throw DeviceAttestationException("Not a freshly-created attestation") + } + if (flags and 0x40 == 0) { + throw DeviceAttestationException("Required authData part is missing in attestation") + } + val aaguid = ByteString(authData.sliceArray(37..52)) + val credentialIdLength = authData.readInt16(53) + val credentialPublicKeyOffset = 55 + credentialIdLength + val credentialId = ByteString(authData.sliceArray(55.., +) \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceCheck.kt b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceCheck.kt index 230edcef8..427b6a603 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/device/DeviceCheck.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/device/DeviceCheck.kt @@ -15,8 +15,10 @@ expect object DeviceCheck { * The only operation that this opaque private key can be used for is generating * assertions using [generateAssertion] method. * - * [secureArea] must be platform-specific [SecureArea] but it may or may not be used - * by this method depending on the platform. + * [secureArea] must be platform-specific [SecureArea]. (Other implementations of [SecureArea] + * may or may not be used by this method depending on the platform). + * + * Validity of the attestation can be verified using [DeviceAttestation.validate]. */ suspend fun generateAttestation( secureArea: SecureArea, @@ -30,6 +32,9 @@ expect object DeviceCheck { * Note that the exact format for the signature is platform-dependent. * * [secureArea] must be the same value as was passed to [generateAttestation] method. + * + * Validity of the assertion can be verified using [DeviceAttestation.validateAssertion] using + * the [DeviceAttestation] object that corresponds to the given [deviceAttestationId]. */ suspend fun generateAssertion( secureArea: SecureArea, diff --git a/identity/src/javaSharedMain/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt b/identity/src/commonMain/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt similarity index 64% rename from identity/src/javaSharedMain/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt rename to identity/src/commonMain/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt index c5f22aee2..f06a4dc22 100644 --- a/identity/src/javaSharedMain/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/util/AndroidAttestationExtensionParser.kt @@ -15,20 +15,19 @@ */ package com.android.identity.util -import org.bouncycastle.asn1.ASN1Boolean -import org.bouncycastle.asn1.ASN1Encodable -import org.bouncycastle.asn1.ASN1Enumerated -import org.bouncycastle.asn1.ASN1InputStream -import org.bouncycastle.asn1.ASN1Integer -import org.bouncycastle.asn1.ASN1OctetString -import org.bouncycastle.asn1.ASN1Primitive -import org.bouncycastle.asn1.ASN1Sequence -import org.bouncycastle.asn1.ASN1Set -import org.bouncycastle.asn1.ASN1TaggedObject -import java.security.cert.X509Certificate +import com.android.identity.asn1.ASN1 +import com.android.identity.asn1.ASN1Boolean +import com.android.identity.asn1.ASN1Integer +import com.android.identity.asn1.ASN1Object +import com.android.identity.asn1.ASN1OctetString +import com.android.identity.asn1.ASN1Sequence +import com.android.identity.asn1.ASN1Set +import com.android.identity.asn1.ASN1TaggedObject +import com.android.identity.crypto.X509Cert +import kotlinx.io.bytestring.ByteString // This code is based on https://github.com/google/android-key-attestation -class AndroidAttestationExtensionParser(cert: X509Certificate) { +class AndroidAttestationExtensionParser(cert: X509Cert) { enum class SecurityLevel { SOFTWARE, TRUSTED_ENVIRONMENT, @@ -54,53 +53,44 @@ class AndroidAttestationExtensionParser(cert: X509Certificate) { val verifiedBootState: VerifiedBootState - val applicationSignatureDigests: List + val applicationSignatureDigests: List - private val softwareEnforcedAuthorizations: Map - private val teeEnforcedAuthorizations: Map + private val softwareEnforcedAuthorizations: Map + private val teeEnforcedAuthorizations: Map init { val attestationExtensionBytes = cert.getExtensionValue(KEY_DESCRIPTION_OID) - require(attestationExtensionBytes != null && attestationExtensionBytes.size > 0) { + require(attestationExtensionBytes != null && attestationExtensionBytes.isNotEmpty()) { "Couldn't find keystore attestation extension." } - var seq: ASN1Sequence - ASN1InputStream(attestationExtensionBytes).use { asn1InputStream -> - // The extension contains one object, a sequence, in the - // Distinguished Encoding Rules (DER)-encoded form. Get the DER - // bytes. - val derSequenceBytes = (asn1InputStream.readObject() as ASN1OctetString).octets - ASN1InputStream(derSequenceBytes).use { seqInputStream -> - seq = seqInputStream.readObject() as ASN1Sequence - } - } + val extAsn1 = ASN1.decode(attestationExtensionBytes) + ?: throw IllegalArgumentException("ASN.1 parsing failed") + val seq = extAsn1 as ASN1Sequence - attestationVersion = getIntegerFromAsn1(seq.getObjectAt(ATTESTATION_VERSION_INDEX)) + attestationVersion = getIntegerFromAsn1(seq.elements[ATTESTATION_VERSION_INDEX]) this.attestationSecurityLevel = securityLevelToEnum( - getIntegerFromAsn1( - seq.getObjectAt(ATTESTATION_SECURITY_LEVEL_INDEX) - ) + getIntegerFromAsn1(seq.elements[ATTESTATION_SECURITY_LEVEL_INDEX]) ) - keymasterVersion = getIntegerFromAsn1(seq.getObjectAt(KEYMASTER_VERSION_INDEX)) + keymasterVersion = getIntegerFromAsn1(seq.elements[KEYMASTER_VERSION_INDEX]) this.keymasterSecurityLevel = securityLevelToEnum( - getIntegerFromAsn1(seq.getObjectAt(KEYMASTER_SECURITY_LEVEL_INDEX)) + getIntegerFromAsn1(seq.elements[KEYMASTER_SECURITY_LEVEL_INDEX]) ) attestationChallenge = - (seq.getObjectAt(ATTESTATION_CHALLENGE_INDEX) as ASN1OctetString).octets - uniqueId = (seq.getObjectAt(UNIQUE_ID_INDEX) as ASN1OctetString).octets + (seq.elements[ATTESTATION_CHALLENGE_INDEX] as ASN1OctetString).value + uniqueId = (seq.elements[UNIQUE_ID_INDEX] as ASN1OctetString).value softwareEnforcedAuthorizations = getAuthorizationMap( - (seq.getObjectAt(SW_ENFORCED_INDEX) as ASN1Sequence).toArray() + (seq.elements[SW_ENFORCED_INDEX] as ASN1Sequence).elements ) teeEnforcedAuthorizations = getAuthorizationMap( - (seq.getObjectAt(TEE_ENFORCED_INDEX) as ASN1Sequence).toArray() + (seq.elements[TEE_ENFORCED_INDEX] as ASN1Sequence).elements ) val rootOfTrustSeq = findAuthorizationListEntry(teeEnforcedAuthorizations, 704) as ASN1Sequence? verifiedBootState = if (rootOfTrustSeq != null) { - when (getIntegerFromAsn1(rootOfTrustSeq.getObjectAt(2))) { + when (getIntegerFromAsn1(rootOfTrustSeq.elements[2])) { 0 -> VerifiedBootState.GREEN 1 -> VerifiedBootState.YELLOW 2 -> VerifiedBootState.ORANGE @@ -112,15 +102,15 @@ class AndroidAttestationExtensionParser(cert: X509Certificate) { } val encodedAttestationApplicationId = getSoftwareAuthorizationByteString(709) - - val attestationApplicationIdSeq = - ASN1InputStream(encodedAttestationApplicationId).readObject() as ASN1Sequence - - val signatureDigestSet = attestationApplicationIdSeq.getObjectAt(1) as ASN1Set - val digests = mutableListOf() - for (n in 0 until signatureDigestSet.size()) { - val octetString = signatureDigestSet.getObjectAt(n) as ASN1OctetString - digests.add(octetString.octets) + ?: throw IllegalArgumentException("No software authorization") + val attestationApplicationIdSeq = (ASN1.decode(encodedAttestationApplicationId) + ?: throw IllegalArgumentException("ASN.1 parsing failed")) as ASN1Sequence + + val signatureDigestSet = attestationApplicationIdSeq.elements[1] as ASN1Set + val digests = mutableListOf() + for (element in signatureDigestSet.elements) { + val octetString = element as ASN1OctetString + digests.add(ByteString(octetString.value)) } applicationSignatureDigests = digests } @@ -163,12 +153,12 @@ class AndroidAttestationExtensionParser(cert: X509Certificate) { fun getSoftwareAuthorizationByteString(tag: Int): ByteArray? { val entry = findAuthorizationListEntry(softwareEnforcedAuthorizations, tag) as ASN1OctetString? - return entry?.octets + return entry?.value } fun getTeeAuthorizationByteString(tag: Int): ByteArray? { val entry = findAuthorizationListEntry(teeEnforcedAuthorizations, tag) as ASN1OctetString? - return entry?.octets + return entry?.value } companion object { @@ -187,53 +177,56 @@ class AndroidAttestationExtensionParser(cert: X509Certificate) { private const val KM_SECURITY_LEVEL_SOFTWARE = 0 private const val KM_SECURITY_LEVEL_TRUSTED_ENVIRONMENT = 1 private const val KM_SECURITY_LEVEL_STRONG_BOX = 2 + private fun findAuthorizationListEntry( - authorizationMap: Map, tag: Int - ): ASN1Primitive? { - return authorizationMap.getOrDefault(tag, null) + authorizationMap: Map, tag: Int + ): ASN1Object? { + return authorizationMap[tag] } - private fun getBooleanFromAsn1(asn1Value: ASN1Encodable): Boolean { + private fun getBooleanFromAsn1(asn1Value: ASN1Object): Boolean { return if (asn1Value is ASN1Boolean) { - asn1Value.isTrue + asn1Value.value } else { throw RuntimeException( - "Boolean value expected; found " + asn1Value.javaClass.name + " instead." + "Boolean value expected; found " + asn1Value::class.simpleName + " instead." ) } } - private fun getIntegerFromAsn1(asn1Value: ASN1Encodable): Int { + private fun getIntegerFromAsn1(asn1Value: ASN1Object): Int { return if (asn1Value is ASN1Integer) { - asn1Value.value.toInt() - } else if (asn1Value is ASN1Enumerated) { - asn1Value.value.toInt() + val longValue = asn1Value.toLong() + if (Int.MIN_VALUE <= longValue && longValue <= Int.MAX_VALUE) { + longValue.toInt() + } else { + throw IllegalArgumentException("Int value out of range: $longValue") + } } else { throw IllegalArgumentException( - "Integer value expected; found " + asn1Value.javaClass.name + " instead." + "Integer value expected; found " + asn1Value::class.simpleName + " instead." ) } } - private fun getLongFromAsn1(asn1Value: ASN1Encodable): Long { + private fun getLongFromAsn1(asn1Value: ASN1Object): Long { return if (asn1Value is ASN1Integer) { - asn1Value.value.toLong() - } else if (asn1Value is ASN1Enumerated) { - asn1Value.value.toLong() + asn1Value.toLong() } else { throw IllegalArgumentException( - "Integer value expected; found " + asn1Value.javaClass.name + " instead." + "Integer value expected; found " + asn1Value::class.simpleName + " instead." ) } } private fun getAuthorizationMap( - authorizationList: Array - ): Map { - val authorizationMap: MutableMap = HashMap() + authorizationList: List + ): Map { + val authorizationMap: MutableMap = HashMap() for (entry in authorizationList) { val taggedEntry = entry as ASN1TaggedObject - authorizationMap[taggedEntry.tagNo] = taggedEntry.baseObject as ASN1Primitive + authorizationMap[taggedEntry.tag] = ASN1.decode(taggedEntry.content) + ?: throw IllegalArgumentException("ASN.1 parsing error") } return authorizationMap } diff --git a/identity/src/commonMain/kotlin/com/android/identity/util/binaryUtils.kt b/identity/src/commonMain/kotlin/com/android/identity/util/binaryUtils.kt new file mode 100644 index 000000000..c8351be86 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/util/binaryUtils.kt @@ -0,0 +1,13 @@ +package com.android.identity.util + +fun ByteArray.readInt16(offset: Int): Int { + return (this[offset].toInt() and 0xFF shl 8) or + (this[offset + 1].toInt() and 0xFF) +} + +fun ByteArray.readInt32(offset: Int): Int { + return this[offset].toInt() and 0xFF shl 24 or + (this[offset + 1].toInt() and 0xFF shl 16) or + (this[offset + 2].toInt() and 0xFF shl 8) or + (this[offset + 3].toInt() and 0xFF) +} \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/util/validateKeyAttestation.kt b/identity/src/commonMain/kotlin/com/android/identity/util/validateKeyAttestation.kt new file mode 100644 index 000000000..49927629d --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/util/validateKeyAttestation.kt @@ -0,0 +1,144 @@ +package com.android.identity.util + +import com.android.identity.asn1.ASN1 +import com.android.identity.asn1.ASN1OctetString +import com.android.identity.cbor.Cbor +import com.android.identity.crypto.X509Cert +import com.android.identity.crypto.X509CertChain +import com.android.identity.securearea.AttestationExtension +import kotlinx.io.bytestring.ByteString + +fun validateAndroidKeyAttestation( + chain: X509CertChain, + nonce: ByteString?, + requireGmsAttestation: Boolean, + requireVerifiedBootGreen: Boolean, + requireAppSignatureCertificateDigests: List, +) { + if (requireGmsAttestation) { + // Google root certificate uses RSA private key (and not EC key that we currently support + // in Kotlin Multiplatform code). Instead of comparing the keys, just replace the root + // in the chain with the known root certificate before validation. + val truncatedChain = chain.certificates.subList(0, chain.certificates.lastIndex) + val chainToVerify = truncatedChain + listOf(GOOGLE_ATTESTATION_ROOT_CERTIFICATE) + check(X509CertChain(chainToVerify).validate()) { + "Certificate chain did not validate" + } + } else { + check(chain.validate()) { + "Certificate chain did not validate" + } + } + + // Check the Attestation Extension... + try { + val parser = AndroidAttestationExtensionParser(chain.certificates.first()) + + // Challenge must match... + check(nonce == null || nonce == ByteString(parser.attestationChallenge)) { + "Challenge didn't match what was expected" + } + + if (requireVerifiedBootGreen) { + // Verified Boot state must VERIFIED + check( + parser.verifiedBootState == + AndroidAttestationExtensionParser.VerifiedBootState.GREEN + ) { "Verified boot state is not GREEN" } + } + + if (requireAppSignatureCertificateDigests.isNotEmpty()) { + check (parser.applicationSignatureDigests.size == requireAppSignatureCertificateDigests.size) + { "Number Signing certificates mismatch" } + for (n in 0.. +) { + check(chain.validate()) { + "Certificate chain did not validate" + } + val certificates = chain.certificates + val leafX509Cert = certificates.first() + val extensionDerEncodedString = leafX509Cert.getExtensionValue(AttestationExtension.ATTESTATION_OID) + ?: throw IllegalStateException( + "No attestation extension at OID ${AttestationExtension.ATTESTATION_OID}") + + val challengeInAttestation = ByteString(AttestationExtension.decode(extensionDerEncodedString)) + if (challengeInAttestation != nonce) { + throw IllegalStateException("Challenge in attestation does match expected nonce") + } + + val rootPublicKey = certificates.last().ecPublicKey.toDataItem() + val trusted = trustedRootKeys.firstOrNull { trustedKey -> + Cbor.decode(trustedKey.toByteArray()) == rootPublicKey + } + + if (trusted == null) { + throw IllegalArgumentException("Unexpected cloud attestation root") + } +} diff --git a/identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationAndroidTest.kt b/identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationAndroidTest.kt new file mode 100644 index 000000000..962c8c065 --- /dev/null +++ b/identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationAndroidTest.kt @@ -0,0 +1,184 @@ +package com.android.identity.device + +import com.android.identity.util.fromBase64Url +import kotlinx.io.bytestring.ByteString +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + +class DeviceAttestationAndroidTest { + @Test + fun testValidationPixel7a() { + val deviceAttestation = DeviceAttestation.fromCbor(ATTESTATION_PIXEL7A) + deviceAttestation.validate( + DeviceAttestationValidationData( + clientId = CLIENT_ID_PIXEL7A, + iosReleaseBuild = false, + iosAppIdentifier = "", + androidGmsAttestation = true, + androidVerifiedBootGreen = true, + androidAppSignatureCertificateDigests = listOf( + ByteString("VEpxrWMf2GFLy2_HHTuN7xlW5fy6mKhVAmRADo4aLh0".fromBase64Url()) + ) + ) + ) + } + + @Test + fun testAssertionPixel7a() { + val deviceAttestation = DeviceAttestation.fromCbor(ATTESTATION_PIXEL7A) + val deviceAssertion = DeviceAssertion.fromCbor(ASSERTION_PIXEL7A) + deviceAttestation.validateAssertion(deviceAssertion) + } + + @Test + fun testAttestationEmulatorPixel3a() { + val deviceAttestation = DeviceAttestation.fromCbor(ATTESTATION_EMULATOR_PIXEL3A) + deviceAttestation.validate( + DeviceAttestationValidationData( + clientId = CLIENT_ID_EMULATOR_PIXEL3A, + iosReleaseBuild = false, + iosAppIdentifier = "", + androidGmsAttestation = false, + androidVerifiedBootGreen = false, + androidAppSignatureCertificateDigests = listOf( + ByteString("VEpxrWMf2GFLy2_HHTuN7xlW5fy6mKhVAmRADo4aLh0".fromBase64Url()) + ) + ) + ) + } + + @Test + fun testAssertionEmulatorPixel3a() { + val deviceAttestation = DeviceAttestation.fromCbor(ATTESTATION_EMULATOR_PIXEL3A) + val deviceAssertion = DeviceAssertion.fromCbor(ASSERTION_EMULATOR_PIXEL3A) + deviceAttestation.validateAssertion(deviceAssertion) + } + + companion object { + private const val CLIENT_ID_PIXEL7A = "ax8S6Z5dKhX7ywLxLx3ZzrVo" + + @OptIn(ExperimentalEncodingApi::class) + val ATTESTATION_PIXEL7A = Base64.decode(""" + omRudWxsZ0FuZHJvaWRwY2VydGlmaWNhdGVDaGFpboVZArEwggKtMIICU6ADAgECAgEBMAoGCCqGSM49 + BAMCMDkxKTAnBgNVBAMTIGE5ZjZmMWIxMTQzZGU2NmQ4OWU2Y2RhMmFkNzUwMDM4MQwwCgYDVQQKEwNU + RUUwHhcNNzAwMTAxMDAwMDAwWhcNNDgwMTAxMDAwMDAwWjAfMR0wGwYDVQQDExRBbmRyb2lkIEtleXN0 + b3JlIEtleTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABF2n2Azc1UGn2RskZxQIViuQoP6qxARoTz4t + S0hTDGJhJ7+nL1256C8auluiXngKSOznKBD7qA8RDt0EPgfjN7ajggFkMIIBYDAOBgNVHQ8BAf8EBAMC + B4AwggFMBgorBgEEAdZ5AgERBIIBPDCCATgCAgEsCgEBAgIBLAoBAQQYYXg4UzZaNWRLaFg3eXdMeEx4 + M1p6clZvBAAwaL+FPQgCBgGULYTVVr+FRVgEVjBUMS4wLAQmY29tLmFuZHJvaWQuaWRlbnRpdHlfY3Jl + ZGVudGlhbC53YWxsZXQCAgK2MSIEIFRKca1jH9hhS8tvxx07je8ZVuX8upioVQJkQA6OGi4dMIGhoQUx + AwIBAqIDAgEDowQCAgEApQUxAwIBBKoDAgEBv4N3AgUAv4U+AwIBAL+FQEwwSgQgAD8a3p1HbmErAPKY + PmrX3NFeaoDMLbsAjafWg57XOo8BAf8KAQAEIH/TlJ2uHBKhE9mBytDJvCVVDufCKfe4+7CXMqTgbUoC + v4VBBQIDAiLgv4VCBQIDAxapv4VOBgIEATTaCb+FTwYCBAE02gkwCgYIKoZIzj0EAwIDSAAwRQIgJl/Y + 2+EW/97g6gEWnlROSTT3D4cqh80TODiNr1tEIdsCIQDfnaFpxs7qW4WKbooJh1CKoZfOg8TFm47GBHbp + 7e5Q3FkB5TCCAeEwggGGoAMCAQICEQCp9vGxFD3mbYnmzaKtdQA4MAoGCCqGSM49BAMCMCkxEzARBgNV + BAoTCkdvb2dsZSBMTEMxEjAQBgNVBAMTCURyb2lkIENBMzAeFw0yNDEyMjAwNDQzMjVaFw0yNTAxMTYx + MTIxMThaMDkxKTAnBgNVBAMTIGE5ZjZmMWIxMTQzZGU2NmQ4OWU2Y2RhMmFkNzUwMDM4MQwwCgYDVQQK + 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 + DqG8At2JHA== + """.trimIndent().replace("\n", "")) + + @OptIn(ExperimentalEncodingApi::class) + val ASSERTION_PIXEL7A = Base64.decode(""" + om1hc3NlcnRpb25EYXRhU6JkbnVsbGVOb25jZWVub25jZUBxcGxhdGZvcm1Bc3NlcnRpb25YQGKxzSMj + nXr2kvwW8/b88CmEDGiYrTGMWzCflf7ok794bhED/HU4n/TBxABCvWsOLH3J67oeqOkUpksdePyjs9I= + """.trimIndent().replace("\n", "")) + + private const val CLIENT_ID_EMULATOR_PIXEL3A = "0F3iune5s98CkH3fGpwMDwL6" + + @OptIn(ExperimentalEncodingApi::class) + val ATTESTATION_EMULATOR_PIXEL3A = Base64.decode(""" + omRudWxsZ0FuZHJvaWRwY2VydGlmaWNhdGVDaGFpboNZAv4wggL6MIICoaADAgECAgEBMAoGCCqGSM49 + BAMCMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMR29vZ2xlLCBJ + bmMuMRAwDgYDVQQLDAdBbmRyb2lkMTswOQYDVQQDDDJBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0 + dGVzdGF0aW9uIEludGVybWVkaWF0ZTAeFw03MDAxMDEwMDAwMDBaFw00ODAxMDEwMDAwMDBaMB8xHTAb + BgNVBAMTFEFuZHJvaWQgS2V5c3RvcmUgS2V5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8JsdsXwh + 80fngsiKvWj0n9FRfewzQd1NfUOin8TQ4/REw7sVGJPaTUKC2B00AxsaRBTvOBjyB9jTJfxTNISoHKOC + AWIwggFeMA4GA1UdDwEB/wQEAwIHgDCCAUoGCisGAQQB1nkCAREEggE6MIIBNgICASwKAQACAgEsCgEA + BBgwRjNpdW5lNXM5OENrSDNmR3B3TUR3TDYEADCCAQahBTEDAgECogMCAQOjBAICAQClBTEDAgEEqgMC + AQG/g3cCBQC/hT0IAgYBlC+CncC/hT4DAgEAv4VATDBKBCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAEBAAoBAgQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC/hUEFAgMCIuC/hUIF + AgMDFkW/hUVYBFYwVDEuMCwEJmNvbS5hbmRyb2lkLmlkZW50aXR5X2NyZWRlbnRpYWwud2FsbGV0AgIC + tjEiBCBUSnGtYx/YYUvLb8cdO43vGVbl/LqYqFUCZEAOjhouHb+FTgMCAQC/hU8GAgQBNLL1MAAwCgYI + KoZIzj0EAwIDRwAwRAIgB5ItkZArjon8LYga5di7BZRZ1Y+WlS8iXv/IHY1iLiUCIG0Ih9CfMgzUg/+a + +qOz4NVNyaBSOAdXTKUf/HIUFmmwWQJ8MIICeDCCAh6gAwIBAgICEAEwCgYIKoZIzj0EAwIwgZgxCzAJ + BgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRUwEwYD + VQQKDAxHb29nbGUsIEluYy4xEDAOBgNVBAsMB0FuZHJvaWQxMzAxBgNVBAMMKkFuZHJvaWQgS2V5c3Rv + cmUgU29mdHdhcmUgQXR0ZXN0YXRpb24gUm9vdDAeFw0xNjAxMTEwMDQ2MDlaFw0yNjAxMDgwMDQ2MDla + MIGIMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMR29vZ2xlLCBJbmMu + MRAwDgYDVQQLDAdBbmRyb2lkMTswOQYDVQQDDDJBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVz + dGF0aW9uIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOueefhCY1msyyqRTImG + zHCtkGaTgqlzJhP+rMv4ISdMIXSXSir+pblNf2bU4GUQZjW8U7ego6ZxWD7bPhGuEBSjZjBkMB0GA1Ud + DgQWBBQ//KzWGrE6noEguNUlHMVlux6RqTAfBgNVHSMEGDAWgBTIrel3TEXDo88NFhDkeUM6IVowzzAS + BgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIChDAKBggqhkjOPQQDAgNIADBFAiBLipt77oK8 + wDOHri/AiZi03cONqycqRZ9pDMfDktQPjgIhAO7aAV229DLp1IQ7YkyUBO86fMy9Xvsiu+f+uXc/WT/7 + WQKPMIICizCCAjKgAwIBAgIJAKIFntEOQ1tXMAoGCCqGSM49BAMCMIGYMQswCQYDVQQGEwJVUzETMBEG + A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEVMBMGA1UECgwMR29vZ2xlLCBJ + bmMuMRAwDgYDVQQLDAdBbmRyb2lkMTMwMQYDVQQDDCpBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0 + dGVzdGF0aW9uIFJvb3QwHhcNMTYwMTExMDA0MzUwWhcNMzYwMTA2MDA0MzUwWjCBmDELMAkGA1UEBhMC + VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFTATBgNVBAoMDEdv + b2dsZSwgSW5jLjEQMA4GA1UECwwHQW5kcm9pZDEzMDEGA1UEAwwqQW5kcm9pZCBLZXlzdG9yZSBTb2Z0 + d2FyZSBBdHRlc3RhdGlvbiBSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7l1ex+HA220Dpn7m + thvsTWpdamguD/9/SQ59dx9EIm29sa/6FsvHrcV30lacqrewLVQBXT5DKyqO107sSHVBpKNjMGEwHQYD + VR0OBBYEFMit6XdMRcOjzw0WEOR5QzohWjDPMB8GA1UdIwQYMBaAFMit6XdMRcOjzw0WEOR5QzohWjDP + MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgKEMAoGCCqGSM49BAMCA0cAMEQCIDUho++LNEYe + nNVg8x1YiSBq3KNlQfYNns6KGYxmSGB7AiBNC/NR2TB8fVvaNTQdqEcbY6WFZTytTySn502vQX3xvw== + """.trimIndent().replace("\n", "")) + + @OptIn(ExperimentalEncodingApi::class) + val ASSERTION_EMULATOR_PIXEL3A = Base64.decode(""" + om1hc3NlcnRpb25EYXRhU6JkbnVsbGVOb25jZWVub25jZUBxcGxhdGZvcm1Bc3NlcnRpb25YQLtkyOMD + +C6VCcVpgZ6/JgWLmCgWEqiOugnKJV64isPtfvxKuEmelOYGCDxzdME7C74qORd1LuNSi88BlQ66lqo= + """.trimIndent().replace("\n", "")) + } +} \ No newline at end of file diff --git a/identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationIosTest.kt b/identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationIosTest.kt new file mode 100644 index 000000000..e0568e0e3 --- /dev/null +++ b/identity/src/commonTest/kotlin/com/android/identity/device/DeviceAttestationIosTest.kt @@ -0,0 +1,138 @@ +package com.android.identity.device + +import com.android.identity.util.fromBase64Url +import kotlinx.io.bytestring.ByteString +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + +class DeviceAttestationIosTest { + private val clientId = "3og8indEDaOekzKXJ20EMBxW" + private val deviceAttestation = DeviceAttestation.fromCbor(ATTESTATION) + private val deviceAssertion = DeviceAssertion.fromCbor(ASSERTION) + + @Test + fun testValidation() { + deviceAttestation.validate( + DeviceAttestationValidationData( + clientId = clientId, + iosReleaseBuild = false, + iosAppIdentifier = "74HWMG89B3.com.sorotokin.identity.testapp1", + androidGmsAttestation = false, + androidVerifiedBootGreen = false, + androidAppSignatureCertificateDigests = listOf() + ) + ) + } + + @Test + fun testAssertion() { + deviceAttestation.validateAssertion(deviceAssertion) + } + + companion object { + @OptIn(ExperimentalEncodingApi::class) + val ATTESTATION = Base64.decode(""" + omRudWxsY0lvc2RibG9iWRUUo2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAzgwggM0 + MIICu6ADAgECAgYBlCntieAwCgYIKoZIzj0EAwIwTzEjMCEGA1UEAwwaQXBwbGUgQXBwIEF0dGVzdGF0 + aW9uIENBIDExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjUwMTAy + MDIxMDIwWhcNMjUxMDI5MDQxNDIwWjCBkTFJMEcGA1UEAwxAYTAwMmU3NmRkZDU5ZThmYWMwODQzNzlj + MDY4NGNiNTQ5NzE5YzBhMmMzOGE5NmY0NTEwZjZhMjE0MDg2N2FmMDEaMBgGA1UECwwRQUFBIENlcnRp + ZmljYXRpb24xEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwWTATBgcqhkjO + PQIBBggqhkjOPQMBBwNCAATRiNf9blD1rZWC0nfTtonlEvfA3FYyTkGDJLVeIDPA2xBLcvP/s+oYzVo/ + kSrbAPFGBl76Cg9wf8yCf6tyMIc7o4IBPjCCATowDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBPAw + gYsGCSqGSIb3Y2QIBQR+MHykAwIBCr+JMAMCAQG/iTEDAgEAv4kyAwIBAb+JMwMCAQG/iTQsBCo3NEhX + TUc4OUIzLmNvbS5zb3JvdG9raW4uaWRlbnRpdHkudGVzdGFwcDGlBgQEc2tzIL+JNgMCAQW/iTcDAgEA + v4k5AwIBAL+JOgMCAQC/iTsDAgEAMFcGCSqGSIb3Y2QIBwRKMEi/ingIBAYxNy42LjG/iFAHAgUA//// + /r+KewcEBTIxRzkzv4p9CAQGMTcuNi4xv4p+AwIBAL+LDA8EDTIxLjcuOTMuMC4wLDAwMwYJKoZIhvdj + ZAgCBCYwJKEiBCDo3YIxjKweNWPzPdX1MYPGhKrLDkJ8xiRoODzih5xQGjAKBggqhkjOPQQDAgNnADBk + AjAQgtBoJfCJTRy5MmIbcwacO8Z2xGxV45Emmscdnvfhclicx6J/zE40Q7ZtHjFhb/4CMEGmwQ+twwKV + fwsINloW3x/rltTsLhhNeyYQjn18sG30/ZU2ao9VVbTU/J5tr0E/VFkCRzCCAkMwggHIoAMCAQICEAm6 + xeG8QBrZ1FOVvDgaCFQwCgYIKoZIzj0EAwMwUjEmMCQGA1UEAwwdQXBwbGUgQXBwIEF0dGVzdGF0aW9u + IFJvb3QgQ0ExEzARBgNVBAoMCkFwcGxlIEluYy4xEzARBgNVBAgMCkNhbGlmb3JuaWEwHhcNMjAwMzE4 + MTgzOTU1WhcNMzAwMzEzMDAwMDAwWjBPMSMwIQYDVQQDDBpBcHBsZSBBcHAgQXR0ZXN0YXRpb24gQ0Eg + MTETMBEGA1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49AgEGBSuB + BAAiA2IABK5bN6B3TXmyNY9A59HyJibxwl/vF4At6rOCalmHT/jSrRUleJqiZgQZEki2PLlnBp6Y02O9 + XjcPv6COMp6Ac6mF53Ruo1mi9m8p2zKvRV4hFljVZ6+eJn6yYU3CGmbOmaNmMGQwEgYDVR0TAQH/BAgw + BgEB/wIBADAfBgNVHSMEGDAWgBSskRBTM72+aEH/pwyp5frq5eWKoTAdBgNVHQ4EFgQUPuNdHAQZqcm0 + MfiEdNbh4Vdy45swDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2kAMGYCMQC7voiNc40FAs+8/WZt + CVdQNbzWhyw/hDBJJint0fkU6HmZHJrota7406hUM/e2DQYCMQCrOO3QzIHtAKRSw7pE+ZNjZVP+zCl/ + LrTfn16+WkrKtplcS4IN+QQ4b3gHu1iUObdncmVjZWlwdFkOsjCABgkqhkiG9w0BBwKggDCAAgEBMQ8w + DQYJYIZIAWUDBAIBBQAwgAYJKoZIhvcNAQcBoIAkgASCA+gxggRrMDICAQICAQEEKjc0SFdNRzg5QjMu + Y29tLnNvcm90b2tpbi5pZGVudGl0eS50ZXN0YXBwMTCCA0ICAQMCAQEEggM4MIIDNDCCArugAwIBAgIG + AZQp7YngMAoGCCqGSM49BAMCME8xIzAhBgNVBAMMGkFwcGxlIEFwcCBBdHRlc3RhdGlvbiBDQSAxMRMw + EQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTI1MDEwMjAyMTAyMFoXDTI1 + MTAyOTA0MTQyMFowgZExSTBHBgNVBAMMQGEwMDJlNzZkZGQ1OWU4ZmFjMDg0Mzc5YzA2ODRjYjU0OTcx + OWMwYTJjMzhhOTZmNDUxMGY2YTIxNDA4NjdhZjAxGjAYBgNVBAsMEUFBQSBDZXJ0aWZpY2F0aW9uMRMw + EQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMFkwEwYHKoZIzj0CAQYIKoZIzj0D + AQcDQgAE0YjX/W5Q9a2VgtJ307aJ5RL3wNxWMk5BgyS1XiAzwNsQS3Lz/7PqGM1aP5Eq2wDxRgZe+goP + cH/Mgn+rcjCHO6OCAT4wggE6MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgTwMIGLBgkqhkiG92Nk + CAUEfjB8pAMCAQq/iTADAgEBv4kxAwIBAL+JMgMCAQG/iTMDAgEBv4k0LAQqNzRIV01HODlCMy5jb20u + c29yb3Rva2luLmlkZW50aXR5LnRlc3RhcHAxpQYEBHNrcyC/iTYDAgEFv4k3AwIBAL+JOQMCAQC/iToD + AgEAv4k7AwIBADBXBgkqhkiG92NkCAcESjBIv4p4CAQGMTcuNi4xv4hQBwIFAP////6/insHBAUyMUc5 + M7+KfQgEBjE3LjYuMb+KfgMCAQC/iwwPBA0yMS43LjkzLjAuMCwwMDMGCSqGSIb3Y2QIAgQmMCShIgQg + 6N2CMYysHjVj8z3V9TGDxoSqyw5CfMYkaDg84oecUBowCgYIKoZIzj0EAwIDZwAwZAIwEILQaCXwiU0c + uTJiG3MGnDvGdsRsVeORJprHHZ734XJYnMeif8xONEO2bR4xYW/+AjBBpsEPrcMClX8LCDZaFt8f65bU + 7C4YTXsmEI59fLBt9P2VNmqPVVW01Pyeba9BP1QwKAIBBAIBAQQgnx+6vf4V+pL+KXMG+2YHa6410Lnf + dY/b+EeDxY11hFgwYAIBBQIBAQRYb1dGSVlYRHhEaFY1V0V1MVZhRFFocFVITnpDY2dJNHlkNi9oZTFO + RmN4MXFRYm9PWXAvcGovBIGHSU1qbjFnalYvNi9zcWhlMHV6M1ZJTUhBNTdyNWkydEE9PTAOAgEGAgEB + BAZBVFRFU1QwDwIBBwIBAQQHc2FuZGJveDAgAgEMAgEBBBgyMDI1LTAxLTAzVDAyOjEwOjIwLjc4Mlow + IAIBFQIBAQQYMjAyNS0wNC0wM1QwMjoxMDoyMC43ODJaAAAAAAAAoIAwggOuMIIDVKADAgECAhB+AhJg + 2M53q3KlnfBoJ779MAoGCCqGSM49BAMCMHwxMDAuBgNVBAMMJ0FwcGxlIEFwcGxpY2F0aW9uIEludGVn + cmF0aW9uIENBIDUgLSBHMTEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzAR + BgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI0MDIyNzE4Mzk1MloXDTI1MDMyODE4Mzk1 + MVowWjE2MDQGA1UEAwwtQXBwbGljYXRpb24gQXR0ZXN0YXRpb24gRnJhdWQgUmVjZWlwdCBTaWduaW5n + MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA + BFQ3uILGT8UT6XpR5xJ0VeFLGpALmYvX1BaHaT8L2JPKizXqPVgjyWp1rfxMt3+SzCmZkJPZxtwtGADJ + AyD0e0SjggHYMIIB1DAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFNkX/ktnkDhLkvTbztVXgBQLjz3J + MEMGCCsGAQUFBwEBBDcwNTAzBggrBgEFBQcwAYYnaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1h + YWljYTVnMTAxMIIBHAYDVR0gBIIBEzCCAQ8wggELBgkqhkiG92NkBQEwgf0wgcMGCCsGAQUFBwICMIG2 + DIGzUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2NlcHRh + bmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRhcmQgdGVybXMgYW5kIGNvbmRpdGlvbnMgb2Yg + dXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNlcnRpZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50 + cy4wNQYIKwYBBQUHAgEWKWh0dHA6Ly93d3cuYXBwbGUuY29tL2NlcnRpZmljYXRlYXV0aG9yaXR5MB0G + A1UdDgQWBBQrz0ke+88beQ7wrwIpE7UBFuF5NDAOBgNVHQ8BAf8EBAMCB4AwDwYJKoZIhvdjZAwPBAIF + ADAKBggqhkjOPQQDAgNIADBFAiEAh6gJK3RfmEDFOpQhQRpdi6oJgNSGktXW0pmZ0HjHyrUCID9lU4wT + LM+IMDSwR3Xol1PPz9P3RINVupdWXH2KBoEcMIIC+TCCAn+gAwIBAgIQVvuD1Cv/jcM3mSO1Wq5uvTAK + BggqhkjOPQQDAzBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENl + cnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0x + OTAzMjIxNzUzMzNaFw0zNDAzMjIwMDAwMDBaMHwxMDAuBgNVBAMMJ0FwcGxlIEFwcGxpY2F0aW9uIElu + dGVncmF0aW9uIENBIDUgLSBHMTEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx + EzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE + ks5jvX2GsasoCjsc4a/7BJSAkaz2Md+myyg1b0RL4SHlV90SjY26gnyVvkn6vjPKrs0EGfEvQyX69L6z + y4N+uqOB9zCB9DAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFLuw3qFYM4iapIqZ3r6966/ayySr + MEYGCCsGAQUFBwEBBDowODA2BggrBgEFBQcwAYYqaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1h + cHBsZXJvb3RjYWczMDcGA1UdHwQwMC4wLKAqoCiGJmh0dHA6Ly9jcmwuYXBwbGUuY29tL2FwcGxlcm9v + dGNhZzMuY3JsMB0GA1UdDgQWBBTZF/5LZ5A4S5L0287VV4AUC489yTAOBgNVHQ8BAf8EBAMCAQYwEAYK + KoZIhvdjZAYCAwQCBQAwCgYIKoZIzj0EAwMDaAAwZQIxAI1vpp+h4OTsW05zipJ/PXhTmI/02h9YHsN1 + Sv44qEwqgxoaqg2mZG3huZPo0VVM7QIwZzsstOHoNwd3y9XsdqgaOlU7PzVqyMXmkrDhYb6ASWnkXyup + bOERAqrMYdk4t3NKMIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS + QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTET + MBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgx + OTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmlj + YXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 + AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7Yy + Yl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0O + BBYEFLuw3qFYM4iapIqZ3r6966/ayySrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoG + CCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 + at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKAAAMYH9 + MIH6AgEBMIGQMHwxMDAuBgNVBAMMJ0FwcGxlIEFwcGxpY2F0aW9uIEludGVncmF0aW9uIENBIDUgLSBH + MTEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIElu + Yy4xCzAJBgNVBAYTAlVTAhB+AhJg2M53q3KlnfBoJ779MA0GCWCGSAFlAwQCAQUAMAoGCCqGSM49BAMC + BEcwRQIgXMvtRCUcK4qSKjwt2etil3uN1KW6MMV/8ZsYYYp69OoCIQCkBSU+Ubf2P4q3qsA0jeZgK303 + DSiFbyKhdxKhgmvueAAAAAAAAGhhdXRoRGF0YVikmnXkzs8lGdYheUnvor1+3/kLTXkIv1nnBWn8A/UP + HkdAAAAAAGFwcGF0dGVzdGRldmVsb3AAIKAC523dWej6wIQ3nAaEy1SXGcCiw4qW9FEPaiFAhnrwpQEC + AyYgASFYINGI1/1uUPWtlYLSd9O2ieUS98DcVjJOQYMktV4gM8DbIlggEEty8/+z6hjNWj+RKtsA8UYG + XvoKD3B/zIJ/q3Iwhzs= + """.trimIndent().replace("\n", "")) + + @OptIn(ExperimentalEncodingApi::class) + val ASSERTION = Base64.decode(""" + om1hc3NlcnRpb25EYXRhU6JkbnVsbGVOb25jZWVub25jZUBxcGxhdGZvcm1Bc3NlcnRpb25YjKJpc2ln + bmF0dXJlWEYwRAIgGPzVfFZj1m0TtZ88nc5uruubHAWAZY++pzeMQkjuaCMCIBAp9rur04YQBT15VmkL + jYgCXPMuY/7UJs5F0d5UQ5LpcWF1dGhlbnRpY2F0b3JEYXRhWCWadeTOzyUZ1iF5Se+ivX7f+QtNeQi/ + WecFafwD9Q8eR0AAAAAB + """.trimIndent().replace("\n", "")) + } +} + diff --git a/identity/src/commonTest/kotlin/com/android/identity/util/ValidateKeyAttestationTest.kt b/identity/src/commonTest/kotlin/com/android/identity/util/ValidateKeyAttestationTest.kt new file mode 100644 index 000000000..6964514e5 --- /dev/null +++ b/identity/src/commonTest/kotlin/com/android/identity/util/ValidateKeyAttestationTest.kt @@ -0,0 +1,58 @@ +package com.android.identity.util + +import com.android.identity.cbor.Cbor +import com.android.identity.crypto.X509CertChain +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.encodeToByteString +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + +class ValidateKeyAttestationTest { + // NB: Android key attestation is covered by DeviceAttestationAndroidTest + + @Test + fun testCloudKeyAttestation() { + val certChain = X509CertChain.fromDataItem(Cbor.decode(CLOUD_CERT_CHAIN)) + println(certChain.certificates.first().toPem()) + validateCloudKeyAttestation( + chain = certChain, + nonce = CLOUD_NONCE, + trustedRootKeys = setOf(ByteString(CLOUD_ROOT_KEY)) + ) + } + + companion object { + val CLOUD_NONCE = "awLuB0C4LL1Rg5lhmrlmWf".encodeToByteString() + + @OptIn(ExperimentalEncodingApi::class) + val CLOUD_ROOT_KEY = Base64.decode(""" + pAECIAEhWCBLAV5gRgZs+trIjDAIV46A2SB+4llWKqdfQYcDInZoCyJYIEDg+bzxPHLiApaQh4DDXa8k + R+rvljhZUFNTeYOmk9PE + """.trimIndent().replace("\n", "")) + + @OptIn(ExperimentalEncodingApi::class) + val CLOUD_CERT_CHAIN = Base64.decode(""" + g1kBuDCCAbQwggFaoAMCAQICAQEwCgYIKoZIzj0EAwIwLTErMCkGA1UEAwwiQ2xvdWQgU2VjdXJlIEFy + ZWEgQXR0ZXN0YXRpb24gUm9vdDAiGA8xOTcwMDEwMTAwMDAwMFoYDzIxMDYwMjA3MDYyODE0WjAgMR4w + HAYDVQQDDBVDbG91ZCBTZWN1cmUgQXJlYSBLZXkwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAStvO4M + YJB9CVj1JkDk5QcoAZSWD55oFO/0J0mI1DDKUlycyd6BsbzlcSbVnRvS3owNCqQvzGHtYQsqOsZChiVp + o3QwcjAfBgNVHSMEGDAWgBR7nk6QYpM5u5mKQ3WJJxBejUKJxjAwBgorBgEEAdZ5AgExBCKhaWNoYWxs + ZW5nZVZhd0x1QjBDNExMMVJnNWxobXJsbVdmMB0GA1UdDgQWBBQwQLxfaQcuzevxaGJ4EmTiKmF5xDAK + BggqhkjOPQQDAgNIADBFAiBU4piBUIgTePAfxsMPYEt5LdjFPomUBHUJUoE7rrXbqQIhAPnj91IdG3bn + Zkkd3vjN68hjyiFGARyotSmBfZFxkS1IWQF8MIIBeDCCAR+gAwIBAgIBATAKBggqhkjOPQQDAjAXMRUw + EwYDVQQDDAxjc2FfZGV2X3Jvb3QwIhgPMjAyNTAxMDEyMDEyNTFaGA8yMDM1MDEwMTIwMTI1MVowLTEr + MCkGA1UEAwwiQ2xvdWQgU2VjdXJlIEFyZWEgQXR0ZXN0YXRpb24gUm9vdDBZMBMGByqGSM49AgEGCCqG + SM49AwEHA0IABPNOGesocse2qyi5RwNflue9Psb38emmPdTh1WCN/53gzsNorszVcvdyl/8945l6Ps4i + snv/DpwwrMMeEKW6pnCjQjBAMB8GA1UdIwQYMBaAFEqc1iDkhWpfhozT8rxG49A6ClfbMB0GA1UdDgQW + BBR7nk6QYpM5u5mKQ3WJJxBejUKJxjAKBggqhkjOPQQDAgNHADBEAiACl3toPGd0OJ1lk3HUfQFbwZDj + Z91IiEiCmjL2ruZIqQIgUV65WhS4xvZfRVEWnhU/W5BOJ7OaKlL8iYKXg5fl41ZZAVcwggFTMIH6oAMC + AQICCQCNcBm6IuEUbzAKBggqhkjOPQQDAjAXMRUwEwYDVQQDDAxjc2FfZGV2X3Jvb3QwHhcNMjQxMTEz + MDAwNzAzWhcNMzQxMTIxMDAwNzAzWjAXMRUwEwYDVQQDDAxjc2FfZGV2X3Jvb3QwWTATBgcqhkjOPQIB + BggqhkjOPQMBBwNCAARLAV5gRgZs+trIjDAIV46A2SB+4llWKqdfQYcDInZoC0Dg+bzxPHLiApaQh4DD + Xa8kR+rvljhZUFNTeYOmk9PEoy8wLTAdBgNVHQ4EFgQUSpzWIOSFal+GjNPyvEbj0DoKV9swDAYDVR0T + BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiAzvkpHNXojiABGTUgFwJfuf7SvY4RdZ8Xrs3SUdwo2NgIh + ANCywDVu7SO42f4ZdA2ZzQJWF1IAXYltFz7vWP81gsjj + """.trimIndent().replace("\n", "")) + } +} \ No newline at end of file diff --git a/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt b/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt index 827385b50..1c9daecf6 100644 --- a/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt @@ -195,7 +195,7 @@ class CloudSecureAreaServlet : BaseHttpServlet() { settings.cloudSecureAreaRekeyingIntervalSeconds, settings.androidRequireGmsAttestation, settings.androidRequireVerifiedBootGreen, - settings.androidRequireAppSignatureCertificateDigests.map { hex -> hex.fromHex() }, + settings.androidRequireAppSignatureCertificateDigests.map { it.toByteArray() }, SimplePassphraseFailureEnforcer( settings.cloudSecureAreaLockoutNumFailedAttempts, settings.cloudSecureAreaLockoutDurationSeconds.seconds