diff --git a/README.md b/README.md index fcdf304..9a21dc9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The Android library has no third-party dependencies — the Kotlin standard libr else. No permissions. No Google Play requirement. `minSdk 24`. ```kotlin -implementation("io.github.rootect:rootect-core:1.1.0") +implementation("io.github.rootect:rootect-core:1.2.0") ``` ```kotlin diff --git a/docs/integration.md b/docs/integration.md index 279ec9a..7d67b68 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -10,7 +10,7 @@ worth against a real attacker, see [threat-model.md](threat-model.md). ```kotlin // build.gradle.kts dependencies { - implementation("io.github.rootect:rootect-core:1.1.0") + implementation("io.github.rootect:rootect-core:1.2.0") } ``` diff --git a/gradle.properties b/gradle.properties index 0b913ed..6f1e124 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ android.useAndroidX=true # Single source for the published coordinates. Bumping here is the whole release step. GROUP=io.github.rootect -VERSION_NAME=1.1.0 +VERSION_NAME=1.2.0 # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true diff --git a/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/HardwareAttestation.kt b/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/HardwareAttestation.kt index f5c84da..68bffe3 100644 --- a/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/HardwareAttestation.kt +++ b/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/HardwareAttestation.kt @@ -40,6 +40,7 @@ internal object HardwareAttestation { private const val BOOT_STATE_SELF_SIGNED = 1 private const val BOOT_STATE_FAILED = 3 private const val CHALLENGE_SIZE = 32 + private const val MAX_EXTENSION_BYTES = 64 * 1024 /** What the attestation record said, or null if it could not be parsed. */ data class Result( @@ -62,12 +63,19 @@ internal object HardwareAttestation { } internal fun parseChain(chain: List, challenge: ByteArray): Result? { - if (challenge.size != CHALLENGE_SIZE || chain.isEmpty()) return null - val hasExtension = chain.map { - runCatching { it.getExtensionValue(ATTESTATION_OID) != null }.getOrDefault(false) - } - if (!hasExtension.first() || hasExtension.drop(1).any { it }) return null - val parsed = parse(chain.first()) ?: return null + if (chain.size !in 1..8) return null + val extensions = runCatching { chain.map { it.getExtensionValue(ATTESTATION_OID) } } + .getOrNull() ?: return null + return parseChainExtensions(extensions, challenge) + } + + // Android returns leaf first. Select the extension nearest the root; a descendant + // can contain an attacker-added extension. This does not authenticate the chain. + internal fun parseChainExtensions(extensions: List, challenge: ByteArray): Result? { + if (challenge.size != CHALLENGE_SIZE || extensions.size !in 1..8) return null + val raw = extensions.lastOrNull { it != null } ?: return null + if (raw.size > MAX_EXTENSION_BYTES) return null + val parsed = parseExtension(raw) ?: return null return parsed.takeIf { it.challenge.contentEquals(challenge) } } @@ -107,12 +115,6 @@ internal object HardwareAttestation { } } - /** Pulls the security level and root of trust out of the attestation extension. */ - private fun parse(cert: X509Certificate): Result? { - val raw = cert.getExtensionValue(ATTESTATION_OID) ?: return null - return parseExtension(raw) - } - internal fun parseExtension(raw: ByteArray): Result? { // The extension value is an OCTET STRING wrapping the real KeyDescription. val inner = Der(raw).next()?.reader()?.next() ?: return null diff --git a/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/HardwareAttestationTest.kt b/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/HardwareAttestationTest.kt index 95c35b2..9129b5c 100644 --- a/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/HardwareAttestationTest.kt +++ b/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/HardwareAttestationTest.kt @@ -8,6 +8,41 @@ import org.junit.Test class HardwareAttestationTest { + private val challenge = ByteArray(32) { it.toByte() } + + @Test + fun `extension need not be in the leaf`() { + val result = HardwareAttestation.parseChainExtensions( + listOf(null, keyDescription(1, 1, true), null), challenge, + ) + assertEquals(1, result!!.securityLevel) + } + + @Test + fun `descendant extension cannot override ancestor`() { + val result = HardwareAttestation.parseChainExtensions( + listOf(keyDescription(2, 2, true), keyDescription(0, 0, false), null), challenge, + ) + assertTrue(result!!.isSoftwareOnly) + } + + @Test + fun `malformed ancestor cannot fall back to valid leaf`() { + assertNull(HardwareAttestation.parseChainExtensions( + listOf(keyDescription(1, 1, true), byteArrayOf(4, 0), null), challenge, + )) + } + + @Test + fun `wrong challenge and missing extension are unknown`() { + assertNull(HardwareAttestation.parseChainExtensions( + listOf(keyDescription(1, 1, true)), ByteArray(32), + )) + assertNull(HardwareAttestation.parseChainExtensions(listOf(null, null), challenge)) + assertNull(HardwareAttestation.parseChainExtensions(emptyList(), challenge)) + assertNull(HardwareAttestation.parseChainExtensions(List(9) { null }, challenge)) + } + @Test fun `software-only record remains usable without root of trust`() { val result = HardwareAttestation.parseExtension(keyDescription(0, 0, withRoot = false))