package org.meshbay.client.bridge import org.json.JSONArray import org.json.JSONObject import org.meshbay.client.keys.DeviceKey import org.meshbay.client.keys.Keyring import org.meshbay.client.keys.SecretStore import org.meshbay.client.keys.Secrets /** * The device key, the account's bundle key and its identity on every node — * held here, never in the page (§8.2, §14.1 #20). The page is answered with * public keys, signatures and agreements; it names what it signs by kind and * fields, never by bytes (Transcripts). Arguments are checked as main.js does: * ids are ids, keys are keys. * * `confirm` is a dialog this process draws, worded from the interface's own * catalogues: what widens what leaves this device is never answered by the * page. */ class KeyChannels( private val secrets: Secrets, private val confirm: (String) -> Boolean, private val declined: () -> String, ) { private val device = DeviceKey(secrets) val keyring = Keyring( load = { val raw = secrets.read().optString(SecretStore.KEYRING_SLOT, "") if (raw.isEmpty()) null else try { JSONObject(raw) } catch (e: Exception) { null } }, save = { state -> secrets.update { it.put(SecretStore.KEYRING_SLOT, state.toString()) } }, ) // Only where the OS protects what is stored: an identity kept here and lost // at the next start would leave a node pinning a key nobody holds, so // without key storage the page keeps its keys the way a browser does. private fun available() = secrets.backend() != "unavailable" private fun needKeys() { if (!available()) throw Refused("No OS key storage") } fun handles(channel: String) = channel.startsWith("keys:") || channel.startsWith("device:") || channel == "secrets:backend" fun call(channel: String, a: JSONArray): Any? = when (channel) { "secrets:backend" -> secrets.backend() "device:ensure" -> device.ensure() "device:public" -> device.publicKey() "device:sign" -> device.sign(a.optString(0, "")) "device:forget" -> device.forget() "keys:available" -> available() "keys:derive-session" -> { needKeys() val o = a.optJSONObject(0) ?: JSONObject() keyring.deriveSession( password = o.optString("password", ""), username = o.optString("username", ""), userId = uid(o.opt("userId")), pepperB64 = o.optString("pepperB64", ""), pepperVersion = o.optInt("pepperVersion", 1).takeIf { it != 0 } ?: 1, pendingChange = o.optBoolean("pending", false)) } "keys:commit-pending" -> keyring.commitPending(uid(a.opt(0))) "keys:drop-pending" -> keyring.dropPending(uid(a.opt(0))) "keys:has-session" -> available() && keyring.hasSession(uid(a.opt(0))) "keys:forget-session" -> keyring.forgetSession(uid(a.opt(0))) "keys:identity" -> keyring.identity(uid(a.opt(0)), npk(a.opt(1)))?.let { JSONObject().put("pkEdB64", it.pkEdB64).put("pkXB64", it.pkXB64).put("sealedWith", it.sealedWith ?: JSONObject.NULL) } "keys:open-bundle" -> pub(keyring.openBundle(uid(a.opt(0)), npk(a.opt(1)), bundleEnc = a.optJSONObject(2)?.optString("bundleEnc", "") ?: "")) "keys:mint" -> { needKeys(); pub(keyring.mint(uid(a.opt(0)), npk(a.opt(1)))) } "keys:seal-bundle" -> keyring.sealBundle(uid(a.opt(0)), npk(a.opt(1)), usePending = a.optJSONObject(2)?.optBoolean("pending", false) ?: false).let { JSONObject().put("bundle", it.bundle).put("fingerprint", it.fingerprint) } "keys:seal-recovery" -> keyring.sealRecovery(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optString(3, "")) "keys:mark-sealed" -> keyring.markSealed(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, "")) "keys:fingerprint" -> keyring.currentFingerprint(uid(a.opt(0))) // By kind and fields: the page never names the bytes. "keys:sign" -> keyring.signAs(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, ""), a.optJSONObject(3) ?: JSONObject()) "keys:shared" -> keyring.shared(uid(a.opt(0)), npk(a.opt(1)), a.optString(2, "")) "keys:playlist-key" -> keyring.playlistKey(uid(a.opt(0))) "keys:browser-access" -> keyring.browserAccess(uid(a.opt(0))) // Turning it on leaves this account's identities on every node, sealed // for a browser: the person decides that here, in a dialog the page // cannot answer. Turning it off only narrows. "keys:set-browser-access" -> { val id = uid(a.opt(0)) val on = a.optBoolean(1, false) if (on && !keyring.browserAccess(id) && !confirm("native.browser_access_confirm")) throw Refused(declined()) keyring.setBrowserAccess(id, on) } // An account created on this device starts without browser access. // Only ever narrows, so the page may say it. "keys:created-here" -> keyring.setBrowserAccess(uid(a.opt(0)), false) else -> throw Refused("Refused: no such channel") } private fun pub(p: Keyring.Pub) = JSONObject().put("pkEdB64", p.pkEdB64).put("pkXB64", p.pkXB64) companion object { private val UID = Regex("^[0-9a-f-]{36}$", RegexOption.IGNORE_CASE) private val NPK = Regex("^[A-Za-z0-9+/=]{1,100}$") fun uid(v: Any?): String { val s = if (v == null || v == JSONObject.NULL) "" else v.toString() if (!UID.matches(s)) throw Refused("Refused: not an account id") return s } fun npk(v: Any?): String { val s = if (v == null || v == JSONObject.NULL) "" else v.toString() if (!NPK.matches(s)) throw Refused("Refused: not a node's key") return s } } }