From 9066c0a003225e776b93ba5906d46c45904173de Mon Sep 17 00:00:00 2001 From: Suzanna Jiwani Date: Fri, 6 Feb 2026 09:30:07 -0800 Subject: [PATCH] Define mechanism for configurable extension checking PiperOrigin-RevId: 866495147 --- src/main/kotlin/ExtensionConstraintConfig.kt | 117 ++++++++++++++++++ src/main/kotlin/KeyAttestationReason.kt | 22 ++-- src/main/kotlin/Verifier.kt | 41 +++--- .../kotlin/ExtensionConstraintConfigTest.kt | 110 ++++++++++++++++ src/test/kotlin/VerifierTest.kt | 20 ++- 5 files changed, 280 insertions(+), 30 deletions(-) create mode 100644 src/main/kotlin/ExtensionConstraintConfig.kt create mode 100644 src/test/kotlin/ExtensionConstraintConfigTest.kt diff --git a/src/main/kotlin/ExtensionConstraintConfig.kt b/src/main/kotlin/ExtensionConstraintConfig.kt new file mode 100644 index 0000000..6b68f07 --- /dev/null +++ b/src/main/kotlin/ExtensionConstraintConfig.kt @@ -0,0 +1,117 @@ +/* + * 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 androidx.annotation.RequiresApi +import com.google.errorprone.annotations.Immutable +import com.google.errorprone.annotations.ThreadSafe + +/** + * Configuration for validating the extensions in an Android attestation certificate, as described + * at https://source.android.com/docs/security/features/keystore/attestation. + */ +@ThreadSafe +data class ExtensionConstraintConfig( + val keyOrigin: ValidationLevel = ValidationLevel.STRICT(Origin.GENERATED), + val securityLevel: ValidationLevel = + SecurityLevelValidationLevel.STRICT(SecurityLevel.TRUSTED_ENVIRONMENT), + val rootOfTrust: ValidationLevel = ValidationLevel.NOT_NULL, +) + +/** Configuration for validating a single extension in an Android attestation certificate. */ +@Immutable(containerOf = ["T"]) +sealed interface ValidationLevel { + /** Evaluates whether the [extension] is satisfied by this [ValidationLevel]. */ + fun isSatisfiedBy(extension: Any?): Boolean + + /** + * Checks that the extension exists and matches the expected value. + * + * @param expectedVal The expected value of the extension. + */ + @Immutable(containerOf = ["T"]) + data class STRICT(val expectedVal: T) : ValidationLevel { + override fun isSatisfiedBy(extension: Any?): Boolean = extension == expectedVal + } + + /* Check that the extension exists. */ + @Immutable + data object NOT_NULL : ValidationLevel { + override fun isSatisfiedBy(extension: Any?): Boolean = extension != null + } + + @Immutable + data object IGNORE : ValidationLevel { + override fun isSatisfiedBy(extension: Any?): Boolean = true + } +} + +/** + * Configuration for validating the attestationSecurityLevel and keyMintSecurityLevel fields in an + * Android attestation certificate. + */ +@Immutable +sealed class SecurityLevelValidationLevel : ValidationLevel { + @RequiresApi(24) + fun areSecurityLevelsMatching(keyDescription: KeyDescription): Boolean { + return keyDescription.attestationSecurityLevel == keyDescription.keyMintSecurityLevel + } + + /** + * Checks that both the attestationSecurityLevel and keyMintSecurityLevel match the expected + * value. + * + * @param expectedVal The expected value of the security level. + */ + @Immutable + data class STRICT(val expectedVal: SecurityLevel) : SecurityLevelValidationLevel() { + @RequiresApi(24) + override fun isSatisfiedBy(extension: Any?): Boolean { + val keyDescription = extension as? KeyDescription ?: return false + val securityLevelIsExpected = keyDescription.attestationSecurityLevel == this.expectedVal + return areSecurityLevelsMatching(keyDescription) && securityLevelIsExpected + } + } + + /** + * Checks that the attestationSecurityLevel is equal to the keyMintSecurityLevel, and that this + * security level is not [SecurityLevel.SOFTWARE]. + */ + @Immutable + data object NOT_SOFTWARE : SecurityLevelValidationLevel() { + @RequiresApi(24) + override fun isSatisfiedBy(extension: Any?): Boolean { + val keyDescription = extension as? KeyDescription ?: return false + val securityLevelIsSoftware = + keyDescription.attestationSecurityLevel == SecurityLevel.SOFTWARE + return areSecurityLevelsMatching(keyDescription) && !securityLevelIsSoftware + } + } + + /** + * Checks that the attestationSecurityLevel is equal to the keyMintSecurityLevel, regardless of + * security level. + */ + @Immutable + data object CONSISTENT : SecurityLevelValidationLevel() { + @RequiresApi(24) + override fun isSatisfiedBy(extension: Any?): Boolean { + val keyDescription = extension as? KeyDescription ?: return false + return areSecurityLevelsMatching(keyDescription) + } + } +} diff --git a/src/main/kotlin/KeyAttestationReason.kt b/src/main/kotlin/KeyAttestationReason.kt index 68003fa..1ae489c 100644 --- a/src/main/kotlin/KeyAttestationReason.kt +++ b/src/main/kotlin/KeyAttestationReason.kt @@ -33,15 +33,19 @@ enum class KeyAttestationReason : CertPathValidatorException.Reason { // extension. This likely indicates that an attacker is trying to manipulate the key and // device properties. CHAIN_EXTENDED_WITH_FAKE_ATTESTATION_EXTENSION, - // The key was not generated. The verifier cannot know that the key has always been in the - // secure environment. - KEY_ORIGIN_NOT_GENERATED, - // The attestation and the KeyMint security levels do not match. - // This likely indicates that the attestation was generated in software and so cannot be trusted. - MISMATCHED_SECURITY_LEVELS, - // The key description is missing the root of trust. - // An Android key attestation chain without a root of trust is malformed. - ROOT_OF_TRUST_MISSING, + // The origin violated the constraint provided in [ExtensionConstraintConfig]. + // Using the default config, this means the key was not generated, so the verifier cannot know + // that the key has always been in the secure environment. + KEY_ORIGIN_CONSTRAINT_VIOLATION, + // The security level violated the constraint provided in [ExtensionConstraintConfig]. + // Using the default config, this means the attestation and the KeyMint security levels do not + // match, which likely indicates that the attestation was generated in software and so cannot be + // trusted. + SECURITY_LEVEL_CONSTRAINT_VIOLATION, + // The root of trust violated the constraint provided in [ExtensionConstraintConfig]. + // Using the default config, this means the key description is missing the root of trust, and an + // Android key attestation chain without a root of trust is malformed. + ROOT_OF_TRUST_CONSTRAINT_VIOLATION, // There was an error parsing the key description and an unknown tag number was encountered. UNKNOWN_TAG_NUMBER, } diff --git a/src/main/kotlin/Verifier.kt b/src/main/kotlin/Verifier.kt index 34f6f8b..c81d221 100644 --- a/src/main/kotlin/Verifier.kt +++ b/src/main/kotlin/Verifier.kt @@ -136,10 +136,13 @@ interface VerifyRequestLog { * @param anchor a [TrustAnchor] to use for certificate path verification. */ @ThreadSafe -open class Verifier( +open class Verifier +@JvmOverloads +constructor( private val trustAnchorsSource: () -> Set, private val revokedSerialsSource: () -> Set, private val instantSource: InstantSource, + private val extensionConstraintConfig: ExtensionConstraintConfig = ExtensionConstraintConfig(), ) { init { Security.addProvider(KeyAttestationProvider()) @@ -287,36 +290,38 @@ open class Verifier( } } - if ( - keyDescription.hardwareEnforced.origin == null || - keyDescription.hardwareEnforced.origin != Origin.GENERATED - ) { + val origin = keyDescription.hardwareEnforced.origin + if (!extensionConstraintConfig.keyOrigin.isSatisfiedBy(origin)) { return VerificationResult.ExtensionConstraintViolation( - "origin != GENERATED: ${keyDescription.hardwareEnforced.origin}", - KeyAttestationReason.KEY_ORIGIN_NOT_GENERATED, + "Origin violates constraint: value=${origin}, config=${extensionConstraintConfig.keyOrigin}", + KeyAttestationReason.KEY_ORIGIN_CONSTRAINT_VIOLATION, ) } val securityLevel = - if (keyDescription.attestationSecurityLevel == keyDescription.keyMintSecurityLevel) { - keyDescription.attestationSecurityLevel + if (extensionConstraintConfig.securityLevel.isSatisfiedBy(keyDescription)) { + minOf(keyDescription.attestationSecurityLevel, keyDescription.keyMintSecurityLevel) } else { return VerificationResult.ExtensionConstraintViolation( - "attestationSecurityLevel != keyMintSecurityLevel: ${keyDescription.attestationSecurityLevel} != ${keyDescription.keyMintSecurityLevel}", - KeyAttestationReason.MISMATCHED_SECURITY_LEVELS, + "Security level violates constraint: value=${keyDescription.attestationSecurityLevel}, config=${extensionConstraintConfig.securityLevel}", + KeyAttestationReason.SECURITY_LEVEL_CONSTRAINT_VIOLATION, ) } - val rootOfTrust = - keyDescription.hardwareEnforced.rootOfTrust - ?: return VerificationResult.ExtensionConstraintViolation( - "hardwareEnforced.rootOfTrust is null", - KeyAttestationReason.ROOT_OF_TRUST_MISSING, - ) + + val rootOfTrust = keyDescription.hardwareEnforced.rootOfTrust + if (!extensionConstraintConfig.rootOfTrust.isSatisfiedBy(rootOfTrust)) { + return VerificationResult.ExtensionConstraintViolation( + "Root of trust violates constraint: value=${rootOfTrust}, config=${extensionConstraintConfig.rootOfTrust}", + KeyAttestationReason.ROOT_OF_TRUST_CONSTRAINT_VIOLATION, + ) + } + val verifiedBootState = rootOfTrust?.verifiedBootState ?: VerifiedBootState.UNVERIFIED + return VerificationResult.Success( pathValidationResult.publicKey, keyDescription.attestationChallenge, securityLevel, - rootOfTrust.verifiedBootState, + verifiedBootState, deviceInformation, DeviceIdentity.parseFrom(keyDescription), ) diff --git a/src/test/kotlin/ExtensionConstraintConfigTest.kt b/src/test/kotlin/ExtensionConstraintConfigTest.kt new file mode 100644 index 0000000..5eb9468 --- /dev/null +++ b/src/test/kotlin/ExtensionConstraintConfigTest.kt @@ -0,0 +1,110 @@ +/* + * 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 org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ExtensionConstraintConfigTest { + + private companion object { + val authorizationList = + AuthorizationList(purposes = setOf(1.toBigInteger()), algorithms = 1.toBigInteger()) + + fun createTestKeyDescription( + attestationSecurityLevel: SecurityLevel, + keyMintSecurityLevel: SecurityLevel, + ) = + KeyDescription( + attestationVersion = 1.toBigInteger(), + attestationSecurityLevel = attestationSecurityLevel, + keyMintVersion = 1.toBigInteger(), + keyMintSecurityLevel = keyMintSecurityLevel, + attestationChallenge = ByteString.empty(), + uniqueId = ByteString.empty(), + softwareEnforced = authorizationList, + hardwareEnforced = authorizationList, + ) + } + + val keyDescriptionWithStrongBoxSecurityLevels = + createTestKeyDescription(SecurityLevel.STRONG_BOX, SecurityLevel.STRONG_BOX) + val keyDescriptionWithTeeSecurityLevels = + createTestKeyDescription(SecurityLevel.TRUSTED_ENVIRONMENT, SecurityLevel.TRUSTED_ENVIRONMENT) + val keyDescriptionWithSoftwareSecurityLevels = + createTestKeyDescription(SecurityLevel.SOFTWARE, SecurityLevel.SOFTWARE) + val keyDescriptionWithMismatchedSecurityLevels = + createTestKeyDescription(SecurityLevel.STRONG_BOX, SecurityLevel.TRUSTED_ENVIRONMENT) + + @Test + fun ValidationLevelIsSatisfiedBy_strictWithExpectedValue() { + val level = ValidationLevel.STRICT("foo") + + assertThat(level.isSatisfiedBy("foo")).isTrue() + assertThat(level.isSatisfiedBy("bar")).isFalse() + assertThat(level.isSatisfiedBy(null)).isFalse() + } + + @Test + fun ValidationLevelIsSatisfiedBy_notNull_allowsAnyValue() { + val level = ValidationLevel.NOT_NULL + + assertThat(level.isSatisfiedBy("foo")).isTrue() + assertThat(level.isSatisfiedBy(null)).isFalse() + } + + @Test + fun ValidationLevelIsSatisfiedBy_ignore_allowsAnyValue() { + val level = ValidationLevel.IGNORE + + assertThat(level.isSatisfiedBy("foo")).isTrue() + assertThat(level.isSatisfiedBy(null)).isTrue() + } + + @Test + fun SecurityLevelValidationLevelIsSatisfiedBy_strictWithExpectedValue() { + val level = SecurityLevelValidationLevel.STRICT(SecurityLevel.STRONG_BOX) + + assertThat(level.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithTeeSecurityLevels)).isFalse() + assertThat(level.isSatisfiedBy(keyDescriptionWithMismatchedSecurityLevels)).isFalse() + } + + @Test + fun SecurityLevelValidationLevelIsSatisfiedBy_notSoftware_allowsAnyNonSoftwareMatchingLevels() { + val level = SecurityLevelValidationLevel.NOT_SOFTWARE + + assertThat(level.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithTeeSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithSoftwareSecurityLevels)).isFalse() + assertThat(level.isSatisfiedBy(keyDescriptionWithMismatchedSecurityLevels)).isFalse() + } + + @Test + fun SecurityLevelValidationLevelIsSatisfiedBy_consistent_allowsAnyMatchingLevels() { + val level = SecurityLevelValidationLevel.CONSISTENT + + assertThat(level.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithTeeSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithSoftwareSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithMismatchedSecurityLevels)).isFalse() + } +} diff --git a/src/test/kotlin/VerifierTest.kt b/src/test/kotlin/VerifierTest.kt index 88bd475..1004105 100644 --- a/src/test/kotlin/VerifierTest.kt +++ b/src/test/kotlin/VerifierTest.kt @@ -173,20 +173,34 @@ class VerifierTest { fun rootOfTrustMissing_givesRootOfTrustMissingReason() { val result = assertIs(verifier.verify(CertLists.missingRootOfTrust)) - assertThat(result.reason).isEqualTo(KeyAttestationReason.ROOT_OF_TRUST_MISSING) + assertThat(result.reason).isEqualTo(KeyAttestationReason.ROOT_OF_TRUST_CONSTRAINT_VIOLATION) } @Test fun keyOriginNotGenerated_throwsCertPathValidatorException() { val result = assertIs(verifier.verify(CertLists.importedOrigin)) - assertThat(result.reason).isEqualTo(KeyAttestationReason.KEY_ORIGIN_NOT_GENERATED) + assertThat(result.reason).isEqualTo(KeyAttestationReason.KEY_ORIGIN_CONSTRAINT_VIOLATION) } @Test fun mismatchedSecurityLevels_throwsCertPathValidatorException() { val result = assertIs(verifier.verify(CertLists.mismatchedSecurityLevels)) - assertThat(result.reason).isEqualTo(KeyAttestationReason.MISMATCHED_SECURITY_LEVELS) + assertThat(result.reason).isEqualTo(KeyAttestationReason.SECURITY_LEVEL_CONSTRAINT_VIOLATION) + } + + @Test + fun mismatchedSecurityLevels_customConfig_succeeds() { + val verifier = + Verifier( + { prodAnchors + TrustAnchor(Certs.root, null) }, + { setOf() }, + { FakeCalendar.DEFAULT.now() }, + ExtensionConstraintConfig(securityLevel = ValidationLevel.NOT_NULL), + ) + val result = + assertIs(verifier.verify(CertLists.mismatchedSecurityLevels)) + assertThat(result.securityLevel).isEqualTo(SecurityLevel.SOFTWARE) } @Test