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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,10 +95,31 @@ private fun fetchRevocations(url: URL, timeoutMs: Int): Set<String> {
}
}

class AttestationPolicy(packageName: String, signerSha256: ByteArray, minimumVersion: Long) {
class AttestationPolicy(
packageName: String,
signerSha256: ByteArray,
minimumVersion: Long,
trustedBootKeys: Set<String> = 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 &&
Expand Down Expand Up @@ -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)
}
}
},
)

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions docs/attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
13 changes: 11 additions & 2 deletions rootect-core/src/main/cpp/detect/detect_root.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions rootect-core/src/main/cpp/detect/detectors.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
10 changes: 10 additions & 0 deletions rootect-core/src/main/cpp/rootect.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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. */
Expand Down Expand Up @@ -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()
Expand All @@ -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

Expand All @@ -148,7 +165,13 @@ internal object HardwareAttestation {
fun signals(result: Result, propertiesSayLocked: Boolean): List<Signal> = 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),

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