aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-02 13:46:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-03 14:24:54 +0200
commit78f3208db5e7285e62151cecb06c2d3c9eb4d2ae (patch)
tree66598c28931761831363192e9411cf87215aee99 /packages/meshbay-android/app/src/main/kotlin
parent3af2c0205071ea74fd7f2b1b1bbe4d47cdd1357b (diff)
downloadmeshbay-78f3208db5e7285e62151cecb06c2d3c9eb4d2ae.tar.gz
feat(android): device key, bundle key and node identities held natively
Keystore-wrapped store, a Kotlin port of keyring.js and transcripts.js held to the shared vectors, the same keys/device/secrets bridge as the desktop, and a native confirmation before browser access is widened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app/src/main/kotlin')
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt35
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt4
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt117
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt47
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt106
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt266
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt120
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt183
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt48
9 files changed, 923 insertions, 3 deletions
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index f6f9740..9892cae 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -25,9 +25,13 @@ import androidx.webkit.WebViewFeature
import org.json.JSONObject
import org.meshbay.client.bridge.Bridge
import org.meshbay.client.bridge.Channels
+import org.meshbay.client.bridge.KeyChannels
import org.meshbay.client.hub.HubClient
+import org.meshbay.client.keys.SecretStore
import org.meshbay.client.shell.EngineCheck
+import org.meshbay.client.shell.NativeText
import org.meshbay.client.shell.UiAssets
+import java.util.concurrent.CountDownLatch
/**
* The shell: one WebView showing the packaged interface, and the bridge.
@@ -40,6 +44,10 @@ class MainActivity : Activity() {
private lateinit var root: FrameLayout
private lateinit var web: WebView
private lateinit var hub: HubClient
+ private lateinit var channels: Channels
+ private val text = NativeText { code ->
+ try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null }
+ }
private var shim: ScriptHandler? = null
private var fullscreen: View? = null
private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null
@@ -59,8 +67,10 @@ class MainActivity : Activity() {
root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
configure(web)
- val channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
- hasCatalogue = { code -> hasAsset("ui/locales/$code.js") })
+ val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively,
+ declined = { text.get("native.declined", channels.locale) })
+ channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
+ hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
installShim()
web.loadUrl(UiAssets.START)
@@ -151,6 +161,27 @@ class MainActivity : Activity() {
web.loadUrl(UiAssets.START)
}
+ /**
+ * A confirmation this process draws (main.js confirmNatively). Called from
+ * a bridge worker, never the UI thread, which it waits on. The keyboard is
+ * handed back to the page afterwards: after a dialog the document can stay
+ * unfocused and every keystroke go nowhere (CLAUDE.md, ask.js).
+ */
+ private fun confirmNatively(key: String): Boolean {
+ val done = CountDownLatch(1)
+ var accepted = false
+ runOnUiThread {
+ android.app.AlertDialog.Builder(this)
+ .setMessage(text.get(key, channels.locale))
+ .setPositiveButton(text.get("dialog.ok", channels.locale)) { _, _ -> accepted = true }
+ .setNegativeButton(text.get("dialog.cancel", channels.locale), null)
+ .setOnDismissListener { web.requestFocus(); done.countDown() }
+ .show()
+ }
+ done.await()
+ return accepted
+ }
+
private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false }
private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream())
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
index 81be25e..736e434 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -19,6 +19,7 @@ class Channels(
private val hub: HubClient,
private val onHubChanged: () -> Unit,
private val hasCatalogue: (String) -> Boolean,
+ private val keys: KeyChannels? = null,
) {
@Volatile var locale = "en"
private set
@@ -28,7 +29,8 @@ class Channels(
"hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1))
"ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray())
"ui:locale" -> setLocale(args.optString(0, ""))
- else -> throw Refused("Refused: no such channel")
+ else -> if (keys != null && keys.handles(channel)) keys.call(channel, args)
+ else throw Refused("Refused: no such channel")
}
private fun setLocale(code: String): String {
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt
new file mode 100644
index 0000000..f11b98c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/KeyChannels.kt
@@ -0,0 +1,117 @@
+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
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt
new file mode 100644
index 0000000..4cd840c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/DeviceKey.kt
@@ -0,0 +1,47 @@
+package org.meshbay.client.keys
+
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import org.json.JSONObject
+import java.security.SecureRandom
+
+/**
+ * The device's key for signing in to the hub (E3): generated, held and used
+ * here, never handed to the page, which asks for a signature over
+ * `meshbay:user_auth:<username>:<ts>` — the bytes `POST /v1/users/auth`
+ * verifies. Not a per-node identity: nothing here correlates a person across
+ * operators (main.js `device:*`).
+ */
+class DeviceKey(private val store: Secrets, private val now: () -> Long = { System.currentTimeMillis() / 1000 }) {
+
+ private fun current(): Ed25519PrivateKeyParameters? {
+ val stored = store.read().optString(SecretStore.DEVICE_KEY, "")
+ return if (stored.isEmpty()) null else Kdf.edFromPkcs8(Kdf.unb64(stored))
+ }
+
+ private fun publicOf(k: Ed25519PrivateKeyParameters) = Kdf.b64(k.generatePublicKey().encoded)
+
+ fun ensure(): String {
+ current()?.let { return publicOf(it) }
+ val k = Ed25519PrivateKeyParameters(SecureRandom())
+ store.update { it.put(SecretStore.DEVICE_KEY, Kdf.b64(Kdf.edToPkcs8(k))) }
+ return publicOf(k)
+ }
+
+ fun publicKey(): String? = current()?.let { publicOf(it) }
+
+ fun sign(username: String): JSONObject? {
+ val k = current() ?: return null
+ val ts = now()
+ // The username is inside the signature, so one collected for another
+ // account is not usable.
+ val message = "meshbay:user_auth:$username:$ts".toByteArray(Charsets.UTF_8)
+ val s = Ed25519Signer().apply { init(true, k); update(message, 0, message.size) }
+ return JSONObject().put("timestamp", ts).put("signature", Kdf.b64(s.generateSignature()))
+ }
+
+ fun forget(): Boolean {
+ store.update { it.remove(SecretStore.DEVICE_KEY) }
+ return true
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt
new file mode 100644
index 0000000..30f094e
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Kdf.kt
@@ -0,0 +1,106 @@
+package org.meshbay.client.keys
+
+import org.bouncycastle.crypto.digests.SHA256Digest
+import org.bouncycastle.crypto.generators.Argon2BytesGenerator
+import org.bouncycastle.crypto.generators.HKDFBytesGenerator
+import org.bouncycastle.crypto.params.Argon2Parameters
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.HKDFParameters
+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
+import java.util.Base64
+import javax.crypto.Cipher
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+/**
+ * The primitives keyring.js takes from node:crypto and the vendored Argon2,
+ * with the same numbers. keyderive.js (the page), keyring.js (desktop) and this
+ * are one format: a mismatch looks like an account nobody can open, not like
+ * an error. meshbay-hub/tests/vectors/keyring.json holds them together.
+ */
+object Kdf {
+ // keyderive.js: the same numbers, or no bundle opens across the clients.
+ const val ARGON2_MEMORY_KIB = 131072
+ const val ARGON2_PASSES = 3
+ const val ARGON2_PARALLELISM = 1
+ const val ARGON2_TAG = 32
+
+ private val ED_PKCS8_PREFIX = hex("302e020100300506032b657004220420")
+ private val X_PKCS8_PREFIX = hex("302e020100300506032b656e04220420")
+
+ // One derivation at a time: 128 MiB each, on a phone. (Two concurrent
+ // lanes=4 derivations deadlock inside OpenSSL on the hub — CLAUDE.md; not
+ // this library, but there is no reason to find out.)
+ @Synchronized
+ fun argon2id(password: String, salt: ByteArray): ByteArray {
+ val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
+ .withVersion(Argon2Parameters.ARGON2_VERSION_13)
+ .withIterations(ARGON2_PASSES)
+ .withMemoryAsKB(ARGON2_MEMORY_KIB)
+ .withParallelism(ARGON2_PARALLELISM)
+ .withSalt(salt)
+ .build()
+ val gen = Argon2BytesGenerator()
+ gen.init(params)
+ val out = ByteArray(ARGON2_TAG)
+ gen.generateBytes(password.toByteArray(Charsets.UTF_8), out)
+ return out
+ }
+
+ /** node:crypto hkdfSync('sha256', ikm, <empty salt>, info, 32). */
+ fun hkdf(ikm: ByteArray, info: String): ByteArray {
+ val gen = HKDFBytesGenerator(SHA256Digest())
+ gen.init(HKDFParameters(ikm, null, info.toByteArray(Charsets.UTF_8)))
+ val out = ByteArray(32)
+ gen.generateBytes(out, 0, 32)
+ return out
+ }
+
+ fun sha256(data: ByteArray): ByteArray {
+ val d = SHA256Digest()
+ d.update(data, 0, data.size)
+ val out = ByteArray(32)
+ d.doFinal(out, 0)
+ return out
+ }
+
+ /** AES-256-GCM, 16-byte tag appended — the layout node:crypto's getAuthTag gives. */
+ fun gcmSeal(key: ByteArray, nonce: ByteArray, plain: ByteArray, aad: ByteArray?): ByteArray {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))
+ if (aad != null) c.updateAAD(aad)
+ return c.doFinal(plain)
+ }
+
+ fun gcmOpen(key: ByteArray, nonce: ByteArray, ctAndTag: ByteArray, aad: ByteArray?): ByteArray {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce))
+ if (aad != null) c.updateAAD(aad)
+ return c.doFinal(ctAndTag)
+ }
+
+ // Keys are stored as Node exports them: PKCS#8 DER, RFC 8410, 48 bytes, no
+ // public key attached. Written out by hand because a library's own PKCS#8
+ // encoder may add the optional public key, and the stored format is one.
+ fun edToPkcs8(k: Ed25519PrivateKeyParameters) = ED_PKCS8_PREFIX + k.encoded
+ fun xToPkcs8(k: X25519PrivateKeyParameters) = X_PKCS8_PREFIX + k.encoded
+
+ fun edFromPkcs8(der: ByteArray): Ed25519PrivateKeyParameters {
+ require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(ED_PKCS8_PREFIX)) {
+ "not an Ed25519 PKCS#8 key"
+ }
+ return Ed25519PrivateKeyParameters(der, 16)
+ }
+
+ fun xFromPkcs8(der: ByteArray): X25519PrivateKeyParameters {
+ require(der.size == 48 && der.copyOfRange(0, 16).contentEquals(X_PKCS8_PREFIX)) {
+ "not an X25519 PKCS#8 key"
+ }
+ return X25519PrivateKeyParameters(der, 16)
+ }
+
+ fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)
+ fun unb64(s: String?): ByteArray = Base64.getDecoder().decode(s ?: "")
+ fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
+ fun toHex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt
new file mode 100644
index 0000000..fa8ff71
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Keyring.kt
@@ -0,0 +1,266 @@
+package org.meshbay.client.keys
+
+import org.bouncycastle.crypto.agreement.X25519Agreement
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
+import org.bouncycastle.crypto.params.X25519PublicKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import org.json.JSONObject
+import java.security.SecureRandom
+import org.meshbay.client.keys.Kdf.b64
+import org.meshbay.client.keys.Kdf.hkdf
+import org.meshbay.client.keys.Kdf.unb64
+
+/**
+ * The account's keys on Android: the bundle master key `M` and the identity on
+ * every node, held here and never handed to the page. A port of
+ * meshbay-client/src/keyring.js — same state shape (masters, identities,
+ * access), same formats, same refusals — so the two read side by side.
+ *
+ * Storage is injected (load/save of one JSON object), as on desktop; the app
+ * keeps it in SecretStore (Keystore-wrapped). `random` is injected so the
+ * vectors can pin a nonce.
+ */
+class Keyring(
+ private val load: () -> JSONObject?,
+ private val save: (JSONObject) -> Unit,
+ private val transcripts: Transcripts = Transcripts(),
+ private val random: (Int) -> ByteArray = { n -> ByteArray(n).also { SecureRandom().nextBytes(it) } },
+) {
+ class Pub(val pkEdB64: String, val pkXB64: String, val sealedWith: String? = null)
+ class Sealed(val bundle: String, val fingerprint: String)
+ class FormatRetired : IllegalStateException("bundle_format_retired")
+
+ private class Master(val m: ByteArray, val v: Int)
+ private class Identity(val ed: String, val x: String)
+
+ // In memory: the key of a passphrase change not yet accepted by the hub.
+ private val pending = HashMap<String, Master>()
+
+ private fun state(): JSONObject {
+ val s = load() ?: JSONObject()
+ for (k in listOf("masters", "identities", "access")) if (!s.has(k)) s.put(k, JSONObject())
+ return s
+ }
+
+ private fun master(userId: String, usePending: Boolean = false): Master {
+ if (usePending) pending[userId]?.let { return it }
+ val m = state().getJSONObject("masters").optJSONObject(userId)
+ ?: throw IllegalStateException("no bundle key in this session")
+ return Master(unb64(m.getString("m")), m.getInt("v"))
+ }
+
+ private fun fingerprint(m: ByteArray) = Kdf.toHex(Kdf.sha256(m)).substring(0, 16)
+
+ private fun stored(userId: String, nodePk: String): Identity {
+ val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk)
+ ?: throw IllegalStateException("no identity for this node")
+ return Identity(id.getString("ed"), id.getString("x"))
+ }
+
+ private fun publicOf(id: Identity) = Pub(
+ b64(Kdf.edFromPkcs8(unb64(id.ed)).generatePublicKey().encoded),
+ b64(Kdf.xFromPkcs8(unb64(id.x)).generatePublicKey().encoded),
+ )
+
+ private fun accessOn(userId: String) = state().getJSONObject("access").opt(userId) != false
+ private fun needAccess(userId: String) {
+ if (!accessOn(userId)) throw IllegalStateException("Refused: browser access is off for this account")
+ }
+
+ private fun keep(userId: String, nodePk: String, put: (JSONObject) -> Unit) {
+ val s = state()
+ val ids = s.getJSONObject("identities")
+ val forUser = ids.optJSONObject(userId) ?: JSONObject().also { ids.put(userId, it) }
+ val entry = forUser.optJSONObject(nodePk) ?: JSONObject().also { forUser.put(nodePk, it) }
+ put(entry)
+ save(s)
+ }
+
+ private fun aad(userId: String, nodePk: String) =
+ "meshbay:bundle:v3|$userId|$nodePk".toByteArray(Charsets.UTF_8)
+
+ // Exactly JSON.stringify({ skEd, skX }): base64 needs no escaping, and the
+ // sealed bytes are then comparable with the desktop's in tests.
+ private fun plaintext(id: Identity) = "{\"skEd\":\"${id.ed}\",\"skX\":\"${id.x}\"}"
+
+ private fun seal(id: Identity, key: ByteArray, userId: String, nodePk: String, pepperVersion: Int): String {
+ val nonce = random(12)
+ val ct = Kdf.gcmSeal(key, nonce, plaintext(id).toByteArray(Charsets.UTF_8), aad(userId, nodePk))
+ return b64(MAGIC + byteArrayOf((pepperVersion and 0xff).toByte()) + nonce + ct)
+ }
+
+ private fun parse(plain: ByteArray): Identity {
+ val o = JSONObject(String(plain, Charsets.UTF_8))
+ return Identity(o.getString("skEd"), o.getString("skX"))
+ }
+
+ private fun open(bundleB64: String, key: ByteArray, userId: String, nodePk: String): Identity {
+ val raw = unb64(bundleB64)
+ if (raw.size < 4 || !raw.copyOfRange(0, 4).contentEquals(MAGIC)) throw FormatRetired()
+ return parse(Kdf.gcmOpen(key, raw.copyOfRange(5, 17), raw.copyOfRange(17, raw.size), aad(userId, nodePk)))
+ }
+
+ /** TRANSITIONAL — MBK2: "MBK2" ‖ nonce ‖ AES-GCM under the Argon2 key, no AAD. */
+ private fun openLegacy(bundleB64: String, key: ByteArray): Identity {
+ val raw = unb64(bundleB64)
+ return parse(Kdf.gcmOpen(key, raw.copyOfRange(4, 16), raw.copyOfRange(16, raw.size), null))
+ }
+
+ private fun fromMnemonic(mnemonic: String): ByteArray {
+ val clean = mnemonic.replace(Regex("[^A-Za-z2-7]"), "").uppercase()
+ var bits = 0
+ var value = 0
+ val out = ArrayList<Byte>()
+ for (ch in clean) {
+ value = (value shl 5) or B32.indexOf(ch)
+ bits += 5
+ if (bits >= 8) { out.add(((value ushr (bits - 8)) and 0xff).toByte()); bits -= 8 }
+ }
+ if (out.size < 32) throw IllegalArgumentException("recovery key too short")
+ return out.subList(0, 32).toByteArray()
+ }
+
+ // ── The API, in keyring.js order ────────────────────────────────────────
+
+ fun hasSession(userId: String) = state().getJSONObject("masters").has(userId)
+
+ /** `M` from the passphrase and the pepper — one Argon2 run, as in the page. */
+ fun deriveSession(password: String, username: String, userId: String, pepperB64: String?,
+ pepperVersion: Int?, pendingChange: Boolean = false): Boolean {
+ if (userId.isEmpty() || pepperB64.isNullOrEmpty()) {
+ throw IllegalStateException("the hub did not provide the bundle pepper")
+ }
+ val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray(Charsets.UTF_8)).copyOfRange(0, 16)
+ val a = Kdf.argon2id(password, salt)
+ val m = hkdf(a + unb64(pepperB64), "meshbay:bundle-master:v3|$userId")
+ val v = if (pepperVersion == null || pepperVersion == 0) 1 else pepperVersion
+ if (pendingChange) { pending[userId] = Master(m, v); return true }
+ val s = state()
+ // `legacy` (TRANSITIONAL): the Argon2 key MBK2 bundles were sealed under.
+ s.getJSONObject("masters").put(userId, JSONObject().put("m", b64(m)).put("v", v).put("legacy", b64(a)))
+ save(s)
+ return true
+ }
+
+ fun commitPending(userId: String): Boolean {
+ val p = pending[userId] ?: return false
+ val s = state()
+ val legacy = s.getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "")
+ val entry = JSONObject().put("m", b64(p.m)).put("v", p.v)
+ if (!legacy.isNullOrEmpty()) entry.put("legacy", legacy)
+ s.getJSONObject("masters").put(userId, entry)
+ save(s)
+ pending.remove(userId)
+ return true
+ }
+
+ fun dropPending(userId: String) = pending.remove(userId) != null
+
+ /** Sign-out: `M` goes. The identities stay — they are this device's. */
+ fun forgetSession(userId: String): Boolean {
+ val s = state()
+ s.getJSONObject("masters").remove(userId)
+ save(s)
+ pending.remove(userId)
+ return true
+ }
+
+ fun identity(userId: String, nodePk: String): Pub? {
+ val id = state().getJSONObject("identities").optJSONObject(userId)?.optJSONObject(nodePk) ?: return null
+ val pub = publicOf(Identity(id.getString("ed"), id.getString("x")))
+ val sw = id.opt("sealedWith")
+ return Pub(pub.pkEdB64, pub.pkXB64, if (sw is String) sw else null)
+ }
+
+ fun openBundle(userId: String, nodePk: String, bundleEnc: String, recoveryEnc: String? = null,
+ recoveryMnemonic: String? = null, username: String? = null): Pub {
+ val raw = unb64(bundleEnc)
+ if (raw.size >= 4 && raw.copyOfRange(0, 4).contentEquals(LEGACY_MAGIC)) {
+ val legacy = state().getJSONObject("masters").optJSONObject(userId)?.optString("legacy", "")
+ if (legacy.isNullOrEmpty()) throw IllegalStateException("no_legacy_key")
+ val id = openLegacy(bundleEnc, unb64(legacy))
+ keepIdentity(userId, nodePk, id)
+ return publicOf(id)
+ }
+ val m = master(userId).m
+ val id = try {
+ open(bundleEnc, hkdf(m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk)
+ } catch (e: Exception) {
+ if (e is FormatRetired || recoveryEnc.isNullOrEmpty() || recoveryMnemonic.isNullOrEmpty()) throw e
+ val rk = hkdf(fromMnemonic(recoveryMnemonic), "meshbay:recovery:v1:${username ?: ""}")
+ open(recoveryEnc, rk, userId, nodePk)
+ }
+ keepIdentity(userId, nodePk, id)
+ return publicOf(id)
+ }
+
+ private fun keepIdentity(userId: String, nodePk: String, id: Identity) =
+ keep(userId, nodePk) { it.put("ed", id.ed).put("x", id.x).put("sealedWith", JSONObject.NULL) }
+
+ fun mint(userId: String, nodePk: String): Pub {
+ val rnd = SecureRandom()
+ val id = Identity(b64(Kdf.edToPkcs8(Ed25519PrivateKeyParameters(rnd))),
+ b64(Kdf.xToPkcs8(X25519PrivateKeyParameters(rnd))))
+ keepIdentity(userId, nodePk, id)
+ return publicOf(id)
+ }
+
+ /** The identity sealed for its node, under `M` (or the pending one). */
+ fun sealBundle(userId: String, nodePk: String, usePending: Boolean = false): Sealed {
+ needAccess(userId)
+ val m = master(userId, usePending)
+ val bundle = seal(stored(userId, nodePk), hkdf(m.m, "meshbay:bundle:v3|node|$nodePk"), userId, nodePk, m.v)
+ return Sealed(bundle, fingerprint(m.m))
+ }
+
+ /** The recovery copy: sealed under the recovery key, owing nothing to `M`. */
+ fun sealRecovery(userId: String, nodePk: String, mnemonic: String, username: String): String {
+ needAccess(userId)
+ val rk = hkdf(fromMnemonic(mnemonic), "meshbay:recovery:v1:$username")
+ return seal(stored(userId, nodePk), rk, userId, nodePk, 0)
+ }
+
+ fun markSealed(userId: String, nodePk: String, fp: String?): Boolean {
+ keep(userId, nodePk) { it.put("sealedWith", if (fp.isNullOrEmpty()) JSONObject.NULL else fp) }
+ return true
+ }
+
+ fun currentFingerprint(userId: String) = fingerprint(master(userId).m)
+
+ /** Sign what `kind` names, built from `fields` (Transcripts). */
+ fun signAs(userId: String, nodePk: String, kind: String, fields: JSONObject?): String {
+ val id = stored(userId, nodePk)
+ val pub = publicOf(id)
+ val transcript = transcripts.forKind(kind, fields, Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64))
+ val signer = Ed25519Signer()
+ signer.init(true, Kdf.edFromPkcs8(unb64(id.ed)))
+ signer.update(transcript, 0, transcript.size)
+ return b64(signer.generateSignature())
+ }
+
+ fun shared(userId: String, nodePk: String, peerPkB64: String): String {
+ val agreement = X25519Agreement()
+ agreement.init(Kdf.xFromPkcs8(unb64(stored(userId, nodePk).x)))
+ val out = ByteArray(32)
+ agreement.calculateAgreement(X25519PublicKeyParameters(unb64(peerPkB64), 0), out, 0)
+ return b64(out)
+ }
+
+ fun playlistKey(userId: String) = b64(hkdf(master(userId).m, "meshbay:playlists:v2"))
+
+ fun browserAccess(userId: String) = accessOn(userId)
+ fun setBrowserAccess(userId: String, on: Boolean): Boolean {
+ val s = state()
+ s.getJSONObject("access").put(userId, on)
+ save(s)
+ return on
+ }
+
+ companion object {
+ private val MAGIC = "MBK3".toByteArray()
+ // TRANSITIONAL — the format before MBK3, read once to be replaced.
+ private val LEGACY_MAGIC = "MBK2".toByteArray()
+ private const val B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt
new file mode 100644
index 0000000..5a2c1bb
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/SecretStore.kt
@@ -0,0 +1,120 @@
+package org.meshbay.client.keys
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.security.keystore.StrongBoxUnavailableException
+import android.util.Log
+import org.json.JSONObject
+import java.io.File
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * The application's secrets, at rest: the desktop's safeStorage blob, here
+ * wrapped by an AES-256-GCM key that lives in Android Keystore and never leaves
+ * it (in StrongBox where the device has one).
+ *
+ * One file, `files/secrets.bin` = nonce ‖ ciphertext ‖ tag over the JSON of
+ * every slot (`device_key`, `keyring` — main.js's slots), replaced atomically.
+ * Nothing in it is reachable from the page: it holds the device's hub key.
+ *
+ * Honest without protection, as on desktop: if the Keystore cannot be used,
+ * `backend()` says `unavailable` and nothing is stored — the page then keeps
+ * its keys the way a browser does, rather than this downgrading silently.
+ */
+/** What the keys need of their storage; SecretStore on a device, a map in tests. */
+interface Secrets {
+ fun backend(): String
+ fun read(): JSONObject
+ fun update(fn: (JSONObject) -> Unit)
+}
+
+class SecretStore(private val context: Context) : Secrets {
+ private val file get() = File(context.filesDir, "secrets.bin")
+ @Volatile private var strongBox = false
+
+ private fun key(): SecretKey? = try {
+ val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ (ks.getKey(ALIAS, null) as SecretKey?) ?: generate()
+ } catch (e: Exception) {
+ Log.w(TAG, "Keystore unusable", e)
+ null
+ }
+
+ private fun generate(): SecretKey {
+ fun spec(strong: Boolean) = KeyGenParameterSpec.Builder(ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ // Usable without a prompt: the app answers the hub on its own, as
+ // the desktop keychain does once the session is unlocked.
+ .setUserAuthenticationRequired(false)
+ .setRandomizedEncryptionRequired(true)
+ .apply { if (strong) setIsStrongBoxBacked(true) }
+ .build()
+ val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
+ return try {
+ gen.init(spec(true)); gen.generateKey().also { strongBox = true }
+ } catch (e: StrongBoxUnavailableException) {
+ gen.init(spec(false)); gen.generateKey()
+ }
+ }
+
+ override fun backend(): String {
+ if (key() == null) return "unavailable"
+ return if (strongBox || isStrongBox()) "android_strongbox" else "android_keystore"
+ }
+
+ private fun isStrongBox(): Boolean = try {
+ val k = key() ?: return false
+ val info = javax.crypto.SecretKeyFactory.getInstance(k.algorithm, "AndroidKeyStore")
+ .getKeySpec(k, android.security.keystore.KeyInfo::class.java) as android.security.keystore.KeyInfo
+ if (android.os.Build.VERSION.SDK_INT >= 31) info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX else false
+ } catch (e: Exception) { false }
+
+ @Synchronized
+ override fun read(): JSONObject {
+ val k = key() ?: return JSONObject()
+ val raw = try { file.readBytes() } catch (e: java.io.FileNotFoundException) { return JSONObject() }
+ return try {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.DECRYPT_MODE, k, GCMParameterSpec(128, raw, 0, 12))
+ JSONObject(String(c.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8))
+ } catch (e: Exception) {
+ // A store that does not open is reported, never overwritten: what
+ // is in it is a device key and identities nodes have pinned.
+ throw IllegalStateException("the key store does not open", e)
+ }
+ }
+
+ @Synchronized
+ fun write(all: JSONObject) {
+ val k = key() ?: throw IllegalStateException("No OS key storage")
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(Cipher.ENCRYPT_MODE, k)
+ val sealed = c.iv + c.doFinal(all.toString().toByteArray(Charsets.UTF_8))
+ val tmp = File(context.filesDir, "secrets.bin.tmp")
+ tmp.writeBytes(sealed)
+ if (!tmp.renameTo(file)) throw IllegalStateException("could not replace the key store")
+ }
+
+ /** One slot, read and replaced under the same lock. */
+ @Synchronized
+ override fun update(fn: (JSONObject) -> Unit) {
+ val all = read()
+ fn(all)
+ write(all)
+ }
+
+ companion object {
+ private const val TAG = "MeshBay"
+ private const val ALIAS = "meshbay.secrets.v1"
+ const val DEVICE_KEY = "device_key"
+ const val KEYRING_SLOT = "keyring"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
new file mode 100644
index 0000000..4d183f7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt
@@ -0,0 +1,183 @@
+package org.meshbay.client.keys
+
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.util.Base64
+import kotlin.math.abs
+
+/**
+ * What a node identity signs, built here from named fields — never bytes the
+ * page chose. A port of meshbay-client/src/transcripts.js, byte for byte; the
+ * shapes it checks and the refusals it gives are the same, and
+ * meshbay-hub/tests/vectors/keyring.json is what holds them (and
+ * meshbay_common) together.
+ *
+ * `now` is injected so the vectors can pin the clock; production passes the
+ * system clock.
+ */
+class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / 1000.0 }) {
+
+ class Refused(what: String) : IllegalArgumentException("Refused: $what")
+
+ data class Ctx(val userId: String, val nodePk: String, val pkEdB64: String, val pkXB64: String)
+
+ private fun refuse(what: String): Nothing = throw Refused(what)
+
+ private fun enc(s: String) = s.toByteArray(Charsets.UTF_8)
+
+ private fun lenPrefixed(prefix: String, parts: List<ByteArray>): ByteArray {
+ val out = ByteArrayOutputStream()
+ out.write(prefix.toByteArray(Charsets.UTF_8))
+ for (p in parts) {
+ out.write(byteArrayOf((p.size ushr 24).toByte(), (p.size ushr 16).toByte(),
+ (p.size ushr 8).toByte(), p.size.toByte()))
+ out.write(p)
+ }
+ return out.toByteArray()
+ }
+
+ // JavaScript's String(v ?? '') over a value that came through JSON.
+ private fun jsString(v: Any?): String = when (v) {
+ null, JSONObject.NULL -> ""
+ is String -> v
+ is Boolean -> v.toString()
+ is Int, is Long -> v.toString()
+ is Number -> {
+ val d = v.toDouble()
+ if (d == Math.floor(d) && !d.isInfinite() && abs(d) < 1e21) d.toLong().toString() else d.toString()
+ }
+ else -> v.toString()
+ }
+
+ // JavaScript's Number(v), for the values a timestamp or an epoch can arrive as.
+ private fun jsNumber(v: Any?): Double = when (v) {
+ null, JSONObject.NULL -> if (v == null) Double.NaN else 0.0
+ is Number -> v.toDouble()
+ is Boolean -> if (v) 1.0 else 0.0
+ is String -> v.trim().let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() ?: Double.NaN }
+ else -> Double.NaN
+ }
+
+ private fun isInteger(d: Double) = !d.isNaN() && !d.isInfinite() && d == Math.floor(d)
+
+ private val base64Shape = Regex("^[A-Za-z0-9+/]*={0,2}$")
+
+ private fun bytes(v: Any?, what: String, min: Int = 1, max: Int = 64): ByteArray {
+ val s = jsString(v)
+ if (!base64Shape.matches(s)) refuse("$what is not base64")
+ // Node's decoder is lenient where Java's throws (a lone trailing
+ // character). Both outcomes are a refusal: Node's yields a short
+ // buffer that the length check below refuses.
+ val b = try { Base64.getDecoder().decode(s) } catch (e: IllegalArgumentException) {
+ refuse("$what has the wrong length")
+ }
+ if (b.size < min || b.size > max) refuse("$what has the wrong length")
+ return b
+ }
+
+ private fun key32(v: Any?, what: String): String {
+ bytes(v, what, 32, 32)
+ return jsString(v)
+ }
+
+ private fun text(v: Any?, what: String, max: Int = 256): String {
+ val s = jsString(v)
+ if (s.length > max) refuse("$what is too long") // UTF-16 units, as JS counts
+ return s
+ }
+
+ private val groupShape = Regex("^[A-Za-z0-9_-]{1,64}$")
+ private fun groupId(v: Any?): String {
+ val s = jsString(v)
+ if (s.isNotEmpty() && !groupShape.matches(s)) refuse("not a group id")
+ return s
+ }
+
+ private fun timestamp(v: Any?): String {
+ val n = jsNumber(v)
+ if (!isInteger(n) || abs(n - now()) > TS_SLACK_S) refuse("the timestamp is not now")
+ return jsString(n.toLong())
+ }
+
+ fun forKind(kind: String, f: JSONObject?, ctx: Ctx): ByteArray {
+ val fields = f ?: JSONObject()
+ fun field(name: String): Any? = if (fields.has(name)) fields.get(name) else null
+ fun sameNode(): String {
+ if (jsString(field("nodePk")) != ctx.nodePk) refuse("another node")
+ return ctx.nodePk
+ }
+ fun sameUser(): String {
+ if (jsString(field("userId")) != ctx.userId) refuse("another account")
+ return ctx.userId
+ }
+ fun nonceNode() = bytes(field("nonceNode"), "the node nonce", 16, 64)
+
+ return when (kind) {
+ "join" -> lenPrefixed(PREFIX_JOIN, listOf(
+ enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()),
+ enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(field("ts")))))
+ "device_hello" -> lenPrefixed(PREFIX_DEVICE_HELLO, listOf(
+ enc(sameNode()), enc(groupId(field("groupId"))), enc(sameUser()),
+ enc(ctx.pkEdB64), nonceNode(), enc(timestamp(field("ts")))))
+ "device_request" -> {
+ val codeHash = jsString(field("codeHash"))
+ if (!Regex("^[0-9a-f]{64}$").matches(codeHash)) refuse("not a request hash")
+ lenPrefixed(PREFIX_DEVICE_REQUEST, listOf(
+ enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64),
+ enc(codeHash), nonceNode(), enc(timestamp(field("ts")))))
+ }
+ "device_add" -> lenPrefixed(PREFIX_DEVICE_ADD, listOf(
+ enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")),
+ enc(key32(field("pkX"), "the device key")), nonceNode(), enc(timestamp(field("ts")))))
+ "device_revoke" -> lenPrefixed(PREFIX_DEVICE_REVOKE, listOf(
+ enc(sameNode()), enc(sameUser()), enc(key32(field("pkEd"), "the device key")),
+ nonceNode(), enc(timestamp(field("ts")))))
+ "chat" -> {
+ val epoch = jsNumber(field("epoch"))
+ if (!isInteger(epoch) || epoch < 0) refuse("not an epoch")
+ lenPrefixed(PREFIX_CHAT, listOf(
+ enc(groupId(field("groupId"))), enc(jsString(epoch.toLong())),
+ Base64.getDecoder().decode(ctx.pkEdB64),
+ bytes(field("nonce"), "the message nonce", 12, 24),
+ bytes(field("ct"), "the message", 1, 8 * 1024 * 1024)))
+ }
+ "admin" -> {
+ val op = jsString(field("op"))
+ if (op !in ADMIN_OPS) refuse("not an operation")
+ lenPrefixed(PREFIX_ADMIN, listOf(
+ enc(op), enc(sameNode()), enc(groupId(field("groupId"))),
+ enc(text(field("subject"), "the subject", 16384)),
+ bytes(field("nonce"), "the challenge nonce", 16, 64),
+ enc(timestamp(field("ts")))))
+ }
+ else -> refuse("nothing is signed as \"${kind.take(32)}\"")
+ }
+ }
+
+ companion object {
+ // The node's clock and ours: a signature for a moment far from now is one to keep for later.
+ const val TS_SLACK_S = 600
+
+ // The signed operations this application asks a node to perform
+ // (meshbay_common/adminop.py) — transcripts.js's list, held equal by
+ // test_android_keys.py. A list, not a pattern: what widens a node's
+ // sharing (root_add, root_update, group_attach — gone from MNP 6.0) is
+ // never signed here, so a script in the page cannot drive an older node
+ // into it either.
+ val ADMIN_OPS = setOf(
+ "file_delete", "dir_delete", "invite_create", "invite_link_create",
+ "invite_cancel", "member_revoke", "apps_enabled", "set_scan_settings",
+ "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch",
+ "musicbrainz_enabled", "root_remove", "root_eject", "root_plug",
+ "app_directories", "chat_directory", "chat_link_preview", "search_listed",
+ "chat_epoch",
+ )
+ const val PREFIX_JOIN = "meshbay:join:v1"
+ const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1"
+ const val PREFIX_DEVICE_ADD = "meshbay:device_add:v1"
+ const val PREFIX_DEVICE_REVOKE = "meshbay:device_revoke:v1"
+ const val PREFIX_DEVICE_HELLO = "meshbay:device_hello:v1"
+ const val PREFIX_CHAT = "meshbay:chat:v1"
+ const val PREFIX_ADMIN = "meshbay:admin:v1"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt
new file mode 100644
index 0000000..ae23e46
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/NativeText.kt
@@ -0,0 +1,48 @@
+package org.meshbay.client.shell
+
+/**
+ * A sentence from the interface's own catalogues, for the few things the
+ * application draws itself (main.js `nativeText`). The page chooses the
+ * language and nothing else: a confirmation it worded would be one it could
+ * answer for itself.
+ *
+ * The catalogues are `export default { 'key': '…', … }` with single-quoted
+ * strings; a plural entry is an object, of which `other` is taken.
+ */
+class NativeText(private val readCatalogue: (String) -> String?) {
+
+ fun get(key: String, locale: String, params: Map<String, String> = emptyMap()): String {
+ var text = pick(readCatalogue(locale), key) ?: pick(readCatalogue("en"), key) ?: key
+ // split/join, as i18n.js: a folder name may contain `$&`.
+ for ((k, v) in params) text = text.split("{$k}").joinToString(v)
+ return text
+ }
+
+ companion object {
+ fun pick(source: String?, key: String): String? {
+ source ?: return null
+ val k = Regex.escape(key)
+ Regex("""(?m)^\s*'$k'\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) }
+ Regex("""(?ms)^\s*'$k'\s*:\s*\{.*?\bother\s*:\s*'((?:[^'\\]|\\.)*)'""").find(source)?.let { return unescape(it.groupValues[1]) }
+ return null
+ }
+
+ fun unescape(s: String): String {
+ val out = StringBuilder()
+ var i = 0
+ while (i < s.length) {
+ val c = s[i]
+ if (c == '\\' && i + 1 < s.length) {
+ val n = s[i + 1]
+ when (n) {
+ 'n' -> out.append('\n'); 't' -> out.append('\t')
+ 'u' -> if (i + 5 < s.length) { out.append(s.substring(i + 2, i + 6).toInt(16).toChar()); i += 4 }
+ else -> out.append(n)
+ }
+ i += 2
+ } else { out.append(c); i++ }
+ }
+ return out.toString()
+ }
+ }
+}