aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test/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/test/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/test/kotlin')
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt81
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt151
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt34
4 files changed, 277 insertions, 0 deletions
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt
new file mode 100644
index 0000000..86537bd
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakeSecrets.kt
@@ -0,0 +1,11 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.meshbay.client.keys.Secrets
+
+class FakeSecrets(var backendName: String = "android_keystore") : Secrets {
+ var all = JSONObject()
+ override fun backend() = backendName
+ override fun read() = JSONObject(all.toString())
+ override fun update(fn: (JSONObject) -> Unit) { val a = read(); fn(a); all = a }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt
new file mode 100644
index 0000000..c4333db
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyChannelsTest.kt
@@ -0,0 +1,81 @@
+package org.meshbay.client
+
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.bridge.KeyChannels
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.keys.Kdf
+
+class KeyChannelsTest {
+ private val user = "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0"
+ private val node = Kdf.b64(ByteArray(32) { 0x11 })
+ private var asked = 0
+ private var answer = false
+ private val secrets = FakeSecrets()
+ private val keys = KeyChannels(secrets, confirm = { asked++; answer }, declined = { "Cancelled" })
+
+ private fun call(ch: String, vararg args: Any?) = keys.call(ch, JSONArray(args.toList()))
+
+ @Test fun `ids and node keys are checked before anything is done`() {
+ for (bad in listOf("", "../x", "not-an-id", "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0x", null)) {
+ assertThrows("$bad", Refused::class.java) { call("keys:fingerprint", bad) }
+ }
+ for (bad in listOf("", "a/b c", "x".repeat(101), null)) {
+ assertThrows("$bad", Refused::class.java) { call("keys:identity", user, bad) }
+ }
+ }
+
+ @Test fun `the device key signs the bytes the hub verifies and never leaves`() {
+ val pub = call("device:ensure") as String
+ assertEquals(pub, call("device:ensure")) // once, then the same key
+ val s = call("device:sign", "alice") as JSONObject
+ val msg = "meshbay:user_auth:alice:${s.getLong("timestamp")}".toByteArray()
+ val v = Ed25519Signer().apply { init(false, Ed25519PublicKeyParameters(Kdf.unb64(pub), 0)); update(msg, 0, msg.size) }
+ assertTrue(v.verifySignature(Kdf.unb64(s.getString("signature"))))
+ call("device:forget")
+ assertNull(call("device:public"))
+ }
+
+ @Test fun `browser access is widened only by the person, natively`() {
+ call("keys:created-here", user)
+ assertEquals(false, call("keys:browser-access", user))
+ answer = false
+ val e = assertThrows(Refused::class.java) { call("keys:set-browser-access", user, true) }
+ assertEquals("Cancelled", e.message)
+ assertEquals(1, asked)
+ assertEquals(false, call("keys:browser-access", user))
+ answer = true
+ assertEquals(true, call("keys:set-browser-access", user, true))
+ // Narrowing asks nobody.
+ assertEquals(false, call("keys:set-browser-access", user, false))
+ assertEquals(2, asked)
+ }
+
+ @Test fun `without OS key storage nothing is minted or derived`() {
+ val none = KeyChannels(FakeSecrets("unavailable"), confirm = { true }, declined = { "" })
+ assertEquals(false, none.call("keys:available", JSONArray()))
+ assertThrows(Refused::class.java) { none.call("keys:mint", JSONArray(listOf(user, node))) }
+ assertEquals(false, none.call("keys:has-session", JSONArray(listOf(user))))
+ }
+
+ @Test fun `a minted identity answers with public keys only`() {
+ val r = call("keys:mint", user, node) as JSONObject
+ assertEquals(setOf("pkEdB64", "pkXB64"), r.keys().asSequence().toSet())
+ val id = call("keys:identity", user, node) as JSONObject
+ assertEquals(r.getString("pkEdB64"), id.getString("pkEdB64"))
+ assertEquals(JSONObject.NULL, id.get("sealedWith"))
+ }
+
+ @Test fun `channels outside the list are refused`() {
+ assertThrows(Refused::class.java) { call("keys:export") }
+ assertFalse(keys.handles("hub:fetch"))
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt
new file mode 100644
index 0000000..63edbde
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/KeyringVectorsTest.kt
@@ -0,0 +1,151 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.keys.Kdf
+import org.meshbay.client.keys.Keyring
+import org.meshbay.client.keys.Transcripts
+import java.io.File
+
+/**
+ * The keyring and the transcripts against meshbay-hub/tests/vectors/keyring.json,
+ * which the desktop keyring writes and the specification reproduces
+ * (test_keyring_vectors.py). Every deterministic field byte for byte, every
+ * refusal with its message: a bundle this sealed is one the page and the
+ * desktop open, and the reverse.
+ */
+class KeyringVectorsTest {
+ private var passed = 0
+ private val failures = ArrayList<String>()
+ private fun check(label: String, ok: Boolean, detail: () -> String = { "" }) {
+ if (ok) passed++ else failures.add("$label ${detail()}")
+ }
+ private fun <T> eq(label: String, got: T, want: T) = check(label, got == want) { "got=$got want=$want" }
+
+ @Test fun `every vector is reproduced`() {
+
+ val v = JSONObject(VECTORS.readText())
+ val input = v.getJSONObject("input")
+ val kdf = v.getJSONObject("kdf")
+ val bundles = v.getJSONObject("bundles")
+ val now = input.getLong("now").toDouble()
+ val userId = input.getString("userId")
+ val nodePk = input.getString("nodePk")
+ val username = input.getString("username")
+
+ // The store, as SecretStore would hold it.
+ var store = JSONObject()
+ var nonces: ArrayDeque<ByteArray> = ArrayDeque()
+ fun ring() = Keyring(
+ load = { JSONObject(store.toString()) },
+ save = { store = JSONObject(it.toString()) },
+ transcripts = Transcripts { now },
+ random = { n -> nonces.removeFirstOrNull() ?: ByteArray(n).also { java.security.SecureRandom().nextBytes(it) } },
+ )
+ val ring = ring()
+
+ // 1. KDF chain.
+ val salt = Kdf.sha256("meshbay:bundle:v2:$username".toByteArray()).copyOfRange(0, 16)
+ eq("salt", Kdf.toHex(salt), kdf.getString("salt_hex"))
+ ring.deriveSession(input.getString("password"), username, userId,
+ input.getString("pepperB64"), input.getInt("pepperVersion"))
+ val masters = store.getJSONObject("masters").getJSONObject(userId)
+ eq("argon2id", Kdf.toHex(Kdf.unb64(masters.getString("legacy"))), kdf.getString("argon2_hex"))
+ eq("M", Kdf.toHex(Kdf.unb64(masters.getString("m"))), kdf.getString("master_hex"))
+ eq("pepper version", masters.getInt("v"), input.getInt("pepperVersion"))
+ eq("fingerprint", ring.currentFingerprint(userId), kdf.getString("master_fingerprint"))
+ eq("node key", Kdf.toHex(Kdf.hkdf(Kdf.unb64(masters.getString("m")), "meshbay:bundle:v3|node|$nodePk")),
+ kdf.getString("node_key_hex"))
+ eq("playlist key", ring.playlistKey(userId), kdf.getString("playlist_key_b64"))
+
+ // 2. Identity: placed in the store as keyring.js would leave it.
+ val ident = v.getJSONObject("identity")
+ store.getJSONObject("identities").put(userId, JSONObject().put(nodePk,
+ JSONObject().put("ed", ident.getString("ed_pkcs8_b64")).put("x", ident.getString("x_pkcs8_b64"))
+ .put("sealedWith", JSONObject.NULL)))
+ val pub = ring.identity(userId, nodePk)!!
+ eq("pkEd", pub.pkEdB64, ident.getJSONObject("public").getString("pkEdB64"))
+ eq("pkX", pub.pkXB64, ident.getJSONObject("public").getString("pkXB64"))
+
+ // 3. Bundles: sealed byte for byte, and opened.
+ val fixedNonce = Kdf.hex(input.getString("fixedNonceHex"))
+ nonces.addLast(fixedNonce)
+ val sealed = ring.sealBundle(userId, nodePk)
+ eq("bundle sealed with a fixed nonce", sealed.bundle, bundles.getString("fixed_nonce_bundle_b64"))
+ eq("bundle fingerprint", sealed.fingerprint, bundles.getString("fixed_nonce_fingerprint"))
+ nonces.addLast(fixedNonce)
+ eq("recovery bundle", ring.sealRecovery(userId, nodePk, input.getString("mnemonic"), username),
+ bundles.getString("recovery_fixed_nonce_b64"))
+
+ fun opensTo(label: String, block: (Keyring) -> Keyring.Pub) {
+ val saved = store
+ store = JSONObject().put("masters", JSONObject().put(userId, masters)).put("identities", JSONObject())
+ .put("access", JSONObject())
+ try {
+ val p = block(ring())
+ check(label, p.pkEdB64 == pub.pkEdB64 && p.pkXB64 == pub.pkXB64) { "opened to another identity" }
+ check("$label leaves the identity unsealed", ring().identity(userId, nodePk)?.sealedWith == null)
+ } catch (e: Exception) {
+ check(label, false) { e.toString() }
+ } finally { store = saved }
+ }
+ opensTo("desktop bundle (fixed nonce) opens") { it.openBundle(userId, nodePk, bundles.getString("fixed_nonce_bundle_b64")) }
+ opensTo("MBK2 legacy bundle opens") { it.openBundle(userId, nodePk, bundles.getString("legacy_mbk2_b64")) }
+ val bogus = Kdf.b64("MBK3".toByteArray() + ByteArray(30) { 1 })
+ opensTo("recovery copy opens through the fallback") {
+ it.openBundle(userId, nodePk, bogus, bundles.getString("recovery_fixed_nonce_b64"),
+ input.getString("mnemonic"), username)
+ }
+ // A bundle Kotlin seals (random nonce) must open — round trip.
+ val ours = ring.sealBundle(userId, nodePk).bundle
+ opensTo("a bundle sealed here opens here") { it.openBundle(userId, nodePk, ours) }
+ // A retired format is refused, never tried against the recovery key.
+ try {
+ ring.openBundle(userId, nodePk, Kdf.b64("MBK1xxxxxxxxxxxxxxxxxxxxxxxxxxxxx".toByteArray()))
+ check("retired format refused", false)
+ } catch (e: Keyring.FormatRetired) { check("retired format refused", true) }
+
+ // Browser access off: nothing sealed.
+ ring.setBrowserAccess(userId, false)
+ try { ring.sealBundle(userId, nodePk); check("no bundle while browser access is off", false) }
+ catch (e: IllegalStateException) { check("no bundle while browser access is off", e.message!!.startsWith("Refused")) }
+ ring.setBrowserAccess(userId, true)
+
+ // 4. Agreement.
+ val ag = v.getJSONObject("agreement")
+ eq("X25519 agreement", ring.shared(userId, nodePk, ag.getString("peer_x_pub_b64")), ag.getString("shared_b64"))
+
+ // 5. Transcripts and signatures.
+ val tr = Transcripts { now }
+ val ctx = Transcripts.Ctx(userId, nodePk, pub.pkEdB64, pub.pkXB64)
+ val kinds = v.getJSONObject("transcripts")
+ for (kind in kinds.keys()) {
+ val t = kinds.getJSONObject(kind)
+ eq("transcript $kind", Kdf.toHex(tr.forKind(kind, t.getJSONObject("fields"), ctx)), t.getString("transcript_hex"))
+ eq("signature $kind", ring.signAs(userId, nodePk, kind, t.getJSONObject("fields")), t.getString("signature_b64"))
+ }
+ val refusals = v.getJSONArray("refusals")
+ for (i in 0 until refusals.length()) {
+ val r = refusals.getJSONObject(i)
+ try {
+ tr.forKind(r.getString("kind"), r.getJSONObject("fields"), ctx)
+ check("refusal '${r.getString("label")}'", false) { "was signed" }
+ } catch (e: Transcripts.Refused) {
+ eq("refusal '${r.getString("label")}' message", e.message, r.getString("error"))
+ }
+ }
+
+ // Sign-out drops M and keeps the identities.
+ ring.forgetSession(userId)
+ check("sign-out drops M", !ring.hasSession(userId))
+ check("sign-out keeps the identity", ring.identity(userId, nodePk) != null)
+ assertTrue("vector failures:\n" + failures.joinToString("\n"), failures.isEmpty())
+ assertTrue("too few checks ran: $passed", passed >= 55)
+ }
+
+ companion object {
+ // Unit tests run with the module directory as working directory.
+ val VECTORS = File("../../meshbay-hub/tests/vectors/keyring.json")
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt
new file mode 100644
index 0000000..9e0bc7e
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/NativeTextTest.kt
@@ -0,0 +1,34 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+import org.meshbay.client.shell.NativeText
+import java.io.File
+
+/** The application's own dialogs, worded from the real catalogues in every language. */
+class NativeTextTest {
+ private val locales = File("../../meshbay-hub/src/meshbay_hub/static/locales")
+ private val text = NativeText { code -> File(locales, "$code.js").takeIf { it.exists() }?.readText() }
+
+ @Test fun `every catalogue words the native dialogs`() {
+ val codes = locales.listFiles()!!.map { it.nameWithoutExtension }
+ assertEquals(10, codes.size)
+ for (code in codes) for (key in listOf("native.browser_access_confirm", "native.declined", "dialog.ok", "dialog.cancel")) {
+ assertNotEquals("$code $key", key, text.get(key, code))
+ }
+ }
+
+ @Test fun `escapes are read as the page reads them`() {
+ assertEquals("Annulé — rien n'a été modifié.", text.get("native.declined", "fr"))
+ }
+
+ @Test fun `an unknown language falls back to English, an unknown key to itself`() {
+ assertEquals("Cancel", text.get("dialog.cancel", "xx"))
+ assertEquals("no.such.key", text.get("no.such.key", "fr"))
+ }
+
+ @Test fun `plural entries give their other form and parameters are filled`() {
+ assertEquals("Use at least 12 characters", text.get("register.err_min_len", "en", mapOf("n" to "12")))
+ }
+}