Skip to content

Commit a770f57

Browse files
carmenyhcopybara-github
authored andcommitted
Add KeyAttestationReason to CertPathValidatorException.
This will allow callers to distinguish between different errors in the chain without string matching. This requires explicitly stating the required API for each method which diretly or indirectly uses KeyAttestationReason, but shouldn't be an actual change in requirements since the other reasons also require API 24 when used on Android. This also moves the key attestation chain format checks to be exclusively in the validator instead of some of them being duplicated in the KeyAttestationCertPath constructor. The validator checks could previously never be executed and the CertPathValidatorException allows for more structured information returned on exception. PiperOrigin-RevId: 805800611
1 parent 6880dd2 commit a770f57

19 files changed

+595
-234
lines changed

build.gradle.kts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ plugins {
2020
id("org.jetbrains.kotlin.jvm") version "2.2.0"
2121
}
2222

23-
repositories { mavenCentral() }
23+
repositories {
24+
mavenCentral()
25+
google()
26+
}
2427

2528
dependencies {
29+
implementation("androidx.annotation:annotation:1.9.1")
2630
implementation("co.nstant.in:cbor:0.9")
2731
implementation("com.google.code.gson:gson:2.11.0")
2832
implementation("com.google.guava:guava:33.3.1-android")
@@ -97,6 +101,4 @@ val generateSources by
97101

98102
sourceSets { main { kotlin.srcDir(generateSources) } }
99103

100-
tasks.named("compileKotlin").configure {
101-
dependsOn("generateSources")
102-
}
104+
tasks.named("compileKotlin").configure { dependsOn("generateSources") }

src/main/kotlin/Extension.kt

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.android.keyattestation.verifier
1818

19+
import androidx.annotation.RequiresApi
1920
import co.nstant.`in`.cbor.CborDecoder
2021
import co.nstant.`in`.cbor.CborEncoder
2122
import co.nstant.`in`.cbor.CborException
@@ -57,6 +58,11 @@ import org.bouncycastle.asn1.DERSet
5758
import org.bouncycastle.asn1.DERTaggedObject
5859
import org.bouncycastle.asn1.x509.Extension
5960

61+
@Immutable
62+
@RequiresApi(24)
63+
data class ExtensionParsingException(val msg: String, val reason: KeyAttestationReason? = null) :
64+
Exception(msg)
65+
6066
@Immutable
6167
data class ProvisioningInfoMap(
6268
val certificatesIssued: Int,
@@ -96,15 +102,16 @@ data class ProvisioningInfoMap(
96102
}
97103
}
98104

105+
@Immutable
99106
data class DeviceIdentity(
100-
val brand: String?,
101-
val device: String?,
102-
val product: String?,
103-
val serialNumber: String?,
104-
val imeis: Set<String>,
105-
val meid: String?,
106-
val manufacturer: String?,
107-
val model: String?,
107+
val brand: String? = null,
108+
val device: String? = null,
109+
val product: String? = null,
110+
val serialNumber: String? = null,
111+
@SuppressWarnings("Immutable") val imeis: Set<String> = emptySet(),
112+
val meid: String? = null,
113+
val manufacturer: String? = null,
114+
val model: String? = null,
108115
) {
109116
companion object {
110117
@JvmStatic
@@ -126,6 +133,7 @@ data class DeviceIdentity(
126133
}
127134

128135
@Immutable
136+
@RequiresApi(24)
129137
data class KeyDescription(
130138
val attestationVersion: BigInteger,
131139
val attestationSecurityLevel: SecurityLevel,
@@ -160,23 +168,25 @@ data class KeyDescription(
160168
@JvmField val OID = ASN1ObjectIdentifier("1.3.6.1.4.1.11129.2.1.17")
161169

162170
@JvmStatic
163-
fun parseFrom(cert: X509Certificate) =
171+
@JvmOverloads
172+
fun parseFrom(cert: X509Certificate, logFn: (String) -> Unit = {}) =
164173
cert
165174
.getExtensionValue(OID.id)
166175
.let { ASN1OctetString.getInstance(it).octets }
167-
.let { parseFrom(it) }
176+
.let { parseFrom(it, logFn) }
168177

169178
@JvmStatic
170-
fun parseFrom(bytes: ByteArray) =
179+
@JvmOverloads
180+
fun parseFrom(bytes: ByteArray, logFn: (String) -> Unit = {}) =
171181
try {
172-
from(ASN1Sequence.getInstance(bytes))
182+
from(ASN1Sequence.getInstance(bytes), logFn)
173183
} catch (e: NullPointerException) {
174184
// Workaround for a NPE in BouncyCastle.
175185
// https://github.com/bcgit/bc-java/blob/228211ecb973fe87fdd0fc4ab16ba0446ec1a29c/core/src/main/java/org/bouncycastle/asn1/ASN1UniversalType.java#L24
176186
throw IllegalArgumentException(e)
177187
}
178188

179-
private fun from(seq: ASN1Sequence): KeyDescription {
189+
private fun from(seq: ASN1Sequence, logFn: (String) -> Unit = {}): KeyDescription {
180190
require(seq.size() == 8)
181191
return KeyDescription(
182192
attestationVersion = seq.getObjectAt(0).toInt(),
@@ -185,8 +195,8 @@ data class KeyDescription(
185195
keyMintSecurityLevel = seq.getObjectAt(3).toSecurityLevel(),
186196
attestationChallenge = seq.getObjectAt(4).toByteString(),
187197
uniqueId = seq.getObjectAt(5).toByteString(),
188-
softwareEnforced = seq.getObjectAt(6).toAuthorizationList(),
189-
hardwareEnforced = seq.getObjectAt(7).toAuthorizationList(),
198+
softwareEnforced = seq.getObjectAt(6).toAuthorizationList(logFn),
199+
hardwareEnforced = seq.getObjectAt(7).toAuthorizationList(logFn),
190200
)
191201
}
192202
}
@@ -218,7 +228,7 @@ enum class Origin(val value: Long) {
218228
RESERVED(3),
219229
SECURELY_IMPORTED(4);
220230

221-
internal fun toAsn1() = ASN1Integer(value)
231+
fun toAsn1() = ASN1Integer(value)
222232
}
223233

224234
/**
@@ -227,6 +237,7 @@ enum class Origin(val value: Long) {
227237
* @see
228238
* https://cs.android.com/android/platform/superproject/main/+/main:hardware/interfaces/security/keymint/aidl/android/hardware/security/keymint/Tag.aidl
229239
*/
240+
@RequiresApi(24)
230241
enum class KeyMintTag(val value: Int) {
231242
PURPOSE(1),
232243
ALGORITHM(2),
@@ -269,7 +280,10 @@ enum class KeyMintTag(val value: Int) {
269280
companion object {
270281
fun from(value: Int) =
271282
values().firstOrNull { it.value == value }
272-
?: throw IllegalArgumentException("unknown tag number: $value")
283+
?: throw ExtensionParsingException(
284+
"unknown tag number: $value",
285+
KeyAttestationReason.UNKNOWN_TAG_NUMBER,
286+
)
273287
}
274288
}
275289

@@ -280,6 +294,7 @@ enum class KeyMintTag(val value: Int) {
280294
* https://source.android.com/docs/security/features/keystore/attestation#authorizationlist-fields
281295
*/
282296
@Immutable
297+
@RequiresApi(24)
283298
data class AuthorizationList(
284299
@SuppressWarnings("Immutable") val purposes: Set<BigInteger>? = null,
285300
val algorithms: BigInteger? = null,
@@ -397,7 +412,7 @@ data class AuthorizationList(
397412
.let { DERSequence(it.toTypedArray()) }
398413

399414
internal companion object {
400-
fun from(seq: ASN1Sequence, validateTagOrder: Boolean = false): AuthorizationList {
415+
fun from(seq: ASN1Sequence, logFn: (String) -> Unit = { _ -> }): AuthorizationList {
401416
val objects =
402417
seq.associate {
403418
require(it is ASN1TaggedObject) {
@@ -417,9 +432,8 @@ data class AuthorizationList(
417432
* 2. within each class of tags, the elements or alternatives shall appear in ascending order
418433
* of their tag numbers.
419434
*/
420-
// TODO: b/356172932 - Add test data once an example certificate is found in the wild.
421-
if (validateTagOrder && !objects.keys.zipWithNext().all { (lhs, rhs) -> rhs > lhs }) {
422-
throw IllegalArgumentException("AuthorizationList tags must appear in ascending order")
435+
if (!objects.keys.zipWithNext().all { (lhs, rhs) -> rhs > lhs }) {
436+
logFn("AuthorizationList tags should appear in ascending order")
423437
}
424438

425439
return AuthorizationList(
@@ -449,7 +463,7 @@ data class AuthorizationList(
449463
rollbackResistant = if (objects.containsKey(KeyMintTag.ROLLBACK_RESISTANT)) true else null,
450464
rootOfTrust = objects[KeyMintTag.ROOT_OF_TRUST]?.toRootOfTrust(),
451465
osVersion = objects[KeyMintTag.OS_VERSION]?.toInt(),
452-
osPatchLevel = objects[KeyMintTag.OS_PATCH_LEVEL]?.toPatchLevel(),
466+
osPatchLevel = objects[KeyMintTag.OS_PATCH_LEVEL]?.toPatchLevel("OS", logFn),
453467
attestationApplicationId =
454468
objects[KeyMintTag.ATTESTATION_APPLICATION_ID]?.toAttestationApplicationId(),
455469
attestationIdBrand = objects[KeyMintTag.ATTESTATION_ID_BRAND]?.toStr(),
@@ -460,8 +474,8 @@ data class AuthorizationList(
460474
attestationIdMeid = objects[KeyMintTag.ATTESTATION_ID_MEID]?.toStr(),
461475
attestationIdManufacturer = objects[KeyMintTag.ATTESTATION_ID_MANUFACTURER]?.toStr(),
462476
attestationIdModel = objects[KeyMintTag.ATTESTATION_ID_MODEL]?.toStr(),
463-
vendorPatchLevel = objects[KeyMintTag.VENDOR_PATCH_LEVEL]?.toPatchLevel(),
464-
bootPatchLevel = objects[KeyMintTag.BOOT_PATCH_LEVEL]?.toPatchLevel(),
477+
vendorPatchLevel = objects[KeyMintTag.VENDOR_PATCH_LEVEL]?.toPatchLevel("vendor", logFn),
478+
bootPatchLevel = objects[KeyMintTag.BOOT_PATCH_LEVEL]?.toPatchLevel("boot", logFn),
465479
attestationIdSecondImei = objects[KeyMintTag.ATTESTATION_ID_SECOND_IMEI]?.toStr(),
466480
moduleHash = objects[KeyMintTag.MODULE_HASH]?.toByteString(),
467481
)
@@ -480,14 +494,24 @@ data class PatchLevel(val yearMonth: YearMonth, val version: Int? = null) {
480494
}
481495

482496
companion object {
483-
fun from(patchLevel: ASN1Encodable): PatchLevel? {
497+
fun from(
498+
patchLevel: ASN1Encodable,
499+
partitionName: String = "",
500+
logFn: (String) -> Unit = { _ -> },
501+
): PatchLevel? {
484502
check(patchLevel is ASN1Integer) { "Must be an ASN1Integer, was ${this::class.simpleName}" }
485-
return from(patchLevel.value.toString())
503+
return from(patchLevel.value.toString(), partitionName, logFn)
486504
}
487505

488506
@JvmStatic
489-
fun from(patchLevel: String): PatchLevel? {
507+
@JvmOverloads
508+
fun from(
509+
patchLevel: String,
510+
partitionName: String = "",
511+
logFn: (String) -> Unit = { _ -> },
512+
): PatchLevel? {
490513
if (patchLevel.length != 6 && patchLevel.length != 8) {
514+
logFn("Invalid $partitionName patch level: $patchLevel")
491515
return null
492516
}
493517
try {
@@ -496,6 +520,7 @@ data class PatchLevel(val yearMonth: YearMonth, val version: Int? = null) {
496520
val version = if (patchLevel.length == 8) patchLevel.substring(6).toInt() else null
497521
return PatchLevel(yearMonth, version)
498522
} catch (e: DateTimeParseException) {
523+
logFn("Invalid $partitionName patch level: $patchLevel")
499524
return null
500525
}
501526
}
@@ -625,12 +650,10 @@ private fun ASN1Encodable.toAttestationApplicationId(): AttestationApplicationId
625650
return AttestationApplicationId.from(ASN1Sequence.getInstance(this.octets))
626651
}
627652

628-
// TODO: b/356172932 - `validateTagOrder` should default to true after making it user configurable.
629-
private fun ASN1Encodable.toAuthorizationList(
630-
validateTagOrder: Boolean = false
631-
): AuthorizationList {
653+
@RequiresApi(24)
654+
private fun ASN1Encodable.toAuthorizationList(logFn: (String) -> Unit): AuthorizationList {
632655
check(this is ASN1Sequence) { "Object must be an ASN1Sequence, was ${this::class.simpleName}" }
633-
return AuthorizationList.from(this, validateTagOrder)
656+
return AuthorizationList.from(this, logFn)
634657
}
635658

636659
private fun ASN1Encodable.toBoolean(): Boolean {
@@ -657,7 +680,10 @@ private fun ASN1Encodable.toInt(): BigInteger {
657680
return this.value
658681
}
659682

660-
private fun ASN1Encodable.toPatchLevel(): PatchLevel? = PatchLevel.from(this)
683+
private fun ASN1Encodable.toPatchLevel(
684+
partitionName: String = "",
685+
logFn: (String) -> Unit = { _ -> },
686+
): PatchLevel? = PatchLevel.from(this, partitionName, logFn)
661687

662688
private fun ASN1Encodable.toRootOfTrust(): RootOfTrust {
663689
check(this is ASN1Sequence) { "Object must be an ASN1Sequence, was ${this::class.simpleName}" }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.android.keyattestation.verifier
18+
19+
import androidx.annotation.RequiresApi
20+
import java.security.cert.CertPathValidatorException
21+
22+
/** Reasons why a certificate chain could not be verified which are specific to key attestation. */
23+
@RequiresApi(24)
24+
enum class KeyAttestationReason : CertPathValidatorException.Reason {
25+
CERTIFICATE_AFTER_TARGET,
26+
TARGET_MISSING_ATTESTATION_EXTENSION,
27+
ADDITIONAL_ATTESTATION_EXTENSION,
28+
KEY_ORIGIN_NOT_GENERATED,
29+
MISMATCHED_SECURITY_LEVELS,
30+
ROOT_OF_TRUST_MISSING,
31+
UNKNOWN_TAG_NUMBER,
32+
}

0 commit comments

Comments
 (0)