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
130 changes: 130 additions & 0 deletions app/src/main/java/org/matrix/teesim/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ object App {
if (!restarting) ReAttest.run(cfg)
}
}
// The hook reports an rkp_only prop it saw reasserted despite a delete intent (#262);
// re-delete it live here, off the control reader thread, so the next provisioning reads
// it unset and takes the keybox path.
Control.onRkpReassert = { names -> reEnforceRkpDeletions(names) }
Control.start()
// Freeze the auto-include baseline (known_packages.json) at a known moment, before the
// first resolve reads it — so "future installs only" is measured from daemon-start, not
Expand All @@ -199,6 +203,17 @@ 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 deletes/restores an RKP-only property through the daemon (POST /rkp/*), so
// the live resetprop, the rkp.json delete-intent and the re-push that hands the new
// intent set to the hook form one atomic, lock-ordered step with the boot/push
// re-enforce.
KeyAdmin.onRkp = { action, name ->
when (action) {
"delete" -> deleteRkpProp(name)
"restore" -> restoreRkpProp(name)
else -> false
}
}
startUsagePoll()

SystemLogger.info("App: daemon initialised; entering main loop")
Expand Down Expand Up @@ -352,6 +367,7 @@ object App {
harvest = Harvester.applyUserOverrides(capturedBase, OverrideStore.load())
KeyAdmin.updateHarvest(harvest)
applyBootProps(harvest)
applyRkpDeletions()
try {
lastGoodConfig = ConfigStore.load()
} catch (e: ConfigStore.ConfigException) {
Expand Down Expand Up @@ -518,6 +534,120 @@ object App {
LogTail.start(File(moduleDir, "$abi/libteesim_logcat.so"))
}

/**
* Enforce the user's RKP-only *delete intents* (rkp.json): delete any `remote_provisioning.*`
* knob that is marked deleted yet reads back present. Those props are plain (not `persist.*`)
* and a device can ship them `true` in a vendor `.prop` that init re-applies every boot
* (OnePlus Android 16, #236), so a deleted prop reappears on the next boot. Runs from
* resolveAndPush — its first call is daemon start, and it runs again on every later push, so a
* reappearance is cleared at the next push. keystore2 and our hook both read an unset prop as
* false, so the keybox path holds with no retrigger. A device that re-asserts the prop between
* pushes, hours after boot (#262), is handled by [reEnforceRkpDeletions] off the hook's report
* instead.
*/
private fun applyRkpDeletions() {
for ((name, _) in RkpStore.load()) {
val live = DeviceProps.prop(name, "")
if (live.isEmpty()) continue // already absent
if (SysProp.delete(name))
SystemLogger.info("App: rkp prop re-deleted $name (reappeared as '$live')")
else SystemLogger.warning("App: rkp prop $name reappeared as '$live' but delete failed")
}
}

/**
* Delete the RKP-only knobs the hook reports it saw present despite a delete intent (#262),
* reported on the getUsage reply. Runs under the push lock, so it cannot interleave with
* [applyRkpDeletions] or a delete/restore. Keys off the intent store, not the reported names: a
* knob the user restored between the hook's report and this call carries no intent and is left
* alone.
*/
@Synchronized
fun reEnforceRkpDeletions(reported: List<String>) {
val intents = RkpStore.load()
for (name in reported) {
if (name !in intents) {
SystemLogger.info("App: rkp lazy re-delete skipped $name (no delete intent)")
continue
}
val live = DeviceProps.prop(name, "")
if (live.isEmpty()) continue // already absent
if (SysProp.delete(name))
SystemLogger.info("App: rkp lazy re-delete $name (reasserted to '$live')")
else SystemLogger.warning("App: rkp lazy re-delete $name failed (was '$live')")
}
}

/**
* Delete one RKP-only property live and record the delete intent, as one atomic step. Reached
* from the WebUI through `KeyAdmin`'s `POST /rkp/delete`. Because it is `@Synchronized` on the
* same monitor as [resolveAndPush] (and therefore [applyRkpDeletions]), the live delete and the
* persist can never interleave with a boot/re-push re-enforce. Captures the property's current
* value first so [restoreRkpProp] can put exactly that back. Records the intent only when the
* live delete took, so the file mirrors the live state. [name] is validated against
* [RkpStore.KNOWN] by the caller. Returns whether the property was deleted live.
*/
@Synchronized
fun deleteRkpProp(name: String): Boolean {
val was = DeviceProps.prop(name, "")
val ok = SysProp.delete(name)
if (ok) {
RkpStore.recordDelete(name, was)
SystemLogger.info(
"App: rkp prop deleted $name (was '${was.ifEmpty { "unset" }}'); intent persisted"
)
pushRkpIntents("delete $name")
} else {
SystemLogger.warning("App: could not delete rkp prop $name live; not recording intent")
}
return ok
}

/**
* Restore one deleted RKP-only property and clear its intent, atomically. Reached through `POST
* /rkp/restore`. Sets the property back to the value remembered at deletion, then drops the
* intent so [applyRkpDeletions] leaves it alone. When nothing was remembered the property was
* already absent when the intent was recorded, so restoring it means leaving it absent: the
* intent is dropped and no value is invented — writing a plausible-looking `true` onto a device
* that never declared the knob would leave exactly the anomalous reading deletion exists to
* avoid. Clears the intent only when the live set took. Returns whether the restore took.
*/
@Synchronized
fun restoreRkpProp(name: String): Boolean {
val remembered = RkpStore.load()[name].orEmpty()
if (remembered.isEmpty()) {
RkpStore.clear(name)
SystemLogger.info(
"App: rkp prop $name was already unset when deleted; intent cleared, leaving it unset"
)
pushRkpIntents("restore $name")
return true
}
val ok = SysProp.set(name, remembered)
if (ok) {
RkpStore.clear(name)
SystemLogger.info("App: rkp prop restored $name=$remembered; intent cleared")
pushRkpIntents("restore $name")
} else {
SystemLogger.warning("App: could not restore rkp prop $name live; intent kept")
}
return ok
}

/**
* Re-resolve and push after rkp.json changed, so the hook's delete-intent set (pushed as the
* config's `rkpDeleted`) matches the file again. Nothing else would: the DATA_DIR watcher only
* fires for config.json and keyboxes, so without this a fresh delete intent would not reach the
* hook until some unrelated push, and until then a reasserted property (#262) would read as a
* genuine rkp_only=true and never be reported for a lazy re-delete. Safe to call from the
* `@Synchronized` delete/restore: the monitor is reentrant, and by here rkp.json is already
* written, so the push carries the new intent set. [why] only labels the line in the log.
*/
private fun pushRkpIntents(why: String) {
SystemLogger.info("App: re-pushing config after rkp intent change ($why)")
resolveAndPush()
}

/** 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
8 changes: 8 additions & 0 deletions app/src/main/java/org/matrix/teesim/Const.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ object Const {
* 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 RKP-only delete intents ({ property: value-at-deletion }). 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-deletes an intent-marked prop that reappears; the stored value
* lets a later restore put exactly that back. enable_rkpd is persist.device_config, survives on
* its own, and is not managed here (the WebUI keeps it as a plain toggle).
*/
val rkpFile = File(DATA_DIR, "rkp.json")
val adminTokenFile = File(DATA_DIR, "admin.token")

/**
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/org/matrix/teesim/Control.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ object Control {
// Invoked (on [commitExecutor]) after the lib acks a config commit, so the daemon can re-attest
// pre-existing keys against the just-committed profile set. Coalesced by [commitPending].
@Volatile var onCommitted: (() -> Unit)? = null

// Invoked (on [commitExecutor]) when a getUsage reply reports rkp_only props the hook saw
// reasserted despite a delete intent (#262), so the daemon can re-delete them live. The names
// are always a subset of the known rkp_only knobs; the daemon re-validates.
@Volatile var onRkpReassert: ((List<String>) -> Unit)? = null
private val commitExecutor = Executors.newSingleThreadExecutor { r ->
Thread(r, "teesim-reattest").apply { isDaemon = true }
}
Expand Down Expand Up @@ -262,11 +267,38 @@ object Control {
// on PackageManager.
usageReplies.clear()
usageReplies.offer(msg)
// A reasserted rkp_only prop (the hook saw it live despite a delete intent) rides
// the same reply; re-delete off the reader thread so a resetprop can't stall it.
dispatchRkpReassert(msg.optJSONArray("rkpReassert"))
}
"pong" -> logPong(msg.optLong("epoch"))
}
}

// Hand any reasserted rkp_only prop names from a getUsage reply to [onRkpReassert], on the
// commit executor rather than this reader thread, so the daemon's resetprop --delete cannot
// stall frame reading. A no-op when the array is absent/empty or no handler is set.
private fun dispatchRkpReassert(arr: JSONArray?) {
if (arr == null || arr.length() == 0) return
val names = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) {
val name = arr.optString(i)
if (name.isNotEmpty()) names.add(name)
}
if (names.isEmpty()) return
val handler = onRkpReassert ?: return
SystemLogger.info(
"Control: rkp reassert reported for ${names.joinToString(",")}; re-deleting"
)
commitExecutor.execute {
try {
handler(names)
} catch (e: Throwable) {
SystemLogger.warning("Control: rkp reassert handler failed: ${e.message}")
}
}
}

// The keepalive answers every 30s for as long as the channel is up. A pong that reports a new
// epoch is logged; an unchanged one only every [PONG_LOG_EVERY]th, to show the channel is
// alive.
Expand Down
75 changes: 61 additions & 14 deletions app/src/main/java/org/matrix/teesim/KeyAdmin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,23 @@ import org.json.JSONObject
* 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 /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 = epoch ms of last request; recent = requested a key since this boot)
* GET /icon?pkg=P&token=T -> raw image/png (query-token auth, like /logs/download; 404 when the
* package has no icon) POST /usage/clear -> { ok, cleared } (wipes the frequency memory) POST
* /keys/db/delete?ids=1,2,3 -> { ok, deleted, requested } (removes those keyentry ids from
* keystore2, marker- and target-verified) GET /keys/inspect?alias=A -> { ok, alias,
* attestation{...} | null } POST /keys/delete?alias=A -> { ok, deleted } GET /logs?after=N&max=M ->
* { ok, lines:[{seq,level,tag,text}], nextAfter } GET /keybox/inspect?name=F -> { ok, name,
* deviceId, revocationListAvailable, keys:[{algorithm, privateKeyPresent, chainLength, linkage,
* rootAuthority(google|aosp|knox|unknown|none), googleSigned, chainVerified, revoked,
* revocationChecked, certs:[{index, subject, issuer, serial, notBefore, notAfter, expired, sigAlg,
* keyAlgorithm, keySize, isCa, selfSigned, signatureValid?, revocationChecked?, revoked?,
* being no package watcher) GET /rkp -> { ok, deleted:{ prop:value } } (the user's RKP-only delete
* intents, for the Restore control) POST /rkp/delete?name=P and POST /rkp/restore?name=P -> { ok,
* name, action } (delete one RKP-only prop + persist the intent, or restore it + clear the intent,
* 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 = epoch ms of last request; recent =
* requested a key since this boot) GET /icon?pkg=P&token=T -> raw image/png (query-token auth, like
* /logs/download; 404 when the package has no icon) POST /usage/clear -> { ok, cleared } (wipes the
* frequency memory) POST /keys/db/delete?ids=1,2,3 -> { ok, deleted, requested } (removes those
* keyentry ids from keystore2, marker- and target-verified) GET /keys/inspect?alias=A -> { ok,
* alias, attestation{...} | null } POST /keys/delete?alias=A -> { ok, deleted } GET
* /logs?after=N&max=M -> { ok, lines:[{seq,level,tag,text}], nextAfter } GET /keybox/inspect?name=F
* -> { ok, name, deviceId, revocationListAvailable, keys:[{algorithm, privateKeyPresent,
* chainLength, linkage, rootAuthority(google|aosp|knox|unknown|none), googleSigned, chainVerified,
* revoked, revocationChecked, certs:[{index, subject, issuer, serial, notBefore, notAfter, expired,
* sigAlg, keyAlgorithm, keySize, isCa, selfSigned, signatureValid?, revocationChecked?, revoked?,
* revocationStatus?, revocationReason?, rootAuthority?}]}] } GET /canary -> { ok, currentCode,
* latest{...}|null, updateAvailable } POST /canary/install?tag=&variant= -> { ok, message }
*/
Expand Down Expand Up @@ -100,6 +103,15 @@ object KeyAdmin {
*/
@Volatile var onRescan: (() -> Int)? = null

/**
* Set by [App] to its delete-and-restore for one RKP-only property, invoked by `POST
* /rkp/delete` and `POST /rkp/restore`. Running the live `resetprop`, the delete-intent write
* and the re-push that carries the new intent set to the hook in the daemon keeps all three
* atomic with the boot/push re-enforce. Takes the action ("delete"|"restore") and the real
* property name; returns whether the live change took.
*/
@Volatile var onRkp: ((String, String) -> Boolean)? = null

fun start(record: Harvester.Record) {
harvest = record
token = newToken()
Expand Down Expand Up @@ -431,6 +443,9 @@ object KeyAdmin {
method == "GET" && path == "/scope" -> scope()
method == "GET" && path == "/packages" -> packages()
method == "POST" && path == "/rescan" -> rescan()
method == "GET" && path == "/rkp" -> rkpList()
method == "POST" && path == "/rkp/delete" -> rkpAction("delete", query)
method == "POST" && path == "/rkp/restore" -> rkpAction("restore", query)
method == "POST" && path == "/usage/clear" -> usageClear()
method == "POST" && path == "/keys/db/delete" -> deleteDbKeys(query)
method == "GET" && path == "/keys/inspect" ->
Expand Down Expand Up @@ -703,6 +718,38 @@ object KeyAdmin {
return JSONObject().put("ok", true).put("uids", uids)
}

/**
* `GET /rkp` — the user's current RKP-only delete intents, `{ ok, deleted:{ <property>:
* <value-at-deletion> } }`, so the WebUI can render a Restore control for each deleted knob
* (the property itself reads back as unset, so the live value alone cannot tell "deleted" from
* "never shipped"). Read-only; a dirty read only feeds a repaint, so it does not take the App
* monitor.
*/
private fun rkpList(): JSONObject {
val deleted = JSONObject()
for ((name, value) in RkpStore.load()) deleted.put(name, value)
return JSONObject().put("ok", true).put("deleted", deleted)
}

/**
* `POST /rkp/delete?name=<property>` and `POST /rkp/restore?name=<property>` — delete one
* RKP-only property (and persist the intent) or restore it (and clear the intent), as one
* atomic step in [App.deleteRkpProp]/[App.restoreRkpProp]. The property name is checked against
* [RkpStore.KNOWN] here so a malformed or hostile request can never drive `resetprop` at an
* arbitrary property.
*/
private fun rkpAction(action: String, query: Map<String, String>): JSONObject {
val name = query["name"] ?: error("name required")
if (name !in RkpStore.KNOWN)
return JSONObject().put("ok", false).put("error", "unknown rkp property")
val hook = onRkp ?: return JSONObject().put("ok", false).put("error", "daemon not ready")
if (!hook(action, name))
return JSONObject()
.put("ok", false)
.put("error", "could not $action property (no working resetprop)")
return JSONObject().put("ok", true).put("name", name).put("action", action)
}

/**
* 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
9 changes: 9 additions & 0 deletions app/src/main/java/org/matrix/teesim/Resolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ object Resolver {
profiles.put(resolveProfile(p, scope, harvest))
}
msg.put("profiles", profiles)

// The rkp_only props the user deleted, so the hook can tell a genuine rkp_only=true from
// one a vendor .prop reasserted after a delete, and ask the daemon (via the getUsage
// reply) to re-delete it. Device-wide, full replace each push; empty when nothing is
// deleted.
val rkpDeleted = JSONArray()
for (name in RkpStore.load().keys) rkpDeleted.put(name)
msg.put("rkpDeleted", rkpDeleted)

// Published as the last act of building the message, so a resolve that throws part-way
// publishes nothing. There is no "push succeeded" moment to wait for instead: Control.push
// only stages the JSON for the connection thread, and actual delivery is confirmed much
Expand Down
Loading
Loading