diff --git a/src/main/kotlin/Extension.kt b/src/main/kotlin/Extension.kt index 0bc51ae..d36752e 100644 --- a/src/main/kotlin/Extension.kt +++ b/src/main/kotlin/Extension.kt @@ -333,6 +333,7 @@ data class AuthorizationList( val bootPatchLevel: PatchLevel? = null, val attestationIdSecondImei: String? = null, val moduleHash: ByteString? = null, + internal val areTagsOrdered: Boolean = true, ) { /** * Converts the representation of an [AuthorizationList] to an ASN.1 sequence. @@ -460,7 +461,8 @@ data class AuthorizationList( * 2. within each class of tags, the elements or alternatives shall appear in ascending order * of their tag numbers. */ - if (!objects.keys.zipWithNext().all { (lhs, rhs) -> rhs > lhs }) { + val areTagsOrdered = (objects.keys.zipWithNext().all { (lhs, rhs) -> rhs > lhs }) + if (!areTagsOrdered) { logFn("AuthorizationList tags should appear in ascending order") } @@ -507,6 +509,7 @@ data class AuthorizationList( bootPatchLevel = converter.parsePatchLevel(KeyMintTag.BOOT_PATCH_LEVEL, "boot"), attestationIdSecondImei = converter.parseStr(KeyMintTag.ATTESTATION_ID_SECOND_IMEI), moduleHash = converter.parseByteString(KeyMintTag.MODULE_HASH), + areTagsOrdered = areTagsOrdered, ) } } diff --git a/src/main/kotlin/ExtensionConstraintConfig.kt b/src/main/kotlin/ExtensionConstraintConfig.kt new file mode 100644 index 0000000..fc3a985 --- /dev/null +++ b/src/main/kotlin/ExtensionConstraintConfig.kt @@ -0,0 +1,133 @@ +/* + * 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 attenstation 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: SecurityLevelValidationLevel = SecurityLevelValidationLevel.STRICT(), + val rootOfTrust: ValidationLevel = ValidationLevel.STRICT(null), + val authorizationListOrdering: AuthorizationListOrdering = AuthorizationListOrdering.IGNORE, +) + +/** + * Configuration for validating a single extension in an Android attenstation certificate. + * + * @param expectedVal The expected value of the extension. If null, the extension is checked for + * existence but not equality. + */ +@Immutable(containerOf = ["T"]) +sealed interface ValidationLevel { + @Immutable(containerOf = ["T"]) data class STRICT(val expectedVal: T?) : ValidationLevel + + @Immutable data object IGNORE : ValidationLevel +} + +/** + * Configuration for validating the attestationSecurityLevel and keyMintSecurityLevel fields in an + * Android attenstation certificate. + */ +@Immutable +sealed interface SecurityLevelValidationLevel { + /** + * Checks that the attestationSecurityLevel is both (1) one of {TRUSTED_ENVIRONMENT, STRONG_BOX} + * and (2) equal to the keyMintSecurityLevel. + * + * If expectedVal is provided, checks that both the attestationSecurityLevel and + * keyMintSecurityLevel are equal to the expected value. + */ + @Immutable + data class STRICT(val expectedVal: SecurityLevel? = null) : SecurityLevelValidationLevel { + init { + require(expectedVal != SecurityLevel.SOFTWARE) { + "STRICT validation level cannot be used with SOFTWARE security level." + } + } + } + + /** + * Checks that the attestationSecurityLevel is equal to the keyMintSecurityLevel, regardless of + * security level + */ + @Immutable data object CONSISTENT : SecurityLevelValidationLevel + + /** + * Checks that attestationSecurityLevel and keyMintSecurityLevel both exist and are correctly + * formed. If they are unequal, [Verifier.verify] will return the lower securityLevel. + */ + @Immutable data object EXISTS : SecurityLevelValidationLevel +} + +/** + * Configuration for validating the ordering of the extensions in the AuthorizationList sequence in + * an Android attenstation certificate. + */ +enum class AuthorizationListOrdering { + + /** + * Checks that the extensions in the AuthorizationList sequence appear in the order specified by + * https://source.android.com/docs/security/features/keystore/attestation#schema. + */ + STRICT, + + /** Allows the extensions in the AuthorizationList sequence appear in any order. */ + IGNORE, +} + +/** Evaluates whether the [extension] is satisfied by the [ValidationLevel]. */ +fun ValidationLevel.isSatisfiedBy(extension: T?): Boolean = + when (this) { + is ValidationLevel.STRICT -> + if (expectedVal == null) extension != null else extension == expectedVal + is ValidationLevel.IGNORE -> true + } + +/** Evaluates whether the [keyDescription] is satisfied by the [SecurityLevelValidationLevel]. */ +@RequiresApi(24) +fun SecurityLevelValidationLevel.isSatisfiedBy(keyDescription: KeyDescription): Boolean { + val securityLevelsMatch = + keyDescription.attestationSecurityLevel == keyDescription.keyMintSecurityLevel + + return when (this) { + is SecurityLevelValidationLevel.STRICT -> { + val securityLevelIsExpected = + if (this.expectedVal != null) keyDescription.attestationSecurityLevel == this.expectedVal + else keyDescription.attestationSecurityLevel != SecurityLevel.SOFTWARE + securityLevelsMatch && securityLevelIsExpected + } + is SecurityLevelValidationLevel.CONSISTENT -> securityLevelsMatch + is SecurityLevelValidationLevel.EXISTS -> true + } +} + +/** Evaluates whether the [keyDescription] is satisfied by the [AuthorizationListOrdering]. */ +@RequiresApi(24) +fun AuthorizationListOrdering.isSatisfiedBy(keyDescription: KeyDescription): Boolean = + when (this) { + AuthorizationListOrdering.STRICT -> + keyDescription.softwareEnforced.areTagsOrdered && + keyDescription.hardwareEnforced.areTagsOrdered + AuthorizationListOrdering.IGNORE -> true + } diff --git a/src/main/kotlin/KeyAttestationReason.kt b/src/main/kotlin/KeyAttestationReason.kt index 68003fa..3b4428e 100644 --- a/src/main/kotlin/KeyAttestationReason.kt +++ b/src/main/kotlin/KeyAttestationReason.kt @@ -33,15 +33,22 @@ 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, + // The authorization list ordering violated the constraint provided in + // [ExtensionConstraintConfig]. + AUTHORIZATION_LIST_ORDERING_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 b7a81ba..e6fead5 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()) @@ -284,6 +287,14 @@ open class Verifier( return VerificationResult.ExtensionParsingFailure(ExtensionParsingException(e.toString())) } log?.logKeyDescription(keyDescription) + + if (!extensionConstraintConfig.authorizationListOrdering.isSatisfiedBy(keyDescription)) { + return VerificationResult.ExtensionConstraintViolation( + "Authorization list ordering violates constraint: config=${extensionConstraintConfig.authorizationListOrdering}", + KeyAttestationReason.AUTHORIZATION_LIST_ORDERING_CONSTRAINT_VIOLATION, + ) + } + if (challengeChecker != null) { val checkResult = challengeChecker.checkChallenge(keyDescription.attestationChallenge).await() if (!checkResult) { @@ -291,36 +302,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..ad87f3f --- /dev/null +++ b/src/test/kotlin/ExtensionConstraintConfigTest.kt @@ -0,0 +1,159 @@ +/* + * 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.android.keyattestation.verifier.testing.TestUtils.readCertPath +import com.google.common.truth.Truth.assertThat +import com.google.protobuf.ByteString +import kotlin.test.assertFailsWith +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_strictWithNull_allowsAnyValue() { + val level = ValidationLevel.STRICT(null) + + assertThat(level.isSatisfiedBy("foo")).isTrue() + assertThat(level.isSatisfiedBy(null)).isFalse() + } + + @Test + fun ValidationLevelIsSatisfiedBy_ignore() { + 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_strictWithSoftware_throwsException() { + assertFailsWith { + SecurityLevelValidationLevel.STRICT(SecurityLevel.SOFTWARE) + } + } + + @Test + fun SecurityLevelValidationLevelIsSatisfiedBy_strictWithNull_allowsOnlyNonSoftwareLevels() { + val level = SecurityLevelValidationLevel.STRICT(null) + + assertThat(level.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithTeeSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithSoftwareSecurityLevels)).isFalse() + assertThat(level.isSatisfiedBy(keyDescriptionWithMismatchedSecurityLevels)).isFalse() + } + + @Test + fun SecurityLevelValidationLevelIsSatisfiedBy_match_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() + } + + @Test + fun SecurityLevelValidationLevelIsSatisfiedBy_exists_allowsLevels() { + val level = SecurityLevelValidationLevel.EXISTS + + assertThat(level.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithTeeSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithSoftwareSecurityLevels)).isTrue() + assertThat(level.isSatisfiedBy(keyDescriptionWithMismatchedSecurityLevels)).isTrue() + } + + @Test + fun AuthorizationListOrderingIsSatisfiedBy_strict_allowsOnlyWhenOrdered() { + val ordering = AuthorizationListOrdering.STRICT + + assertThat(ordering.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat( + ordering.isSatisfiedBy( + KeyDescription.parseFrom( + readCertPath("invalid/tags_not_in_ascending_order.pem").leafCert() + ) + ) + ) + .isFalse() + } + + @Test + fun AuthorizationListOrderingIsSatisfiedBy_ignore_allowsAnyOrder() { + val ordering = AuthorizationListOrdering.IGNORE + + assertThat(ordering.isSatisfiedBy(keyDescriptionWithStrongBoxSecurityLevels)).isTrue() + assertThat( + ordering.isSatisfiedBy( + KeyDescription.parseFrom( + readCertPath("invalid/tags_not_in_ascending_order.pem").leafCert() + ) + ) + ) + .isTrue() + } +} diff --git a/src/test/kotlin/VerifierTest.kt b/src/test/kotlin/VerifierTest.kt index 88bd475..cf91ac9 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 = SecurityLevelValidationLevel.EXISTS), + ) + val result = + assertIs(verifier.verify(CertLists.mismatchedSecurityLevels)) + assertThat(result.securityLevel).isEqualTo(SecurityLevel.SOFTWARE) } @Test