Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -62,12 +63,19 @@ internal object HardwareAttestation {
}

internal fun parseChain(chain: List<X509Certificate>, 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<ByteArray?>, 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) }
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading