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
58 changes: 50 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,24 +30,51 @@ 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
is VerificationResult.ExtensionConstraintViolation -> // Handle constraint violations
}
```

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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/main/kotlin/AttestationApplicationIdChecker.kt
Original file line number Diff line number Diff line change
@@ -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 }
}
}
24 changes: 24 additions & 0 deletions src/main/kotlin/Extension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -638,6 +658,10 @@ data class AttestationPackageInfo(val name: String, val version: BigInteger) {
}
.let { DERSequence(it.toTypedArray()) }

internal fun isSatisfiedBy(candidate: Set<AttestationPackageInfo>) = candidate.any {
it.name == name && it.version >= version
}

internal companion object {
fun from(attestationPackageInfo: ASN1Sequence): AttestationPackageInfo {
require(attestationPackageInfo.size() == 2) {
Expand Down
30 changes: 28 additions & 2 deletions src/main/kotlin/Verifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -183,13 +185,19 @@ constructor(
fun verify(
chain: List<X509Certificate>,
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)
Expand All @@ -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].
*/
Expand All @@ -214,6 +224,10 @@ constructor(
coroutineScope: CoroutineScope,
chain: List<X509Certificate>,
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<VerificationResult> {
val immutableChain = ImmutableList.copyOf(chain)
Expand All @@ -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)
Expand All @@ -236,6 +250,7 @@ constructor(
@RequiresApi(24)
private suspend fun internalVerify(
certPath: KeyAttestationCertPath,
attestationApplicationIdChecker: AttestationApplicationIdChecker,
challengeChecker: ChallengeChecker? = null,
log: VerifyRequestLog? = null,
): VerificationResult {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading