Skip to content
Open
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
47 changes: 47 additions & 0 deletions app/src/main/java/org/matrix/teesim/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<String>): File {
args.firstOrNull()?.let {
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/org/matrix/teesim/Const.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion app/src/main/java/org/matrix/teesim/KeyAdmin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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" ->
Expand Down Expand Up @@ -606,6 +616,27 @@ object KeyAdmin {
return JSONObject().put("ok", true).put("uids", uids)
}

/**
* `POST /rkp?name=<property>&on=<true|false>` — 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<String, String>): 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
Expand Down
78 changes: 78 additions & 0 deletions app/src/main/java/org/matrix/teesim/RkpStore.kt
Original file line number Diff line number Diff line change
@@ -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<String, String> {
val f = Const.rkpFile
if (!f.exists()) return emptyMap()
return try {
val o = JSONObject(f.readText())
val m = LinkedHashMap<String, String>()
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)
}
}
}
11 changes: 11 additions & 0 deletions module/webroot/js/data/keyadmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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":
Expand Down
26 changes: 18 additions & 8 deletions module/webroot/js/data/rkp-io.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 };
}
}
Loading