diff --git a/attestation-server/src/main/kotlin/io/github/rootect/attestation/AttestationServer.kt b/attestation-server/src/main/kotlin/io/github/rootect/attestation/AttestationServer.kt index 23d8fbc..f8bdccf 100644 --- a/attestation-server/src/main/kotlin/io/github/rootect/attestation/AttestationServer.kt +++ b/attestation-server/src/main/kotlin/io/github/rootect/attestation/AttestationServer.kt @@ -4,6 +4,7 @@ import com.android.keyattestation.verifier.AttestationApplicationId import com.android.keyattestation.verifier.AttributeConstraint import com.android.keyattestation.verifier.GoogleTrustAnchors import com.android.keyattestation.verifier.InstantSource +import com.android.keyattestation.verifier.RootOfTrust import com.android.keyattestation.verifier.VerificationResult import com.android.keyattestation.verifier.VerifiedBootState import com.android.keyattestation.verifier.Verifier @@ -94,10 +95,31 @@ private fun fetchRevocations(url: URL, timeoutMs: Int): Set { } } -class AttestationPolicy(packageName: String, signerSha256: ByteArray, minimumVersion: Long) { +class AttestationPolicy( + packageName: String, + signerSha256: ByteArray, + minimumVersion: Long, + trustedBootKeys: Set = emptySet(), +) { private val packageName = packageName.also { require(it.isNotBlank()) } private val signerSha256 = signerSha256.copyOf().also { require(it.size == 32) } private val minimumVersion = minimumVersion.also { require(it >= 0) } + private val trustedBootKeys = trustedBootKeys + .map { it.replace(":", "").lowercase() } + .toSet() + .also { keys -> require(keys.all { it.hexBytes(32) != null }) } + + // Verified is the vendor's own OS. SelfSigned is trusted only on a verified boot key the + // listed, such as an official GrapheneOS key; any other self-signed OS is not. + internal fun bootTrusted(rootOfTrust: RootOfTrust?): Boolean = + rootOfTrust != null && + rootOfTrust.deviceLocked && + when (rootOfTrust.verifiedBootState) { + VerifiedBootState.VERIFIED -> true + VerifiedBootState.SELF_SIGNED -> + rootOfTrust.verifiedBootKey.toByteArray().toHex() in trustedBootKeys + else -> false + } internal fun matches(id: AttestationApplicationId?): Boolean = id != null && @@ -136,12 +158,17 @@ class AttestationVerifier( GoogleTrustAnchors, { checkedRevocations() }, now, - constraintConfig { + constraintConfig = constraintConfig { additionalConstraint { AttributeConstraint.STRICT("App identity", true) { policy.matches(it.softwareEnforced.attestationApplicationId) } } + additionalConstraint { + AttributeConstraint.STRICT("Boot state", true) { + policy.bootTrusted(it.hardwareEnforced.rootOfTrust) + } + } }, ) @@ -171,7 +198,8 @@ class AttestationVerifier( val success = result as? VerificationResult.Success val trusted = success != null && success.deviceLocked && - success.verifiedBootState == VerifiedBootState.VERIFIED + (success.verifiedBootState == VerifiedBootState.VERIFIED || + success.verifiedBootState == VerifiedBootState.SELF_SIGNED) return verdictForHardware(trusted, count, report) } @@ -352,6 +380,16 @@ fun main() { ?: error("ROOTECT_SIGNING_SHA256 must contain 64 hexadecimal characters"), requiredEnvironment("ROOTECT_MIN_VERSION").toLongOrNull() ?: error("ROOTECT_MIN_VERSION must be a non-negative integer"), + System.getenv("ROOTECT_TRUSTED_BOOT_KEYS") + ?.split(",") + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.onEach { + it.hexBytes(32) + ?: error("ROOTECT_TRUSTED_BOOT_KEYS must be comma-separated 64-character hex keys") + } + ?.toSet() + .orEmpty(), ) val ttl = System.getenv("ROOTECT_CHALLENGE_TTL_SECONDS")?.toLongOrNull() ?: 120 val store = InMemoryChallengeStore(Duration.ofSeconds(ttl)) diff --git a/attestation-server/src/test/kotlin/io/github/rootect/attestation/AttestationServerTest.kt b/attestation-server/src/test/kotlin/io/github/rootect/attestation/AttestationServerTest.kt index ba11ed0..012f741 100644 --- a/attestation-server/src/test/kotlin/io/github/rootect/attestation/AttestationServerTest.kt +++ b/attestation-server/src/test/kotlin/io/github/rootect/attestation/AttestationServerTest.kt @@ -3,6 +3,8 @@ package io.github.rootect.attestation import com.android.keyattestation.verifier.AttestationApplicationId import com.android.keyattestation.verifier.AttestationPackageInfo import com.android.keyattestation.verifier.GoogleTrustAnchors +import com.android.keyattestation.verifier.RootOfTrust +import com.android.keyattestation.verifier.VerifiedBootState import com.google.gson.Gson import com.google.protobuf.ByteString import io.ktor.client.request.post @@ -42,8 +44,29 @@ class AttestationServerTest { assertFails { AttestationPolicy("", signer, 2) } assertFails { AttestationPolicy("app", ByteArray(31), 2) } assertFails { AttestationPolicy("app", signer, -1) } + assertFails { AttestationPolicy("app", signer, 2, setOf("abc")) } } + @Test + fun selfSignedBootIsTrustedOnlyOnAConfiguredKey() { + val key = ByteArray(32) { 7 } + val trusting = AttestationPolicy("app", signer, 2, setOf(key.toHexForTest().uppercase())) + fun root(state: VerifiedBootState, locked: Boolean = true, bootKey: ByteArray = key) = + RootOfTrust(ByteString.copyFrom(bootKey), locked, state) + + assertTrue(trusting.bootTrusted(root(VerifiedBootState.VERIFIED))) + assertTrue(trusting.bootTrusted(root(VerifiedBootState.SELF_SIGNED))) + assertFalse(trusting.bootTrusted(root(VerifiedBootState.SELF_SIGNED, locked = false))) + assertFalse( + trusting.bootTrusted(root(VerifiedBootState.SELF_SIGNED, bootKey = ByteArray(32))), + ) + assertFalse(trusting.bootTrusted(root(VerifiedBootState.UNVERIFIED))) + assertFalse(trusting.bootTrusted(null)) + assertFalse(policy.bootTrusted(root(VerifiedBootState.SELF_SIGNED))) + } + + private fun ByteArray.toHexForTest(): String = joinToString("") { "%02x".format(it) } + @Test fun malformedInputFailsBeforeChallengeConsumption() { var consumed = false diff --git a/docs/attestation.md b/docs/attestation.md index 6fc2e86..9843f05 100644 --- a/docs/attestation.md +++ b/docs/attestation.md @@ -143,8 +143,8 @@ never clean. The local report is untrusted: use it to tighten policy, never as t Rootect.analyze(context, RootectConfig(hardwareAttestation = true)) ``` -That emits `ATTESTATION_BOOT_UNVERIFIED`, `ATTESTATION_SOFTWARE_ONLY` and -`ATTESTATION_CONTRADICTS_PROPERTIES` as ordinary signals. Useful for telemetry — but it is a +That emits `ATTESTATION_BOOT_UNVERIFIED`, `ATTESTATION_SELF_SIGNED_BOOT`, +`ATTESTATION_SOFTWARE_ONLY` and `ATTESTATION_CONTRADICTS_PROPERTIES` as ordinary signals. Useful for telemetry — but it is a *local* result, so it carries local trust. It is not a substitute for the round trip. Local parsing checks the nonce and extension shape, not signatures, roots or revocation. @@ -166,7 +166,7 @@ Reject on the first failure: | 6 | Attestation and KeyMint levels match and are hardware-backed | Rejects software or inconsistent claims | | 7 | Key origin is hardware-generated | Rejects imported keys | | 8 | Hardware `rootOfTrust` is present | Missing evidence fails closed | -| 9 | Boot is locked and `Verified` | Enforces device posture | +| 9 | Boot is locked and `Verified`, or `SelfSigned` on a key you trust | Enforces device posture | | 10 | Package, version and signer match | Binds evidence to your app | **There are currently two roots in force.** The older RSA-4096 root, and *Key Attestation @@ -202,7 +202,11 @@ not published as a Maven artifact. Pass its directory when building: ./gradlew :attestation-server:run -PkeyAttestationPath=/path/to/keyattestation ``` -Set `ROOTECT_APP_ID`, `ROOTECT_SIGNING_SHA256` and `ROOTECT_MIN_VERSION` before starting it. The +Set `ROOTECT_APP_ID`, `ROOTECT_SIGNING_SHA256` and `ROOTECT_MIN_VERSION` before starting it. +To accept a locked `SelfSigned` boot, such as GrapheneOS, list the verified boot key digests you +trust in `ROOTECT_TRUSTED_BOOT_KEYS`, comma-separated hex; GrapheneOS publishes its keys in its +[attestation compatibility guide](https://grapheneos.org/articles/attestation-compatibility-guide). +Without it, only a locked `Verified` boot is trusted. The default revocation source caches Google's list for an hour, sets connect and read timeouts, keeps serving the last good list through a transient Google outage, and returns HTTP 503 only when it has no list recent enough to trust. diff --git a/docs/signals.md b/docs/signals.md index dc97d62..013fe81 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -41,7 +41,7 @@ already knows is the cheaper trade. | Signal | Confidence | Catches | Limits | |---|---|---|---| | `SELINUX_PERMISSIVE` | STRONG | SELinux not enforcing | Not a root signal — custom ROMs run permissive legitimately | -| `BOOTLOADER_UNLOCKED` | STRONG | Three boot-state properties (including any non-green Verified Boot state) | Properties are rewritable on a rooted device | +| `BOOTLOADER_UNLOCKED` | STRONG | Three boot-state properties (including a Verified Boot state other than green or yellow) | Properties are rewritable on a rooted device | | `KNOX_WARRANTY_BIT_TRIPPED` | STRONG | Samsung reports its persistent Knox warranty fuse as tripped | Samsung only; the local property is rewritable, so Knox server attestation is authoritative | | `TEST_KEYS_BUILD` | WEAK | `ro.build.tags` containing `test-keys` (not a signature verification) | Common on honest custom ROMs, hence `WEAK` | | `DEVELOPER_OPTIONS_ENABLED` | WEAK | The Developer options toggle is on, read from `Settings.Global` | Common on honest devices and one JVM hook from silent; posture only, never feeds `isRooted` | @@ -116,6 +116,7 @@ hookable; the server path is authoritative. See [attestation.md](attestation.md) | Signal | Confidence | Catches | |---|---|---| | `ATTESTATION_BOOT_UNVERIFIED` | STRONG | Secure hardware reporting an unlocked or unverified device | +| `ATTESTATION_SELF_SIGNED_BOOT` | WEAK | Secure hardware reporting a locked device booting an OS on a user-enrolled key | | `ATTESTATION_SOFTWARE_ONLY` | MODERATE | A parsed record reports a software security level; unavailable evidence is inconclusive instead | | `ATTESTATION_CONTRADICTS_PROPERTIES` | CONCLUSIVE | Properties claiming a locked device while hardware disagrees | diff --git a/rootect-core/src/androidTest/kotlin/io/github/rootect/RootDetectionTest.kt b/rootect-core/src/androidTest/kotlin/io/github/rootect/RootDetectionTest.kt index 1eb1a57..3207a9d 100644 --- a/rootect-core/src/androidTest/kotlin/io/github/rootect/RootDetectionTest.kt +++ b/rootect-core/src/androidTest/kotlin/io/github/rootect/RootDetectionTest.kt @@ -14,6 +14,7 @@ import io.github.rootect.internal.jni.NativeSignals import io.github.rootect.signal.Category import io.github.rootect.signal.SignalId import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue @@ -63,6 +64,15 @@ class RootDetectionTest { SignalId.XPOSED_FRAMEWORK_PRESENT in ids("/system/framework/XposedBridge.jar")) } + @Test + fun verifiedBootStateReadsYellowAsLocked() { + // Yellow is a locked bootloader on a user-enrolled key. + assertFalse(NativeProbes.bootStateProbe("green")) + assertFalse("yellow read as unlocked", NativeProbes.bootStateProbe("yellow")) + assertTrue(NativeProbes.bootStateProbe("orange")) + assertTrue("an unknown state must keep flagging", NativeProbes.bootStateProbe("violet")) + } + @Test fun scanReturnsWellFormedOutput() { val scan = NativeBridge.scan(0) diff --git a/rootect-core/src/debug/kotlin/io/github/rootect/internal/jni/NativeProbes.kt b/rootect-core/src/debug/kotlin/io/github/rootect/internal/jni/NativeProbes.kt index 64913a9..b1bf670 100644 --- a/rootect-core/src/debug/kotlin/io/github/rootect/internal/jni/NativeProbes.kt +++ b/rootect-core/src/debug/kotlin/io/github/rootect/internal/jni/NativeProbes.kt @@ -22,6 +22,9 @@ internal object NativeProbes { /** How many signal bits the native side defines. */ external fun nativeSignalCount(): Int + /** Whether scan_properties reads a Verified Boot state as unlocked. */ + external fun bootStateProbe(state: String): Boolean + /** Instrumentation flags scan_maps would set for a mapped file path. */ external fun mapsProbe(path: String): Int } diff --git a/rootect-core/src/main/cpp/detect/detect_root.cpp b/rootect-core/src/main/cpp/detect/detect_root.cpp index db8a341..b122598 100644 --- a/rootect-core/src/main/cpp/detect/detect_root.cpp +++ b/rootect-core/src/main/cpp/detect/detect_root.cpp @@ -105,7 +105,6 @@ void scan_properties(ScanOutcome& out) { auto tags = ROOTECT_HIDE("ro.build.tags"); auto warranty = ROOTECT_HIDE("ro.boot.warranty_bit"); - auto green = ROOTECT_HIDE("green"); auto zero = ROOTECT_HIDE("0"); auto one = ROOTECT_HIDE("1"); auto unlocked = ROOTECT_HIDE("unlocked"); @@ -116,7 +115,7 @@ void scan_properties(ScanOutcome& out) { if (prop_get(vbs.c_str(), value)) { answered = true; - if (!field_equals(value, green.c_str())) out.flags |= NS_BOOTLOADER_UNLOCKED; + if (boot_state_unlocked(value)) out.flags |= NS_BOOTLOADER_UNLOCKED; } if (prop_get(locked.c_str(), value)) { answered = true; @@ -175,6 +174,16 @@ void scan_kernel(ScanOutcome& out) { } // namespace +// Verified Boot reports green for a locked device on the OEM key and yellow for a locked +// device on a user-enrolled key. Only those two mean locked; orange +// is unlocked, and a value this list does not know keeps flagging rather than passing. +bool boot_state_unlocked(const char* verified_boot_state) { + auto green = ROOTECT_HIDE("green"); + auto yellow = ROOTECT_HIDE("yellow"); + return !field_equals(verified_boot_state, green.c_str()) && + !field_equals(verified_boot_state, yellow.c_str()); +} + // Runs every root check. void scan_root(ScanOutcome& out) { unsigned before = out.inconclusive; diff --git a/rootect-core/src/main/cpp/detect/detectors.h b/rootect-core/src/main/cpp/detect/detectors.h index 7027a14..a9d63f1 100644 --- a/rootect-core/src/main/cpp/detect/detectors.h +++ b/rootect-core/src/main/cpp/detect/detectors.h @@ -59,6 +59,9 @@ void scan_emulator(ScanOutcome& out); unsigned maps_probe(const char* path); #endif +// True when a ro.boot.verifiedbootstate value does not describe a locked bootloader. +bool boot_state_unlocked(const char* verified_boot_state); + // 0 match, 1 mismatch, -1 unreadable. int signing_matches(const char* apk_path, const char* expected_hex, int sdk); diff --git a/rootect-core/src/main/cpp/rootect.cpp b/rootect-core/src/main/cpp/rootect.cpp index acec244..90372f6 100644 --- a/rootect-core/src/main/cpp/rootect.cpp +++ b/rootect-core/src/main/cpp/rootect.cpp @@ -115,6 +115,16 @@ Java_io_github_rootect_internal_jni_NativeProbes_pathProbe(JNIEnv* env, jobject, return rc; } +// Debug only. Whether scan_properties reads a Verified Boot state as unlocked. +extern "C" JNIEXPORT jboolean JNICALL +Java_io_github_rootect_internal_jni_NativeProbes_bootStateProbe(JNIEnv* env, jobject, jstring jstate) { + const char* state = env->GetStringUTFChars(jstate, nullptr); + if (state == nullptr) return JNI_FALSE; + bool unlocked = rootect::boot_state_unlocked(state); + env->ReleaseStringUTFChars(jstate, state); + return unlocked ? JNI_TRUE : JNI_FALSE; +} + // Debug only. Instrumentation flags scan_maps would set for a mapped file path. extern "C" JNIEXPORT jint JNICALL Java_io_github_rootect_internal_jni_NativeProbes_mapsProbe(JNIEnv* env, jobject, jstring jpath) { diff --git a/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/Der.kt b/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/Der.kt index a2bc568..372e16a 100644 --- a/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/Der.kt +++ b/rootect-core/src/main/kotlin/io/github/rootect/internal/attest/Der.kt @@ -58,8 +58,12 @@ internal class Der( /** Reader over this element's contents. */ fun reader(): Der = Der(src, contentStart, contentEnd) - /** Contents interpreted as an unsigned integer or enumerated value. */ + /** + * Contents interpreted as an unsigned integer or enumerated value, or -1 when they are + * empty or wider than four bytes, which would otherwise wrap into a small value. + */ fun asInt(): Int { + if (contentEnd - contentStart !in 1..4) return -1 var value = 0 for (i in contentStart until contentEnd) { value = (value shl 8) or (src[i].toInt() and 0xFF) 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 ca8b368..f5c84da 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 @@ -21,6 +21,12 @@ internal object HardwareAttestation { private const val IDX_CHALLENGE = 4 private const val IDX_TEE_ENFORCED = 7 + // Universal tag numbers of the fields read below. + private const val TAG_BOOLEAN = 1L + private const val TAG_OCTET_STRING = 4L + private const val TAG_ENUMERATED = 10L + private const val TAG_SEQUENCE = 16L + // AuthorizationList tag for rootOfTrust. private const val TAG_ROOT_OF_TRUST = 704L @@ -31,6 +37,7 @@ internal object HardwareAttestation { private const val SECURITY_LEVEL_SOFTWARE = 0 private const val SECURITY_LEVEL_STRONGBOX = 2 private const val BOOT_STATE_VERIFIED = 0 + private const val BOOT_STATE_SELF_SIGNED = 1 private const val BOOT_STATE_FAILED = 3 private const val CHALLENGE_SIZE = 32 @@ -44,6 +51,7 @@ internal object HardwareAttestation { val isSoftwareOnly: Boolean get() = securityLevel == SECURITY_LEVEL_SOFTWARE val hasRootOfTrust: Boolean get() = deviceLocked != null && verifiedBootState != null val isBootVerified: Boolean get() = verifiedBootState == BOOT_STATE_VERIFIED + val isBootSelfSigned: Boolean get() = verifiedBootState == BOOT_STATE_SELF_SIGNED } /** Generates a throwaway attested key and reads the statement out of its certificate. */ @@ -110,6 +118,12 @@ internal object HardwareAttestation { val inner = Der(raw).next()?.reader()?.next() ?: return null val fields = inner.reader().all() if (fields.size <= IDX_TEE_ENFORCED) return null + // A field of the wrong type is a malformed record, not a value to coerce. + if (fields[IDX_SECURITY_LEVEL].tag != TAG_ENUMERATED || + fields[IDX_KEYMINT_SECURITY_LEVEL].tag != TAG_ENUMERATED || + fields[IDX_CHALLENGE].tag != TAG_OCTET_STRING || + fields[IDX_TEE_ENFORCED].tag != TAG_SEQUENCE + ) return null val attestationSecurityLevel = fields[IDX_SECURITY_LEVEL].asInt() val keyMintSecurityLevel = fields[IDX_KEYMINT_SECURITY_LEVEL].asInt() @@ -130,6 +144,9 @@ internal object HardwareAttestation { ?: return null if (rootOfTrust.size <= IDX_BOOT_STATE) return null + if (rootOfTrust[IDX_DEVICE_LOCKED].tag != TAG_BOOLEAN || + rootOfTrust[IDX_BOOT_STATE].tag != TAG_ENUMERATED + ) return null val verifiedBootState = rootOfTrust[IDX_BOOT_STATE].asInt() if (verifiedBootState !in BOOT_STATE_VERIFIED..BOOT_STATE_FAILED) return null @@ -148,7 +165,13 @@ internal object HardwareAttestation { fun signals(result: Result, propertiesSayLocked: Boolean): List = buildList { if (result.isSoftwareOnly) add(Signal(SignalId.ATTESTATION_SOFTWARE_ONLY)) - if (result.hasRootOfTrust && (result.deviceLocked == false || !result.isBootVerified)) { + if (!result.hasRootOfTrust) return@buildList + + // SelfSigned on a locked device is an OS on a user-enrolled key. + // Rootect cannot tell whose key it is, so it is posture rather than an unverified boot. + if (result.deviceLocked == true && result.isBootSelfSigned) { + add(Signal(SignalId.ATTESTATION_SELF_SIGNED_BOOT)) + } else if (result.deviceLocked == false || !result.isBootVerified) { add(Signal(SignalId.ATTESTATION_BOOT_UNVERIFIED)) // The properties claim a locked, verified device while the hardware says diff --git a/rootect-core/src/main/kotlin/io/github/rootect/signal/SignalId.kt b/rootect-core/src/main/kotlin/io/github/rootect/signal/SignalId.kt index 9bba51c..bce3e71 100644 --- a/rootect-core/src/main/kotlin/io/github/rootect/signal/SignalId.kt +++ b/rootect-core/src/main/kotlin/io/github/rootect/signal/SignalId.kt @@ -32,6 +32,9 @@ public enum class SignalId( /** Secure hardware reports the bootloader unlocked or boot unverified. */ ATTESTATION_BOOT_UNVERIFIED(Category.ENVIRONMENT, Confidence.STRONG), + /** Secure hardware reports a locked device booting an OS signed with a user-enrolled key. */ + ATTESTATION_SELF_SIGNED_BOOT(Category.ENVIRONMENT, Confidence.WEAK), + /** No TEE-backed attestation available, so the answer came from software. */ ATTESTATION_SOFTWARE_ONLY(Category.ENVIRONMENT, Confidence.MODERATE), diff --git a/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/DerTest.kt b/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/DerTest.kt index 62e4e15..58a1059 100644 --- a/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/DerTest.kt +++ b/rootect-core/src/test/kotlin/io/github/rootect/internal/attest/DerTest.kt @@ -35,6 +35,13 @@ class DerTest { assertEquals(704L, e!!.tag) } + @Test + fun `an integer wider than four bytes does not wrap into a small value`() { + // 0x01_00000002 would otherwise shift its high byte out and read as 2. + assertEquals(-1, der(0x0A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x02).next()!!.asInt()) + assertEquals(-1, der(0x0A, 0x00).next()!!.asInt()) + } + @Test fun `an element claiming more content than exists yields null`() { assertNull(der(0x30, 0x08, 0x01, 0x02).next()) 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 d5b94e7..95c35b2 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 @@ -42,6 +42,42 @@ class HardwareAttestationTest { assertNull(HardwareAttestation.parseExtension(keyDescription(1, 1, withRoot = false))) } + @Test + fun `a locked self-signed boot is posture not an unverified boot`() { + val result = HardwareAttestation.parseExtension( + keyDescription(1, 1, withRoot = true, bootState = 1), + ) + + assertEquals( + listOf(SignalId.ATTESTATION_SELF_SIGNED_BOOT), + HardwareAttestation.signals(result!!, propertiesSayLocked = true).map { it.id }, + ) + } + + @Test + fun `an unlocked self-signed boot stays unverified`() { + val result = HardwareAttestation.parseExtension( + keyDescription(1, 1, withRoot = true, bootState = 1, locked = false), + ) + + assertEquals( + listOf( + SignalId.ATTESTATION_BOOT_UNVERIFIED, + SignalId.ATTESTATION_CONTRADICTS_PROPERTIES, + ), + HardwareAttestation.signals(result!!, propertiesSayLocked = true).map { it.id }, + ) + } + + @Test + fun `a security level that is not an enumerated value is rejected`() { + assertNull( + HardwareAttestation.parseExtension( + keyDescription(1, 1, withRoot = true, levelTag = 0x02), + ), + ) + } + @Test fun `an out-of-range boot state is rejected`() { assertNull( @@ -54,13 +90,15 @@ class HardwareAttestationTest { keyMintLevel: Int, withRoot: Boolean, bootState: Int = 0, + locked: Boolean = true, + levelTag: Int = 0x0A, ): ByteArray { val root = if (withRoot) { context( 704, sequence( element(0x04, byteArrayOf(1)), - element(0x01, byteArrayOf(0xFF.toByte())), + element(0x01, byteArrayOf(if (locked) 0xFF.toByte() else 0)), integer(bootState, 0x0A), ), ) @@ -69,9 +107,9 @@ class HardwareAttestationTest { } val description = sequence( integer(4), - integer(attestationLevel, 0x0A), + integer(attestationLevel, levelTag), integer(4), - integer(keyMintLevel, 0x0A), + integer(keyMintLevel, levelTag), element(0x04, ByteArray(32) { it.toByte() }), element(0x04, byteArrayOf()), sequence(), diff --git a/sample/src/main/kotlin/io/github/rootect/sample/SignalCopy.kt b/sample/src/main/kotlin/io/github/rootect/sample/SignalCopy.kt index 21bcff4..d336a17 100644 --- a/sample/src/main/kotlin/io/github/rootect/sample/SignalCopy.kt +++ b/sample/src/main/kotlin/io/github/rootect/sample/SignalCopy.kt @@ -35,6 +35,8 @@ internal object SignalCopy { "Samsung's Knox warranty fuse reports historical device tampering." SignalId.ATTESTATION_BOOT_UNVERIFIED -> "The device's secure hardware reports the bootloader unlocked or boot unverified." + SignalId.ATTESTATION_SELF_SIGNED_BOOT -> + "The device is locked and runs an OS signed with a user-enrolled key, not the OEM's." SignalId.ATTESTATION_SOFTWARE_ONLY -> "No hardware-backed attestation — the answer came from software and is weaker." SignalId.ATTESTATION_CONTRADICTS_PROPERTIES ->