diff --git a/README.md b/README.md index 5ac3186..f515c67 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ val verifier = Verifier( ) // Verify an attestation certificate chain -val result = verifier.verify(certificateChain) +val result = verifier.verify( + certificateChain, + challengeChecker, + AttestationApplicationIdChecker.LENIENT(expectedAppId), +) // Handle the verification result when (result) { @@ -26,6 +30,7 @@ when (result) { val deviceInformation = result.deviceInformation } is VerificationResult.ChallengeMismatch -> // Handle challenge mismatch + is VerificationResult.AttestationApplicationIdMismatch -> // Handle mismatch is VerificationResult.PathValidationFailure -> // Handle validation failure is VerificationResult.ChainParsingFailure -> // Handle parsing failure is VerificationResult.ExtensionParsingFailure -> // Handle extension parsing issues @@ -33,17 +38,43 @@ when (result) { } ``` -If there is additional verification you'd like to perform on the challenge -associated with the attestation certificate chain, pass in a `ChallengeChecker` -when verifying. For example, if you expect the challenge to be equal to -"challenge123", then usage would look like +### Choose a strong attestation challenge {#use-the-challenge} + +Correctly generating your attestation challenges can: + + * Prevent replay attacks (where attackers use an attestation more than once). + * Set a time-bound on a replay/relay (where attackers use an attestation from + one device on another device) attacks. + +**Important:** A challenge alone, unless it is bound to the protocol in some +other way, cannot entirely prevent relay attacks. + +#### Include information unique to the request in the challenge + +See the +[PIA](https://developer.android.com/google/play/integrity/standard#protect-requests) +documentation for guidance on how to choose a strong challenge by including +information from the request inthe challenge. Note that the equivalent of the +attestation challenge is `requestHash` in the PIA context. + +#### Set time bounds for attestation validity + +It’s important that the attestation not be valid for eternity. The longer an +attestation lives, the more likely it is to be used for a replay or relay +attack. + +The easiest way to do this is to include a timestamp signed by your server-side +code in the challenge. When you verify the attestation's challenge, you'll check +the challenge signature and then make sure the timestamp is sufficiently fresh. + +#### Example implementations + +For example, if you expect the challenge to be equal to "challenge123", then +usage would look like ```kotlin // Create a ChallengeChecker val challengeChecker = ChallengeMatcher(ByteString.copyFromUtf8("challenge123")) - -// Verify an attestation certificate chain with the checker -val result = verifier.verify(certificateChain, challengeChecker) ``` If there are multiple checks to perform on the challenge, use a @@ -76,6 +107,17 @@ against the `InMemoryLruCache` if the challenge doesn't match. If the implementations in `challengecheckers/` don't fit your needs, simply extend the `ChallengeChecker` interface. +### Getting the expected Attestation Application Id + +It is important to check the attestation application ID when verifying a key +attestation. This assures that you don't accept attestations for keys controlled +by other applications, and can provide some assurance against relay attacks. + +The package list should be the names of all applications you expect to verify +against and their minimum accepted version numbers. You can get the signature +digests to put in in `signatures` from the Play Console as the app certificate +digests. + ## Building ```bash diff --git a/src/main/kotlin/AttestationApplicationIdChecker.kt b/src/main/kotlin/AttestationApplicationIdChecker.kt new file mode 100644 index 0000000..4faf116 --- /dev/null +++ b/src/main/kotlin/AttestationApplicationIdChecker.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.keyattestation.verifier + +import com.google.errorprone.annotations.ThreadSafe +import java.math.BigInteger + +/** Checks the attestation application ID in an Android Key Attestation certificate. */ +@ThreadSafe +sealed class AttestationApplicationIdChecker( + val isSatisfied: (AttestationApplicationId?, BigInteger?) -> Boolean +) { + /** + * Checks the given [attestationApplicationId] for validity. + * + * @return True if the attestation application ID is valid, else false. + */ + fun checkAttestationApplicationId( + attestationApplicationId: AttestationApplicationId?, + osVersion: BigInteger?, + ): Boolean = isSatisfied(attestationApplicationId, osVersion) + + /** + * Checks that the attestation application ID matches the expected value. + * + * This is the strictest form of attestation application ID check. There is no leniency for the OS + * version so it may fail on older devices. + * + * @param expectedId The expected value of the attestation application ID. + */ + data class STRICT(val expectedId: AttestationApplicationId) : + AttestationApplicationIdChecker({ id, _ -> expectedId.isSatisfiedBy(id) }) + + /** + * Checks that the attestation application ID matches the expected value or that the OS version is + * at or below the maximum OS version. + * + * This is a lenient form of attestation application ID check. It allows for older devices to pass + * the check if they have an unknown package name. + * + * @param expectedId The expected value of the attestation application ID. + * @param maxOsVersion The maximum OS version that the device can be on to pass the check. + */ + data class LENIENT(val expectedId: AttestationApplicationId, val maxOsVersion: BigInteger) : + AttestationApplicationIdChecker({ id, osVersion -> + expectedId.isSatisfiedBy(id) || + (osVersion != null && osVersion <= maxOsVersion && hasUnknownPackage(id)) + }) + + /** + * Does not check the attestation application ID. This should only be used for testing purposes. + */ + data object NONE : AttestationApplicationIdChecker({ _, _ -> true }) + + companion object { + fun hasUnknownPackage(id: AttestationApplicationId?) = + id != null && id.packages.any { it.name == "UnknownPackage" && it.version == BigInteger.ONE } + } +} diff --git a/src/main/kotlin/Extension.kt b/src/main/kotlin/Extension.kt index c3842fa..ac26fb1 100644 --- a/src/main/kotlin/Extension.kt +++ b/src/main/kotlin/Extension.kt @@ -609,6 +609,26 @@ data class AttestationApplicationId( } .let { DERSequence(it.toTypedArray()) } + /** + * Checks if the AttestationApplicationId is satisfied by the [candidate] + * AttestationApplicationId. + * + * @param candidate The actual AttestationApplicationId. + * @return True if the candidate satisfies the AttestationApplicationId, false otherwise. + */ + fun isSatisfiedBy(candidate: AttestationApplicationId?): Boolean { + if (candidate == null) return false + + if (packages.isNotEmpty()) { + if (packages.none { it.isSatisfiedBy(candidate.packages) }) return false + } + + if (signatures.isNotEmpty()) { + if (signatures.none { it in candidate.signatures }) return false + } + return true + } + internal companion object { fun from(seq: ASN1Sequence): AttestationApplicationId { require(seq.size() == 2) @@ -638,6 +658,10 @@ data class AttestationPackageInfo(val name: String, val version: BigInteger) { } .let { DERSequence(it.toTypedArray()) } + internal fun isSatisfiedBy(candidate: Set) = candidate.any { + it.name == name && it.version >= version + } + internal companion object { fun from(attestationPackageInfo: ASN1Sequence): AttestationPackageInfo { require(attestationPackageInfo.size() == 2) { diff --git a/src/main/kotlin/Verifier.kt b/src/main/kotlin/Verifier.kt index 01cbb9d..0d834bd 100644 --- a/src/main/kotlin/Verifier.kt +++ b/src/main/kotlin/Verifier.kt @@ -56,6 +56,8 @@ sealed interface VerificationResult { data object ChallengeMismatch : VerificationResult + data class AttestationApplicationIdMismatch(val config: String) : VerificationResult + data class PathValidationFailure(val cause: CertPathValidatorException) : VerificationResult data class ChainParsingFailure(val cause: Exception) : VerificationResult @@ -183,13 +185,19 @@ constructor( fun verify( chain: List, challengeChecker: ChallengeChecker? = null, + // TODO(google-internal bug): Make AttestationApplicationIdChecker a required parameter. + // All callers should be providing this. + attestationApplicationIdChecker: AttestationApplicationIdChecker = + AttestationApplicationIdChecker.NONE, log: LogHook? = null, ): VerificationResult { val requestLog = log?.createRequestLog() val result = try { val certPath = KeyAttestationCertPath(chain) - runBlocking { internalVerify(certPath, challengeChecker, requestLog) } + runBlocking { + internalVerify(certPath, attestationApplicationIdChecker, challengeChecker, requestLog) + } } catch (e: CertificateException) { requestLog?.logInputChain(chain.map { it.getEncoded().toByteString() }) VerificationResult.ChainParsingFailure(e) @@ -206,6 +214,8 @@ constructor( * @param chain The attestation certificate chain to verify. * @param coroutineScope The coroutine scope to from which to run the verification. * @param challengeChecker The challenge checker to use for additional challenge validation. + * @param attestationApplicationIdChecker The attestation application ID checker to use for + * additional attestation application ID validation. * @param log The log hook to use for logging. * @return A [ListenableFuture] containing the [VerificationResult]. */ @@ -214,6 +224,10 @@ constructor( coroutineScope: CoroutineScope, chain: List, challengeChecker: ChallengeChecker? = null, + // TODO(google-internal bug): Make AttestationApplicationIdChecker a required parameter. + // All callers should be providing this. + attestationApplicationIdChecker: AttestationApplicationIdChecker = + AttestationApplicationIdChecker.NONE, log: LogHook? = null, ): ListenableFuture { val immutableChain = ImmutableList.copyOf(chain) @@ -222,7 +236,7 @@ constructor( val result = try { val certPath = KeyAttestationCertPath(immutableChain) - internalVerify(certPath, challengeChecker, requestLog) + internalVerify(certPath, attestationApplicationIdChecker, challengeChecker, requestLog) } catch (e: CertificateException) { requestLog?.logInputChain(immutableChain.map { it.getEncoded().toByteString() }) VerificationResult.ChainParsingFailure(e) @@ -236,6 +250,7 @@ constructor( @RequiresApi(24) private suspend fun internalVerify( certPath: KeyAttestationCertPath, + attestationApplicationIdChecker: AttestationApplicationIdChecker, challengeChecker: ChallengeChecker? = null, log: VerifyRequestLog? = null, ): VerificationResult { @@ -305,6 +320,17 @@ constructor( } } + if ( + !attestationApplicationIdChecker.checkAttestationApplicationId( + keyDescription.softwareEnforced.attestationApplicationId, + keyDescription.hardwareEnforced.osVersion, + ) + ) { + return VerificationResult.AttestationApplicationIdMismatch( + attestationApplicationIdChecker.javaClass.simpleName + ) + } + for (constraint in constraintConfig.getConstraints()) { val result = constraint.check(keyDescription, certPath) when (result) { diff --git a/src/test/kotlin/AttestationApplicationIdCheckerTest.kt b/src/test/kotlin/AttestationApplicationIdCheckerTest.kt new file mode 100644 index 0000000..158afca --- /dev/null +++ b/src/test/kotlin/AttestationApplicationIdCheckerTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.keyattestation.verifier + +import com.google.common.truth.Truth.assertThat +import com.google.protobuf.ByteString +import com.google.protobuf.kotlin.toByteStringUtf8 +import java.math.BigInteger +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class AttestationApplicationIdCheckerTest { + + private companion object { + val TEST_PACKAGE = AttestationPackageInfo("com.example.app", BigInteger.valueOf(10)) + val TEST_SIGNATURE = ByteString.copyFromUtf8("test-signature") + val EXPECTED_APP_ID = + AttestationApplicationId(packages = setOf(TEST_PACKAGE), signatures = setOf(TEST_SIGNATURE)) + val UNKNOWN_PACKAGE = AttestationPackageInfo("UnknownPackage", BigInteger.ONE) + val MAX_OS_VERSION = BigInteger.valueOf(140000) + } + + @Test + fun strict_matchingAppId_returnsTrue() { + val checker = AttestationApplicationIdChecker.STRICT(EXPECTED_APP_ID) + val actualAppId = + AttestationApplicationId(packages = setOf(TEST_PACKAGE), signatures = setOf(TEST_SIGNATURE)) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isTrue() + } + + @Test + fun strict_higherVersion_returnsTrue() { + val checker = AttestationApplicationIdChecker.STRICT(EXPECTED_APP_ID) + val actualAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("com.example.app", BigInteger.valueOf(11))), + signatures = setOf(TEST_SIGNATURE), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isTrue() + } + + @Test + fun strict_noSignatureRequirements_returnsTrue() { + val checker = + AttestationApplicationIdChecker.STRICT( + AttestationApplicationId(packages = setOf(TEST_PACKAGE), signatures = emptySet()) + ) + val actualAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("com.example.app", BigInteger.valueOf(11))), + signatures = setOf("totally-not-the-right-signature".toByteStringUtf8()), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isTrue() + } + + @Test + fun strict_lowerVersion_returnsFalse() { + val checker = AttestationApplicationIdChecker.STRICT(EXPECTED_APP_ID) + val actualAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("com.example.app", BigInteger.valueOf(9))), + signatures = setOf(TEST_SIGNATURE), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isFalse() + } + + @Test + fun strict_mismatchedPackageName_returnsFalse() { + val checker = AttestationApplicationIdChecker.STRICT(EXPECTED_APP_ID) + val actualAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("com.other.app", BigInteger.valueOf(10))), + signatures = setOf(TEST_SIGNATURE), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isFalse() + } + + @Test + fun strict_mismatchedSignature_returnsFalse() { + val checker = AttestationApplicationIdChecker.STRICT(EXPECTED_APP_ID) + val actualAppId = + AttestationApplicationId( + packages = setOf(TEST_PACKAGE), + signatures = setOf(ByteString.copyFromUtf8("other-signature")), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isFalse() + } + + @Test + fun lenient_matchingAppId_returnsTrueRegardlessOfOsVersion() { + val checker = AttestationApplicationIdChecker.LENIENT(EXPECTED_APP_ID, MAX_OS_VERSION) + + // Higher OS version with matching ID + assertThat(checker.checkAttestationApplicationId(EXPECTED_APP_ID, BigInteger.valueOf(150000))) + .isTrue() + // Lower OS version with matching ID + assertThat(checker.checkAttestationApplicationId(EXPECTED_APP_ID, BigInteger.valueOf(130000))) + .isTrue() + } + + @Test + fun lenient_mismatchedAppId_withUnknownPackage_andLowerOrEqualOsVersion_returnsTrue() { + val checker = AttestationApplicationIdChecker.LENIENT(EXPECTED_APP_ID, MAX_OS_VERSION) + val actualAppId = + AttestationApplicationId(packages = setOf(UNKNOWN_PACKAGE), signatures = setOf()) + + // OS version equal to maxOsVersion + assertThat(checker.checkAttestationApplicationId(actualAppId, MAX_OS_VERSION)).isTrue() + // OS version strictly lower than maxOsVersion + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(130000))) + .isTrue() + } + + @Test + fun lenient_mismatchedAppId_withUnknownPackage_andHigherOsVersion_returnsFalse() { + val checker = AttestationApplicationIdChecker.LENIENT(EXPECTED_APP_ID, MAX_OS_VERSION) + val actualAppId = + AttestationApplicationId(packages = setOf(UNKNOWN_PACKAGE), signatures = setOf()) + + // OS version exceeds maxOsVersion + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(150000))) + .isFalse() + } + + @Test + fun lenient_mismatchedAppId_withoutUnknownPackage_andLowerOsVersion_returnsFalse() { + val checker = AttestationApplicationIdChecker.LENIENT(EXPECTED_APP_ID, MAX_OS_VERSION) + val actualAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("com.other.app", BigInteger.valueOf(10))), + signatures = setOf(TEST_SIGNATURE), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(130000))) + .isFalse() + } + + @Test + fun lenient_mismatchedAppId_withUnknownPackageWrongVersion_returnsFalse() { + val checker = AttestationApplicationIdChecker.LENIENT(EXPECTED_APP_ID, MAX_OS_VERSION) + val actualAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("UnknownPackage", BigInteger.valueOf(2))), + signatures = setOf(), + ) + + assertThat(checker.checkAttestationApplicationId(actualAppId, BigInteger.valueOf(130000))) + .isFalse() + } + + @Test + fun none_alwaysReturnsTrue() { + val checker = AttestationApplicationIdChecker.NONE + val anyAppId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("arbitrary.app", BigInteger.valueOf(1))), + signatures = setOf(ByteString.copyFromUtf8("arbitrary-sig")), + ) + + assertThat(checker.checkAttestationApplicationId(anyAppId, BigInteger.valueOf(123456))).isTrue() + assertThat( + checker.checkAttestationApplicationId( + AttestationApplicationId(emptySet(), emptySet()), + BigInteger.ZERO, + ) + ) + .isTrue() + } + + @Test + fun hasUnknownPackage_withMatchingUnknownPackage_returnsTrue() { + val appId = + AttestationApplicationId( + packages = setOf(TEST_PACKAGE, UNKNOWN_PACKAGE), + signatures = emptySet(), + ) + + assertThat(AttestationApplicationIdChecker.hasUnknownPackage(appId)).isTrue() + } + + @Test + fun hasUnknownPackage_withWrongVersion_returnsFalse() { + val appId = + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("UnknownPackage", BigInteger.valueOf(2))), + signatures = emptySet(), + ) + + assertThat(AttestationApplicationIdChecker.hasUnknownPackage(appId)).isFalse() + } + + @Test + fun hasUnknownPackage_withoutUnknownPackage_returnsFalse() { + val appId = AttestationApplicationId(packages = setOf(TEST_PACKAGE), signatures = emptySet()) + + assertThat(AttestationApplicationIdChecker.hasUnknownPackage(appId)).isFalse() + } + + @Test + fun hasUnknownPackage_emptyPackages_returnsFalse() { + val appId = AttestationApplicationId(packages = emptySet(), signatures = emptySet()) + + assertThat(AttestationApplicationIdChecker.hasUnknownPackage(appId)).isFalse() + } +} diff --git a/src/test/kotlin/VerifierTest.kt b/src/test/kotlin/VerifierTest.kt index 0b08445..e2833ce 100644 --- a/src/test/kotlin/VerifierTest.kt +++ b/src/test/kotlin/VerifierTest.kt @@ -43,6 +43,7 @@ import com.google.testing.junit.testparameterinjector.TestParameters import com.google.testing.junit.testparameterinjector.TestParameters.TestParametersValues import com.google.testing.junit.testparameterinjector.TestParametersValuesProvider import com.google.testing.junit.testparameterinjector.TestParametersValuesProvider.Context +import java.math.BigInteger import java.security.cert.PKIXReason import java.security.cert.TrustAnchor import java.time.Instant @@ -105,7 +106,16 @@ class VerifierTest { ), ) val chain = readCertList("${subpath}.pem") - val result = assertIs(verifier.verify(chain)) + val result = + assertIs( + verifier.verify( + chain, + attestationApplicationIdChecker = + AttestationApplicationIdChecker.STRICT( + json.softwareEnforced.attestationApplicationId!! + ), + ) + ) assertThat(result.publicKey).isEqualTo(chain[0].publicKey) assertThat(result.challenge).isEqualTo(json.attestationChallenge) assertThat(result.securityLevel).isEqualTo(json.attestationSecurityLevel) @@ -173,6 +183,25 @@ class VerifierTest { ) } + @Test + fun verify_attestationApplicationIdCheckerReturnsFalse_returnsAttestationApplicationIdMismatch() { + val chain = readCertList("blueline/sdk28/TEE_EC_NONE.pem") + val checker = + AttestationApplicationIdChecker.STRICT( + AttestationApplicationId( + packages = setOf(AttestationPackageInfo("com.wrong.package", BigInteger.ONE)), + signatures = emptySet(), + ) + ) + + val result = + assertIs( + verifier.verify(chain, attestationApplicationIdChecker = checker) + ) + + assertThat(result.config).isEqualTo("STRICT") + } + @Test fun verifyAsync_unexpectedRootKey_returnsPathValidationFailure() = runBlocking { val result = @@ -332,7 +361,7 @@ class VerifierTest { this, CertLists.wrongTrustAnchor, ChallengeMatcher(ByteString.copyFromUtf8("challenge")), - logHook, + log = logHook, ) .await() )