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
95 changes: 95 additions & 0 deletions android/src/androidTest/java/com/comapeo/core/RootKeyStoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class RootKeyStoreTest {
context.getSharedPreferences(RootKeyStore.PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(RootKeyStore.PREFS_KEY)
.remove(RootKeyStore.MASTERKEY_PREFS_KEY)
.commit()
context.getSharedPreferences(
LegacyRootKeyDecoder.SECURE_STORE_PREFS_NAME,
Expand Down Expand Up @@ -295,6 +296,96 @@ class RootKeyStoreTest {
)
}

@Test
fun masterKeyRoundTripsThroughTheCache() {
val rootKey = RootKeyStore(context).loadOrInitialize().key
RootKeyStore(context).storeMasterKey(MASTER_KEY, RootKeyStore.fingerprintOf(rootKey))

// A fresh instance proves the entry survives in persistent storage.
val loaded = RootKeyStore(context).loadMasterKey(rootKey)
assertArrayEquals("cache must return the stored bytes verbatim", MASTER_KEY, loaded)
}

@Test
fun corruptMasterKeyEnvelopeReturnsNullAndDeletesEntry() {
val rootKey = RootKeyStore(context).loadOrInitialize().key
RootKeyStore(context).storeMasterKey(MASTER_KEY, RootKeyStore.fingerprintOf(rootKey))

context.getSharedPreferences(RootKeyStore.PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(RootKeyStore.MASTERKEY_PREFS_KEY, "not valid json {")
.commit()

assertNull(RootKeyStore(context).loadMasterKey(rootKey))
assertNull("corrupt entry must be deleted, not left to fail again", masterKeyEntry())
}

@Test
fun fingerprintMismatchReturnsNullAndDeletesEntry() {
val rootKey = RootKeyStore(context).loadOrInitialize().key
RootKeyStore(context).storeMasterKey(MASTER_KEY, RootKeyStore.fingerprintOf(rootKey))

// Same wrapper key, different rootkey — the `fp` binding is the only thing
// that can catch a rootkey change the cache never saw.
assertNull(RootKeyStore(context).loadMasterKey(KNOWN_BYTES))
assertNull("stale entry must be deleted", masterKeyEntry())
}

@Test
fun rootkeyGenerationClearsTheMasterKeyCache() {
// Seed a cache entry that predates the rootkey write.
context.getSharedPreferences(RootKeyStore.PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(RootKeyStore.MASTERKEY_PREFS_KEY, "stale entry")
.commit()

RootKeyStore(context).loadOrInitialize()

assertNull("first-install rootkey write must clear the cache", masterKeyEntry())
}

@Test
fun legacyMigrationClearsTheMasterKeyCache() {
seedLegacyEntry(KNOWN_HEX, keychainAware = true)
val rootKey = RootKeyStore(context).loadOrInitialize().key
RootKeyStore(context).storeMasterKey(MASTER_KEY, RootKeyStore.fingerprintOf(rootKey))
assertNotNull(masterKeyEntry())

// Drop the native blob so the next load migrates from legacy again — a second
// rootkey write, which must take the cache with it.
context.getSharedPreferences(RootKeyStore.PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(RootKeyStore.PREFS_KEY)
.commit()

RootKeyStore(context).loadOrInitialize()
assertNull("every rootkey write must clear the cache", masterKeyEntry())
}

@Test
fun wrongLengthMasterKeyIsRejected() {
val rootKey = RootKeyStore(context).loadOrInitialize().key

RootKeyStore(context).storeMasterKey(ByteArray(31), RootKeyStore.fingerprintOf(rootKey))

assertNull("a short master key must never be persisted", masterKeyEntry())
assertNull(RootKeyStore(context).loadMasterKey(rootKey))
}

@Test
fun wrongLengthFingerprintIsRejected() {
val rootKey = RootKeyStore(context).loadOrInitialize().key

RootKeyStore(context).storeMasterKey(MASTER_KEY, ByteArray(4))

assertNull("an entry the load path could never match must not be written", masterKeyEntry())
assertNull(RootKeyStore(context).loadMasterKey(rootKey))
}

private fun masterKeyEntry(): String? = context
.getSharedPreferences(RootKeyStore.PREFS_NAME, Context.MODE_PRIVATE)
.getString(RootKeyStore.MASTERKEY_PREFS_KEY, null)

/**
* Writes a handcrafted `expo-secure-store` AES entry: generates the legacy
* AndroidKeyStore alias the way `expo-secure-store@56` does, encrypts [hex] as a
Expand Down Expand Up @@ -365,5 +456,9 @@ class RootKeyStoreTest {
0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0xFF.toByte(),
)
private const val KNOWN_HEX = "00112233445566778899aabbccddeeff"

// Arbitrary 32 bytes — the cache stores whatever it is handed; the pinned
// derivation vector is exercised by MasterKeyDeriveContractTest.
private val MASTER_KEY = ByteArray(RootKeyStore.MASTERKEY_BYTE_LENGTH) { it.toByte() }
}
}
4 changes: 4 additions & 0 deletions android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ class ComapeoCoreModule : Module() {
"errorMessage" to frame.message,
),
)
// Targeted at the FGS connection that sent init, so it
// should never arrive here — and the payload must never
// be logged if it does.
is ControlFrame.MasterKey -> {}
// Sentry frames belong to the FGS-side sentry-android SDK.
// Capturing here would double-send.
is ControlFrame.SentryEvent -> {}
Expand Down
17 changes: 17 additions & 0 deletions android/src/main/java/com/comapeo/core/ControlFrame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ sealed class ControlFrame {
*/
data class SentryEnvelope(val data: String) : ControlFrame()

/**
* The master key the backend derived this boot, base64, for the native
* cache. Sent only to the connection that shipped the init frame, so it
* never reaches the main-app process on Android.
*/
data class MasterKey(val base64: String) : ControlFrame()

/** Frame could not be processed; `detail` is suitable for logs / `messageerror`. */
data class Malformed(val detail: String) : ControlFrame()

Expand All @@ -52,6 +59,16 @@ sealed class ControlFrame {
phase = json.optString("phase", "unknown"),
message = json.optString("message", "(no message)"),
)
"master-key" -> {
// `opt` + cast, not `optString`: org.json's JVM build coerces
// any non-null value to a string, Android's does not.
val key = json.opt("masterKey") as? String
if (key.isNullOrEmpty()) {
Malformed("master-key frame missing string `masterKey`")
} else {
MasterKey(key)
}
}
"sentry-event" -> {
val payload = json.optJSONObject("payload")
// Re-serialize so SentryEvent.Deserializer can re-parse against the bytes it expects.
Expand Down
42 changes: 42 additions & 0 deletions android/src/main/java/com/comapeo/core/MasterKeyFrame.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.comapeo.core

import android.util.Base64

/**
* Validation of the inbound `master-key` frame, kept free of the service so
* the gate is unit-testable on the JVM.
*/
internal object MasterKeyFrame {

const val MASTER_KEY_BYTE_LENGTH = 32

/** Same shape `backend/lib/parse-init.js` accepts for the outbound field. */
private val STRICT_BASE64 = Regex("^[A-Za-z0-9\\+/]{43}=$")

/**
* Returns the 32 raw bytes of [base64], or null when it is not the strict
* base64 of a 32-byte key. The caller owns zeroing the result.
*
* No trailing-bits round-trip check (unlike `parse-init.js` inbound): the
* sender is our own backend, whose `Buffer.toString("base64")` always
* emits the standard encoding.
*
* @param decoder test seam — `android.util.Base64` is not available on the JVM.
*/
fun decode(
base64: String,
decoder: (String) -> ByteArray = { Base64.decode(it, Base64.NO_WRAP) },
): ByteArray? {
if (!STRICT_BASE64.matches(base64)) return null
val bytes = try {
decoder(base64)
} catch (_: Throwable) {
return null
}
if (bytes.size != MASTER_KEY_BYTE_LENGTH) {
bytes.fill(0)
return null
}
return bytes
}
}
90 changes: 85 additions & 5 deletions android/src/main/java/com/comapeo/core/NodeJSService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ class NodeJSService(
@Volatile
var onStateChange: ((State) -> Unit)? = null

/** Fingerprint of the rootkey this boot shipped on the init frame, kept so
* an inbound `master-key` frame can be bound to it after the rootkey
* itself has been zeroed. Not secret (a truncated hash), so it lives for
* the process lifetime. */
@Volatile
private var pendingRootKeyFingerprint: ByteArray? = null

/** Atomic state container — `getAndUpdate` is a CAS loop, so the
* `(nodeRuntime, backendState, stopRequested, state, lastError)` tuple is always coherent. */
private val stateFlow = MutableStateFlow(ComponentSnapshot())
Expand Down Expand Up @@ -684,6 +691,10 @@ class NodeJSService(
it.copy(backendState = BackendState.Error(frame.phase, frame.message))
}
}
is ControlFrame.MasterKey -> {
logCrumb(SentryCategories.CONTROL, "received: master-key")
storeMasterKey(frame.base64)
}
is ControlFrame.SentryEvent -> {
SentryFgsBridge.captureEventJson(frame.payloadJson)
}
Expand All @@ -704,8 +715,10 @@ class NodeJSService(
}

/**
* Reads the rootkey, base64-encodes, and ships the init frame on the control
* socket. Failures transition to ERROR and forward `error-native` to Node so
* Reads the rootkey (plus the cached master key, when the cache has one),
* base64-encodes,
* and ships the init frame on the control socket. Rootkey failures alone are
* fatal: they transition to ERROR and forward `error-native` to Node so
* the main-app process sees the same phase via re-broadcast. The node thread
* is left alive — recovery (restart FGS, prompt user, …) is the application's
* responsibility, exposed via the JS `stateChange` event.
Expand All @@ -718,8 +731,9 @@ class NodeJSService(
if (rootkeyLoadSpan != null) {
bootSpans["rootkey-load"] = rootkeyLoadSpan
}
val store = RootKeyStore(applicationContext)
val rootKeyBytes: ByteArray = try {
val result = RootKeyStore(applicationContext).loadOrInitialize()
val result = store.loadOrInitialize()
bootSpans.remove("rootkey-load")?.let { sp ->
SentryFgsBridge.setSpanData(sp, "generated", result.generated)
SentryFgsBridge.finishSpan(sp, "ok")
Expand Down Expand Up @@ -748,15 +762,81 @@ class NodeJSService(
}
return
}
val b64 = Base64.encodeToString(rootKeyBytes, Base64.NO_WRAP)
val masterKeyBytes = try {
store.loadMasterKey(rootKeyBytes)
} catch (t: Throwable) {
// Class name only: nothing about the keys reaches a log line.
logCrumb(
SentryCategories.BOOT,
"master key cache read threw (${t.javaClass.simpleName}), continuing uncached",
level = "warning",
)
null
}
val rootKeyB64 = Base64.encodeToString(rootKeyBytes, Base64.NO_WRAP)
pendingRootKeyFingerprint = RootKeyStore.fingerprintOf(rootKeyBytes)
rootKeyBytes.fill(0)
val frame = "{\"type\":\"init\",\"rootKey\":\"$b64\"}"
val frame = if (masterKeyBytes == null) {
"{\"type\":\"init\",\"rootKey\":\"$rootKeyB64\"}"
} else {
val masterKeyB64 = Base64.encodeToString(masterKeyBytes, Base64.NO_WRAP)
masterKeyBytes.fill(0)
"{\"type\":\"init\",\"rootKey\":\"$rootKeyB64\",\"masterKey\":\"$masterKeyB64\"}"
}
serviceScope.launch {
ipcDeferred.await().sendMessage(frame)
logCrumb(SentryCategories.BOOT, "init frame sent")
}
}

/**
* Caches the master key the backend derived this boot so the next init
* frame can carry it. Never fatal: a dropped frame costs the next boot a
* derivation.
*
* The write runs off the receive path — a StrongBox encrypt plus a
* synchronous `commit()` can outlast the `ready` frame that follows this
* one on a cache-miss boot.
*/
private fun storeMasterKey(base64: String) {
val fingerprint = pendingRootKeyFingerprint
if (fingerprint == null) {
logCrumb(
SentryCategories.BOOT,
"master-key frame arrived before the init frame, dropping",
level = "warning",
)
metricCount(
RootKeyStore.METRIC_MASTERKEY_STORE,
mapOf("outcome" to "no-fingerprint"),
)
return
}
val masterKeyBytes = MasterKeyFrame.decode(base64)
if (masterKeyBytes == null) {
// Length only: the payload is the key.
logCrumb(
SentryCategories.BOOT,
"master-key frame failed validation (${base64.length} chars), dropping",
level = "warning",
)
metricCount(
RootKeyStore.METRIC_MASTERKEY_STORE,
mapOf("outcome" to "invalid-frame"),
)
return
}
serviceScope.launch {
withContext(Dispatchers.IO) {
try {
RootKeyStore(applicationContext).storeMasterKey(masterKeyBytes, fingerprint)
} finally {
masterKeyBytes.fill(0)
}
}
}
}

/**
* Sends `{type:"error-native",phase,message}` to Node for cross-process
* attribution. The backend re-broadcasts as an `error` frame to all control
Expand Down
Loading