Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 112 additions & 51 deletions src/main/kotlin/Extension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import co.nstant.`in`.cbor.CborEncoder
import co.nstant.`in`.cbor.CborException
import co.nstant.`in`.cbor.model.DataItem
import co.nstant.`in`.cbor.model.MajorType
import co.nstant.`in`.cbor.model.Map
import co.nstant.`in`.cbor.model.Map as CborModelMap
import co.nstant.`in`.cbor.model.NegativeInteger
import co.nstant.`in`.cbor.model.SimpleValue
import co.nstant.`in`.cbor.model.SimpleValueType
Expand Down Expand Up @@ -68,7 +68,7 @@ data class ProvisioningInfoMap(
val certificatesIssued: Int,
) {
fun cborEncode(): ByteArray {
val map = Map()
val map = CborModelMap()
map.put(UnsignedInteger(1L), certificatesIssued.asDataItem())
return cborEncode(map)
}
Expand All @@ -93,7 +93,7 @@ data class ProvisioningInfoMap(
throw IllegalArgumentException(e)
}

private fun from(seq: Map): ProvisioningInfoMap {
private fun from(seq: CborModelMap): ProvisioningInfoMap {
require(seq.keys.size >= 1)
return ProvisioningInfoMap(
certificatesIssued = seq.get(UnsignedInteger(1L)).asInteger(),
Expand Down Expand Up @@ -413,6 +413,33 @@ data class AuthorizationList(
.let { DERSequence(it.toTypedArray()) }

internal companion object {

private class ASN1Converter(
private val objects: Map<KeyMintTag, ASN1Encodable>,
private val logFn: (String) -> Unit,
) {
fun <T> parse(tag: KeyMintTag, transform: (ASN1Encodable) -> T): T? {
return try {
objects[tag]?.let(transform)
} catch (e: ExtensionParsingException) {
logFn("Exception when parsing ${tag.name.lowercase()}: ${e.message}")
null
}
}

fun parseInt(tag: KeyMintTag) = parse(tag) { it.toInt() }

fun parseIntSet(tag: KeyMintTag) =
parse(tag) { it.toSet<ASN1Integer>().map { innerIt -> innerIt.value }.toSet() }

fun parseStr(tag: KeyMintTag) = parse(tag) { it.toStr() }

fun parseByteString(tag: KeyMintTag) = parse(tag) { it.toByteString() }

fun parsePatchLevel(tag: KeyMintTag, partition: String) =
parse(tag) { it.toPatchLevel(partition, logFn) }
}

fun from(seq: ASN1Sequence, logFn: (String) -> Unit = { _ -> }): AuthorizationList {
val objects =
seq.associate {
Expand All @@ -437,48 +464,49 @@ data class AuthorizationList(
logFn("AuthorizationList tags should appear in ascending order")
}

val converter = ASN1Converter(objects, logFn)
return AuthorizationList(
purposes = objects[KeyMintTag.PURPOSE]?.toSet<ASN1Integer>()?.map { it.value }?.toSet(),
algorithms = objects[KeyMintTag.ALGORITHM]?.toInt(),
keySize = objects[KeyMintTag.KEY_SIZE]?.toInt(),
blockModes =
objects[KeyMintTag.BLOCK_MODE]?.toSet<ASN1Integer>()?.map { it.value }?.toSet(),
digests = objects[KeyMintTag.DIGEST]?.toSet<ASN1Integer>()?.map { it.value }?.toSet(),
paddings = objects[KeyMintTag.PADDING]?.toSet<ASN1Integer>()?.map { it.value }?.toSet(),
ecCurve = objects[KeyMintTag.EC_CURVE]?.toInt(),
rsaPublicExponent = objects[KeyMintTag.RSA_PUBLIC_EXPONENT]?.toInt(),
rsaOaepMgfDigests =
objects[KeyMintTag.RSA_OAEP_MGF_DIGEST]?.toSet<ASN1Integer>()?.map { it.value }?.toSet(),
activeDateTime = objects[KeyMintTag.ACTIVE_DATE_TIME]?.toInt(),
originationExpireDateTime = objects[KeyMintTag.ORIGINATION_EXPIRE_DATE_TIME]?.toInt(),
usageExpireDateTime = objects[KeyMintTag.USAGE_EXPIRE_DATE_TIME]?.toInt(),
purposes = converter.parseIntSet(KeyMintTag.PURPOSE),
algorithms = converter.parseInt(KeyMintTag.ALGORITHM),
keySize = converter.parseInt(KeyMintTag.KEY_SIZE),
blockModes = converter.parseIntSet(KeyMintTag.BLOCK_MODE),
digests = converter.parseIntSet(KeyMintTag.DIGEST),
paddings = converter.parseIntSet(KeyMintTag.PADDING),
ecCurve = converter.parseInt(KeyMintTag.EC_CURVE),
rsaPublicExponent = converter.parseInt(KeyMintTag.RSA_PUBLIC_EXPONENT),
rsaOaepMgfDigests = converter.parseIntSet(KeyMintTag.RSA_OAEP_MGF_DIGEST),
activeDateTime = converter.parseInt(KeyMintTag.ACTIVE_DATE_TIME),
originationExpireDateTime = converter.parseInt(KeyMintTag.ORIGINATION_EXPIRE_DATE_TIME),
usageExpireDateTime = converter.parseInt(KeyMintTag.USAGE_EXPIRE_DATE_TIME),
noAuthRequired = if (objects.containsKey(KeyMintTag.NO_AUTH_REQUIRED)) true else null,
userAuthType = objects[KeyMintTag.USER_AUTH_TYPE]?.toInt(),
authTimeout = objects[KeyMintTag.AUTH_TIMEOUT]?.toInt(),
userAuthType = converter.parseInt(KeyMintTag.USER_AUTH_TYPE),
authTimeout = converter.parseInt(KeyMintTag.AUTH_TIMEOUT),
trustedUserPresenceRequired =
if (objects.containsKey(KeyMintTag.TRUSTED_USER_PRESENCE_REQUIRED)) true else null,
unlockedDeviceRequired =
if (objects.containsKey(KeyMintTag.UNLOCKED_DEVICE_REQUIRED)) true else null,
creationDateTime = objects[KeyMintTag.CREATION_DATE_TIME]?.toInt(),
origin = objects[KeyMintTag.ORIGIN]?.toOrigin(),
creationDateTime = converter.parseInt(KeyMintTag.CREATION_DATE_TIME),
origin = converter.parse(KeyMintTag.ORIGIN) { it.toOrigin() },
rollbackResistant = if (objects.containsKey(KeyMintTag.ROLLBACK_RESISTANT)) true else null,
rootOfTrust = objects[KeyMintTag.ROOT_OF_TRUST]?.toRootOfTrust(),
osVersion = objects[KeyMintTag.OS_VERSION]?.toInt(),
osPatchLevel = objects[KeyMintTag.OS_PATCH_LEVEL]?.toPatchLevel("OS", logFn),
rootOfTrust = converter.parse(KeyMintTag.ROOT_OF_TRUST) { it.toRootOfTrust() },
osVersion = converter.parseInt(KeyMintTag.OS_VERSION),
osPatchLevel = converter.parsePatchLevel(KeyMintTag.OS_PATCH_LEVEL, "OS"),
attestationApplicationId =
objects[KeyMintTag.ATTESTATION_APPLICATION_ID]?.toAttestationApplicationId(),
attestationIdBrand = objects[KeyMintTag.ATTESTATION_ID_BRAND]?.toStr(),
attestationIdDevice = objects[KeyMintTag.ATTESTATION_ID_DEVICE]?.toStr(),
attestationIdProduct = objects[KeyMintTag.ATTESTATION_ID_PRODUCT]?.toStr(),
attestationIdSerial = objects[KeyMintTag.ATTESTATION_ID_SERIAL]?.toStr(),
attestationIdImei = objects[KeyMintTag.ATTESTATION_ID_IMEI]?.toStr(),
attestationIdMeid = objects[KeyMintTag.ATTESTATION_ID_MEID]?.toStr(),
attestationIdManufacturer = objects[KeyMintTag.ATTESTATION_ID_MANUFACTURER]?.toStr(),
attestationIdModel = objects[KeyMintTag.ATTESTATION_ID_MODEL]?.toStr(),
vendorPatchLevel = objects[KeyMintTag.VENDOR_PATCH_LEVEL]?.toPatchLevel("vendor", logFn),
bootPatchLevel = objects[KeyMintTag.BOOT_PATCH_LEVEL]?.toPatchLevel("boot", logFn),
attestationIdSecondImei = objects[KeyMintTag.ATTESTATION_ID_SECOND_IMEI]?.toStr(),
moduleHash = objects[KeyMintTag.MODULE_HASH]?.toByteString(),
converter.parse(KeyMintTag.ATTESTATION_APPLICATION_ID) {
it.toAttestationApplicationId()
},
attestationIdBrand = converter.parseStr(KeyMintTag.ATTESTATION_ID_BRAND),
attestationIdDevice = converter.parseStr(KeyMintTag.ATTESTATION_ID_DEVICE),
attestationIdProduct = converter.parseStr(KeyMintTag.ATTESTATION_ID_PRODUCT),
attestationIdSerial = converter.parseStr(KeyMintTag.ATTESTATION_ID_SERIAL),
attestationIdImei = converter.parseStr(KeyMintTag.ATTESTATION_ID_IMEI),
attestationIdMeid = converter.parseStr(KeyMintTag.ATTESTATION_ID_MEID),
attestationIdManufacturer = converter.parseStr(KeyMintTag.ATTESTATION_ID_MANUFACTURER),
attestationIdModel = converter.parseStr(KeyMintTag.ATTESTATION_ID_MODEL),
vendorPatchLevel = converter.parsePatchLevel(KeyMintTag.VENDOR_PATCH_LEVEL, "vendor"),
bootPatchLevel = converter.parsePatchLevel(KeyMintTag.BOOT_PATCH_LEVEL, "boot"),
attestationIdSecondImei = converter.parseStr(KeyMintTag.ATTESTATION_ID_SECOND_IMEI),
moduleHash = converter.parseByteString(KeyMintTag.MODULE_HASH),
)
}
}
Expand Down Expand Up @@ -535,6 +563,7 @@ data class PatchLevel(val yearMonth: YearMonth, val version: Int? = null) {
* https://source.android.com/docs/security/features/keystore/attestation#attestationapplicationid-schema
*/
@Immutable
@RequiresApi(24)
data class AttestationApplicationId(
@SuppressWarnings("Immutable") val packages: Set<AttestationPackageInfo>,
@SuppressWarnings("Immutable") val signatures: Set<ByteString>,
Expand Down Expand Up @@ -566,6 +595,7 @@ data class AttestationApplicationId(
* @see
* https://source.android.com/docs/security/features/keystore/attestation#attestationapplicationid-schema
*/
@RequiresApi(24)
data class AttestationPackageInfo(val name: String, val version: BigInteger) {
fun toAsn1() =
buildList {
Expand Down Expand Up @@ -593,6 +623,7 @@ data class AttestationPackageInfo(val name: String, val version: BigInteger) {
* @see https://source.android.com/docs/security/features/keystore/attestation#rootoftrust-fields
*/
@Immutable
@RequiresApi(24)
data class RootOfTrust(
val verifiedBootKey: ByteString,
val deviceLocked: Boolean,
Expand Down Expand Up @@ -644,40 +675,57 @@ enum class VerifiedBootState(val value: Int) {
}
}

@RequiresApi(24)
private fun ASN1Encodable.toAttestationApplicationId(): AttestationApplicationId {
require(this is ASN1OctetString) {
"Object must be an ASN1OctetString, was ${this::class.simpleName}"
if (this !is ASN1OctetString) {
throw ExtensionParsingException(
"Object must be an ASN1OctetString, was ${this::class.simpleName}"
)
}
return AttestationApplicationId.from(ASN1Sequence.getInstance(this.octets))
}

@RequiresApi(24)
private fun ASN1Encodable.toAuthorizationList(logFn: (String) -> Unit): AuthorizationList {
check(this is ASN1Sequence) { "Object must be an ASN1Sequence, was ${this::class.simpleName}" }
if (this !is ASN1Sequence) {
throw ExtensionParsingException("Object must be an ASN1Sequence, was ${this::class.simpleName}")
}
return AuthorizationList.from(this, logFn)
}

@RequiresApi(24)
private fun ASN1Encodable.toBoolean(): Boolean {
check(this is ASN1Boolean) { "Must be an ASN1Boolean, was ${this::class.simpleName}" }
if (this !is ASN1Boolean) {
throw ExtensionParsingException("Must be an ASN1Boolean, was ${this::class.simpleName}")
}
return this.isTrue
}

@RequiresApi(24)
private fun ASN1Encodable.toByteArray(): ByteArray {
check(this is ASN1OctetString) { "Must be an ASN1OctetString, was ${this::class.simpleName}" }
if (this !is ASN1OctetString) {
throw ExtensionParsingException("Must be an ASN1OctetString, was ${this::class.simpleName}")
}
return this.octets
}

private fun ASN1Encodable.toByteBuffer() = ByteBuffer.wrap(this.toByteArray())
@RequiresApi(24) private fun ASN1Encodable.toByteBuffer() = ByteBuffer.wrap(this.toByteArray())

private fun ASN1Encodable.toByteString() = ByteString.copyFrom(this.toByteArray())
@RequiresApi(24) private fun ASN1Encodable.toByteString() = ByteString.copyFrom(this.toByteArray())

@RequiresApi(24)
private fun ASN1Encodable.toEnumerated(): ASN1Enumerated {
check(this is ASN1Enumerated) { "Must be an ASN1Enumerated, was ${this::class.simpleName}" }
if (this !is ASN1Enumerated) {
throw ExtensionParsingException("Must be an ASN1Enumerated, was ${this::class.simpleName}")
}
return this
}

@RequiresApi(24)
private fun ASN1Encodable.toInt(): BigInteger {
check(this is ASN1Integer) { "Must be an ASN1Integer, was ${this::class.simpleName}" }
if (this !is ASN1Integer) {
throw ExtensionParsingException("Must be an ASN1Integer, was ${this::class.simpleName}")
}
return this.value
}

Expand All @@ -686,28 +734,41 @@ private fun ASN1Encodable.toPatchLevel(
logFn: (String) -> Unit = { _ -> },
): PatchLevel? = PatchLevel.from(this, partitionName, logFn)

@RequiresApi(24)
private fun ASN1Encodable.toRootOfTrust(): RootOfTrust {
check(this is ASN1Sequence) { "Object must be an ASN1Sequence, was ${this::class.simpleName}" }
if (this !is ASN1Sequence) {
throw ExtensionParsingException("Object must be an ASN1Sequence, was ${this::class.simpleName}")
}
return RootOfTrust.from(this)
}

@RequiresApi(24)
private fun ASN1Encodable.toSecurityLevel(): SecurityLevel =
SecurityLevel.values().firstOrNull { it.value.toBigInteger() == this.toEnumerated().value }
?: throw IllegalStateException("unknown value: ${this.toEnumerated().value}")

@RequiresApi(24)
private fun ASN1Encodable.toOrigin(): Origin =
Origin.values().firstOrNull { it.value.toBigInteger() == this.toInt() }
?: throw IllegalStateException("unknown value: ${this.toInt()}")

@RequiresApi(24)
private inline fun <reified T> ASN1Encodable.toSet(): Set<T> {
check(this is ASN1Set) { "Object must be an ASN1Set, was ${this::class.simpleName}" }
if (this !is ASN1Set) {
throw ExtensionParsingException("Object must be an ASN1Set, was ${this::class.simpleName}")
}
return this.map {
check(it is T) { "Object must be a ${T::class.simpleName}, was ${this::class.simpleName}" }
if (it !is T) {
throw ExtensionParsingException(
"Object must be a ${T::class.simpleName}, was ${this::class.simpleName}"
)
}
it
}
.toSet()
}

@RequiresApi(24)
private fun ASN1Encodable.toStr() =
UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
Expand Down Expand Up @@ -763,12 +824,12 @@ fun Int.asDataItem() =

fun String.asDataItem() = UnicodeString(this)

private fun DataItem.asMap(): Map {
private fun DataItem.asMap(): CborModelMap {
if (this.majorType != MajorType.MAP) {
throw CborException("Expected a map, got ${this.majorType.name}")
}
@Suppress("UNCHECKED_CAST")
return this as Map
return this as CborModelMap
}

fun DataItem.asUnicodeString(): UnicodeString {
Expand Down
100 changes: 100 additions & 0 deletions src/main/kotlin/ExtensionConstraintConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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<Origin> = ValidationLevel.STRICT(Origin.GENERATED),
val securityLevel: SecurityLevelValidationLevel = SecurityLevelValidationLevel.STRICT(),
val rootOfTrust: ValidationLevel<RootOfTrust> = ValidationLevel.STRICT(null),
)

/**
* 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<out T> {
@Immutable(containerOf = ["T"]) data class STRICT<T>(val expectedVal: T?) : ValidationLevel<T>

@Immutable data object IGNORE : ValidationLevel<Nothing>
}

/**
* 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

/**
* Checks that the attestationSecurityLevel is equal to the keyMintSecurityLevel, regardless of
* security level
*/
@Immutable data object MATCH : 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
}

/** Evaluates whether the [extension] is satisfied by the [ValidationLevel]. */
fun <T> ValidationLevel<T>.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.MATCH -> securityLevelsMatch
is SecurityLevelValidationLevel.EXISTS -> true
}
}
Loading