diff --git a/app/src/main/java/org/matrix/teesim/App.kt b/app/src/main/java/org/matrix/teesim/App.kt index 3179e384..bfe9cb3f 100644 --- a/app/src/main/java/org/matrix/teesim/App.kt +++ b/app/src/main/java/org/matrix/teesim/App.kt @@ -168,6 +168,9 @@ object App { // no-ops from 34 on). So the set of installed apps is only ever re-read here, on a // config change, and at daemon start. KeyAdmin.onRescan = { resolveAndPush() } + // The WebUI's RKP toggles set their property through the daemon (POST /rkp) rather than shelling + // out themselves, so the live write and the rkp.json persist are one atomic, lock-ordered step. + KeyAdmin.onSetRkp = { name, value -> setRkpKnob(name, value) } startUsagePoll() SystemLogger.info("Daemon initialised; entering main loop") @@ -306,6 +309,7 @@ object App { harvest = Harvester.applyUserOverrides(capturedBase, OverrideStore.load()) KeyAdmin.updateHarvest(harvest) applyBootProps(harvest) + applyRkpProps() try { lastGoodConfig = ConfigStore.load() } catch (e: ConfigStore.ConfigException) { @@ -441,6 +445,49 @@ object App { LogTail.start(File(moduleDir, "$abi/libteesim_logcat.so")) } + /** + * Re-apply the user's persisted Remote Key Provisioning knobs (rkp.json) to their system properties, so a + * choice like "TEE RKP-only off" survives a reboot. The two `remote_provisioning.*.rkp_only` props are the + * point of this: they are plain (not `persist.*`), and though keystore2 defaults them to false when unset, + * a device can ship them set `true` in a vendor `.prop` that init re-applies every boot (OnePlus Android + * 16, #236) — so a live `resetprop … false` reverts on reboot. `…enable_rkpd` is `persist.device_config` + * and already survives on its own, so forcing it is a harmless no-op in the common case. Runs from + * resolveAndPush, whose first call is daemon start (after system_server is up). Mirrors [applyBootProps]: + * overwrite only on a live-vs-stored mismatch, so a steady state does no work. `resetprop -n` at boot is + * fine — no provisioning has read the prop yet. + */ + private fun applyRkpProps() { + for ((name, value) in RkpStore.load()) { + if (value.isEmpty()) continue // a knob with no chosen value: nothing to force + val live = DeviceProps.prop(name, "") + if (live != value) { + SysProp.set(name, value) + SystemLogger.info("rkp prop: forced $name to persisted '$value' (was '${live.ifEmpty { "unset" }}')") + } + } + } + + /** + * Set one Remote Key Provisioning knob live and persist the choice, as one atomic step. Reached from + * the WebUI through `KeyAdmin`'s `POST /rkp`, this replaces the WebUI writing the property and rkp.json + * out-of-process: because it is `@Synchronized` on the same monitor as [resolveAndPush] (and therefore + * [applyRkpProps]), the live write and the persist can never interleave with a boot/re-push re-force, so + * a concurrent push can no longer read a stale rkp.json and revert the toggle the user just made. Persist + * only when the live write took, so the file keeps mirroring the live property. [name] is validated + * against [RkpStore.KNOWN] by the caller. Returns whether the property was set live. + */ + @Synchronized + fun setRkpKnob(name: String, value: String): Boolean { + val ok = SysProp.set(name, value) + if (ok) { + RkpStore.save(name, value) + SystemLogger.info("rkp prop: set $name=$value (live + persisted)") + } else { + SystemLogger.warning("rkp prop: could not set $name live; not persisting") + } + return ok + } + /** Where the inject binary + native libs live: args[0], else the dex dir, else default. */ private fun resolveModuleDir(args: Array): File { args.firstOrNull()?.let { diff --git a/app/src/main/java/org/matrix/teesim/Const.kt b/app/src/main/java/org/matrix/teesim/Const.kt index 6bbb02b4..c74d7e2a 100644 --- a/app/src/main/java/org/matrix/teesim/Const.kt +++ b/app/src/main/java/org/matrix/teesim/Const.kt @@ -12,6 +12,10 @@ object Const { /** User edits to the harvest override layer (device ids, synthesized levels, an all-zero boot key). * Written by the WebUI, merged over the frozen captured harvest on every push. */ val overridesFile = File(DATA_DIR, "overrides.json") + /** The user's persisted Remote Key Provisioning knobs ({ property: "true"/"false" }). The two rkp_only + * props are non-persist and a vendor .prop can re-apply a default (often true) each boot (#236), so the + * daemon re-forces them on boot; enable_rkpd is persist.device_config and already survives on its own. */ + val rkpFile = File(DATA_DIR, "rkp.json") val adminTokenFile = File(DATA_DIR, "admin.token") /** Filesystem unix socket the WebUI reaches KeyAdmin on, in the root-only [DATA_DIR] (0700). Unlike a diff --git a/app/src/main/java/org/matrix/teesim/KeyAdmin.kt b/app/src/main/java/org/matrix/teesim/KeyAdmin.kt index 261d5de7..5cbf0e50 100644 --- a/app/src/main/java/org/matrix/teesim/KeyAdmin.kt +++ b/app/src/main/java/org/matrix/teesim/KeyAdmin.kt @@ -52,7 +52,8 @@ import org.json.JSONObject * the last push actually targets, per profile; the only place auto-included uids are visible, since the * rule needs the root-only known_packages.json baseline. Empty profiles[] with epoch 0 before the first * push) POST /rescan -> { ok, uids } (re-resolve against the live device and re-push; how a newly - * installed app is discovered, there being no package watcher) GET + * installed app is discovered, there being no package watcher) POST /rkp?name=P&on=true|false -> + * { ok, name, value } (set one RKP knob live and persist it, atomically; name must be a known knob) GET * /packages -> { ok, firstAppUid, apps:[ {uid, packages:[..], label, system, launchable, enabled, * installTime, freq, lastUsed, recent} ] } (every installed app, one entry per uid, for the Scope * picker: installTime = epoch ms of first install; freq = persistent key-request count; lastUsed = @@ -98,6 +99,14 @@ object KeyAdmin { */ @Volatile var onRescan: (() -> Int)? = null + /** + * Set by [App] to its live-set-and-persist for one RKP knob, and invoked by `POST /rkp`. Routing the + * WebUI's toggle through the daemon (rather than the WebUI shelling out `resetprop` and writing rkp.json + * itself) makes the live write and the persist atomic with respect to the boot/re-push re-force. Takes + * the real property name and the canonical "true"/"false"; returns whether the property was set live. + */ + @Volatile var onSetRkp: ((String, String) -> Boolean)? = null + fun start(record: Harvester.Record) { harvest = record token = newToken() @@ -342,6 +351,7 @@ object KeyAdmin { method == "GET" && path == "/scope" -> scope() method == "GET" && path == "/packages" -> packages() method == "POST" && path == "/rescan" -> rescan() + method == "POST" && path == "/rkp" -> setRkp(query) method == "POST" && path == "/usage/clear" -> usageClear() method == "POST" && path == "/keys/db/delete" -> deleteDbKeys(query) method == "GET" && path == "/keys/inspect" -> @@ -606,6 +616,27 @@ object KeyAdmin { return JSONObject().put("ok", true).put("uids", uids) } + /** + * `POST /rkp?name=&on=` — set one Remote Key Provisioning knob live and persist it, + * as one atomic step in [App.setRkpKnob]. The property name is checked against [RkpStore.KNOWN] here so a + * malformed or hostile request can never drive `resetprop` at an arbitrary property; `on` must be canonical. + * Replaces the WebUI shelling out `resetprop` + writing rkp.json itself, closing the stale-read revert race. + */ + private fun setRkp(query: Map): JSONObject { + val name = query["name"] ?: error("name required") + val on = query["on"] ?: error("on required") + if (name !in RkpStore.KNOWN) + return JSONObject().put("ok", false).put("error", "unknown rkp property") + if (on != "true" && on != "false") + return JSONObject().put("ok", false).put("error", "on must be true or false") + val hook = + onSetRkp + ?: return JSONObject().put("ok", false).put("error", "daemon not ready") + if (!hook(name, on)) + return JSONObject().put("ok", false).put("error", "could not set property (no working resetprop)") + return JSONObject().put("ok", true).put("name", name).put("value", on) + } + /** * Stream the rendered PNG icon for `?pkg=`, looked up in `?user=` (default 0, so an app that only * exists in a work profile still resolves). Validates the package shape before touching diff --git a/app/src/main/java/org/matrix/teesim/RkpStore.kt b/app/src/main/java/org/matrix/teesim/RkpStore.kt new file mode 100644 index 00000000..32c3533e --- /dev/null +++ b/app/src/main/java/org/matrix/teesim/RkpStore.kt @@ -0,0 +1,78 @@ +package org.matrix.teesim + +import java.io.File +import org.json.JSONObject + +/** + * The user's persisted Remote Key Provisioning knobs, at [Const.rkpFile]. A flat `{ property: "true"/"false" }` + * map keyed by the real system-property name (e.g. `remote_provisioning.tee.rkp_only`). The daemon owns both + * ends: [App.setRkpKnob] writes it (live `resetprop` + this store, atomically) and [App.applyRkpProps] re-forces + * it at boot, so the file always mirrors what is actually on the device. + * + * The point of persisting is the two `remote_provisioning.*.rkp_only` props: they are plain (not `persist.*`), + * so a live write reverts to the vendor default on reboot — and a device can ship them `true` in a `.prop` + * that init re-applies every boot (#236). `…enable_rkpd` is `persist.device_config` and already survives on its + * own; re-forcing it is a harmless no-op. + * + * Not internally locked: every read ([load]) and write ([save]) happens on the App monitor (`resolveAndPush` + * and `setRkpKnob` are both `@Synchronized`), so access is already serialized and the two can never interleave. + */ +object RkpStore { + + /** The only property names we will ever force or persist — a hostile/stale key in rkp.json is ignored, + * so [App.applyRkpProps] can never be steered to set an arbitrary property. Mirrors the WebUI's list. */ + val KNOWN = + setOf( + "remote_provisioning.tee.rkp_only", + "remote_provisioning.strongbox.rkp_only", + "persist.device_config.remote_key_provisioning_native.enable_rkpd", + ) + + /** The persisted knobs, or empty when the file is absent/unreadable/malformed. Never throws: a broken + * rkp.json must not take the daemon's push path down — it just means "no persisted RKP choices". Keys + * outside [KNOWN] are dropped on read, so a stale/hand-edited entry can never reach [App.applyRkpProps]. */ + fun load(): Map { + val f = Const.rkpFile + if (!f.exists()) return emptyMap() + return try { + val o = JSONObject(f.readText()) + val m = LinkedHashMap() + for (k in o.keys()) if (k in KNOWN) m[k] = o.optString(k, "") + SystemLogger.info("RKP knobs loaded: ${m.entries.joinToString(",") { "${it.key}=${it.value}" }.ifEmpty { "(none)" }}") + m + } catch (e: Exception) { + SystemLogger.warning("Could not read rkp.json; ignoring persisted RKP knobs", e) + emptyMap() + } + } + + /** + * Record one knob's chosen value, preserving the others. Read-modify-write of the whole file, written + * atomically (temp + rename) so a crash mid-write can never leave a torn rkp.json. Called only from + * [App.setRkpKnob], after the live `resetprop` succeeded, so the file mirrors the live property. Rejects + * a name outside [KNOWN] rather than persist something [load] would just drop. Never throws — a failed + * persist is logged and leaves the live value in place (it simply won't survive the next reboot). + */ + fun save(name: String, value: String) { + if (name !in KNOWN) { + SystemLogger.warning("Refusing to persist unknown RKP knob '$name'") + return + } + try { + val current = LinkedHashMap(load()) + current[name] = value + val o = JSONObject() + for ((k, v) in current) o.put(k, v) + val f = Const.rkpFile + val tmp = File(f.parentFile, "${f.name}.tmp") + tmp.writeText(o.toString(2) + "\n") + if (!tmp.renameTo(f)) { + tmp.copyTo(f, overwrite = true) + tmp.delete() + } + SystemLogger.info("RKP knob persisted: $name=$value") + } catch (e: Exception) { + SystemLogger.warning("Could not persist RKP knob $name; it will not survive a reboot", e) + } + } +} diff --git a/module/webroot/js/data/keyadmin.js b/module/webroot/js/data/keyadmin.js index 9a4dba63..f18c589a 100644 --- a/module/webroot/js/data/keyadmin.js +++ b/module/webroot/js/data/keyadmin.js @@ -178,6 +178,13 @@ const canaryInstallQuery = (args) => "?tag=" + encodeURIComponent((args && args.tag) || "") + "&variant=" + encodeURIComponent((args && args.variant) || "release"); +// One RKP knob: the real property name and the desired boolean. The daemon re-validates the name +// against its known-knob set and sets it live + persists it atomically (POST /rkp), so a stale-read +// re-force can never revert the toggle. `on` is normalised to canonical "true"/"false". +const setRkpQuery = (args) => + "?name=" + encodeURIComponent((args && args.name) || "") + + "&on=" + ((args && args.on) ? "true" : "false"); + // The save target: the chosen folder and filename, both encoded so any character is inert. // The log text itself rides in the request body (too large for a query), not here. const logsWriteQuery = (args) => @@ -214,6 +221,10 @@ export async function keyAdmin(action, args = {}) { // app is discovered — there is no package observer in the daemon, so the Profiles screen's // pull-to-refresh is what goes and looks. Resolves to { ok, uids }. return request("POST", "/rescan"); + case "setRkp": + // Set one RKP knob live and persist it in one daemon-owned, lock-ordered step, so the live write + // and the rkp.json record cannot interleave with a boot re-force. Resolves to { ok, name, value }. + return request("POST", "/rkp" + setRkpQuery(args)); case "keysDbDelete": return request("POST", "/keys/db/delete" + idsQuery(args)); case "inspect": diff --git a/module/webroot/js/data/rkp-io.js b/module/webroot/js/data/rkp-io.js index 8fd8d752..1dc6451d 100644 --- a/module/webroot/js/data/rkp-io.js +++ b/module/webroot/js/data/rkp-io.js @@ -1,9 +1,10 @@ -// Remote-key-provisioning knobs adapter. It reads and writes the handful of system properties that -// decide how keystore2 sources attestation keys, so the Keyboxes screen can show and flip them. The -// property NAMES here are fixed literals, never derived from user input; only the boolean the user -// toggles reaches setProp (as canonical "true"/"false"). No DOM. +// Remote-key-provisioning knobs adapter. It reads the handful of system properties that decide how +// keystore2 sources attestation keys, and flips them through the daemon (POST /rkp), so the Keyboxes +// screen can show and toggle them. The property NAMES here are fixed literals, never derived from user +// input; the daemon re-validates the name and sets it live + persists it atomically. No DOM. -import { getProp, setProp } from "../bridge/shell.js"; +import { getProp } from "../bridge/shell.js"; +import { keyAdmin } from "./keyadmin.js"; // The properties we surface, in display order. `key` is a stable id for the view; `name` is the real // system property; `label`/`help` describe it. The two rkp_only knobs force a security level down the @@ -45,8 +46,17 @@ export async function readRkpProps() { return rows; } -// Flip one knob, written as canonical "true"/"false". Callers re-read afterwards so the UI reflects -// the value the device actually took (a failed write leaves the old value and the switch snaps back). +// Flip one knob. The daemon owns both the live write and the persist: POST /rkp sets the property with +// resetprop and records the choice in rkp.json as one atomic, lock-ordered step, so a concurrent re-push +// can never read a half-updated file and revert the toggle. The rkp_only props are plain (not persist.*) +// and would otherwise revert on reboot; the persisted value is what App.applyRkpProps re-forces each boot. +// +// Never throws: on any failure it returns { ok:false, error } so the caller's re-read simply snaps the +// switch back to the value the device actually took (the daemon leaves the old value on a failed set). export async function setRkpProp(name, on) { - return setProp(name, on ? "true" : "false"); + try { + return await keyAdmin("setRkp", { name, on }); + } catch (e) { + return { ok: false, error: e && e.message }; + } }