diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt new file mode 100644 index 0000000..1f29ece --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt @@ -0,0 +1,105 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom + +/** + * ยง8.9b โ€” the member list, as something the relay cannot read. + * + * ๐Ÿ”ด **The relay used to hold `gid -> user_id` rows**, which is a readable + * social graph sitting in a table. It now holds an opaque blob and a random + * token per member. This is the client half: what goes into that blob, and how + * it comes back out. + * + * โš ๏ธ **This defeats a stolen database, not the operator.** Every fetch is + * authenticated, so a running relay still sees which account asks about which + * group. Nothing here changes that, and no copy anywhere should suggest it does. + * + * โš ๏ธ The JSON keys and the canonical signing string must match the Swift engine + * byte for byte, or a roster written on one platform cannot be opened or + * verified on the other. Guarded by a shared test vector. + */ +object SealedRoster { + + /** A group's members, their tags, and its name โ€” before sealing. */ + data class Contents( + val members: List, + val tags: Map, + val name: String, + ) + + private val random = SecureRandom() + + /** + * A group's key: one AES-256 key, minted by whoever creates the group and + * handed to each member over their pairwise ratchet. + * + * โš ๏ธ **Not derived from anything.** A key derived from the gid or the member + * list would change when the list did, and every older blob would stop + * opening. A random key that travels with the invitation does not. + */ + fun newGroupKey(): ByteArray = ByteArray(32).also { random.nextBytes(it) } + + /** + * A member's tag: 32 random bytes, meaning nothing. + * + * ๐Ÿ”ด Random rather than derived from the account id. Anything derived is a + * pseudonym the relay can link across groups โ€” the same account carrying a + * recognisable value everywhere, which is the graph again wearing a hash. + */ + fun newTag(): String = Bytes.b64(ByteArray(32).also { random.nextBytes(it) }) + + /** + * โš ๏ธ Keys are written in a FIXED order. `JSONObject` does not promise one, + * and a roster whose bytes depend on iteration order is a roster whose + * signature depends on it too. + */ + private fun encode(c: Contents): ByteArray { + val members = JSONArray().also { a -> c.members.forEach(a::put) } + val tags = JSONObject().also { o -> c.tags.toSortedMap().forEach { (k, v) -> o.put(k, v) } } + return ("""{"members":$members,"tags":$tags,"name":${JSONObject.quote(c.name)}}""") + .toByteArray() + } + + /** + * โš ๏ธ A fresh random nonce every time, and `Aead` takes it as a parameter + * precisely so a caller cannot quietly reuse one โ€” GCM does not degrade + * gracefully there. The box is `nonce || ciphertext || tag`, which is the + * same layout CryptoKit's `combined` produces, so a roster sealed on one + * platform opens on the other. + */ + fun seal(contents: Contents, key: ByteArray): String { + val nonce = ByteArray(12).also { random.nextBytes(it) } + return Bytes.b64(Aead.seal(key, nonce, encode(contents))) + } + + /** + * Open a roster the relay handed back. + * + * โš ๏ธ Throws rather than returning null on a blob that will not open. A group + * whose key we have lost is not an empty group, and treating it as one would + * silently drop every member from a conversation that still exists. + */ + fun open(blob: String, key: ByteArray): Contents { + val plain = Aead.open(key, Bytes.unb64(blob)) + val json = JSONObject(String(plain)) + val members = json.optJSONArray("members") ?: JSONArray() + val tagsJson = json.optJSONObject("tags") ?: JSONObject() + val tags = HashMap() + for (k in tagsJson.keys()) tags[k] = tagsJson.optString(k) + return Contents( + members = (0 until members.length()).map { members.getLong(it) }, + tags = tags, + name = json.optString("name"), + ) + } + + /** + * What an owner signs to prove a roster is theirs. Must match + * `Group.rosterCanonical` on the relay and `SealedRoster.canonical` on iOS + * exactly, or every signature this client makes is refused. + */ + fun canonical(gid: String, version: Int, blob: String): ByteArray = + "fngroup-v1|$gid|$version|$blob".toByteArray() +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt new file mode 100644 index 0000000..09dfdcd --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt @@ -0,0 +1,120 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * ยง8.9b โ€” the member list as something the relay cannot read. + * + * ๐Ÿ”ด The relay used to hold `gid -> user_id` rows: a readable social graph in a + * table. This covers the client half โ€” what goes into the opaque blob and how + * it comes back โ€” and the properties that make it worth doing. + * + * โš ๏ธ None of this hides membership from a running relay, which still sees who + * asks about what. It hides it from a stolen database. + */ +class SealedRosterTest { + + private fun contents() = SealedRoster.Contents( + members = listOf(41L, 42L, 43L), + tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()), + name = "Book club", + ) + + @Test + fun `a roster survives the round trip`() { + val key = SealedRoster.newGroupKey() + val original = contents() + val opened = SealedRoster.open(SealedRoster.seal(original, key), key) + assertEquals(original.members, opened.members) + assertEquals(original.tags, opened.tags) + assertEquals(original.name, opened.name) + } + + /** + * ๐Ÿ”ด The property the whole change rests on: the stored bytes say nothing. + * A dump that yields the member ids has bought nobody anything. + */ + @Test + fun `the sealed blob reveals nothing`() { + val blob = SealedRoster.seal(contents(), SealedRoster.newGroupKey()) + for (needle in listOf("41", "42", "Book club", "members", "tags")) { + assertFalse(blob.contains(needle), "$needle is readable in the sealed roster") + } + } + + /** + * โš ๏ธ A blob that will not open is an error, never an empty group. Treating + * it as empty would silently drop every member from a conversation that + * still exists. + */ + @Test + fun `the wrong key throws rather than returning nothing`() { + val blob = SealedRoster.seal(contents(), SealedRoster.newGroupKey()) + assertFailsWith { + SealedRoster.open(blob, SealedRoster.newGroupKey()) + } + } + + /** + * The relay stores these and could change them. Adding a member to + * somebody's roster is exactly the attack this refuses. + */ + @Test + fun `a tampered blob is refused`() { + val key = SealedRoster.newGroupKey() + val raw = Bytes.unb64(SealedRoster.seal(contents(), key)) + raw[raw.size / 2] = (raw[raw.size / 2].toInt() xor 0x01).toByte() + assertFailsWith { SealedRoster.open(Bytes.b64(raw), key) } + } + + /** + * ๐Ÿ”ด Tags are random, not derived from the account. Anything derived would + * be a pseudonym the relay could link across groups โ€” the same graph + * wearing a hash. + */ + @Test + fun `tags are unlinkable`() { + val tags = (0 until 50).map { SealedRoster.newTag() } + assertEquals(50, tags.toSet().size, "tags repeated") + tags.forEach { assertEquals(32, Bytes.unb64(it).size) } + } + + /** + * ๐Ÿ”ด **The bytes must match the Swift engine and the relay exactly.** A + * roster signed here is verified there; one character of difference and + * every signature this client makes is refused, with nothing in the error + * pointing at the format. + */ + @Test + fun `the signed bytes match the other implementations`() { + assertEquals( + "fngroup-v1|abc|7|Zm9v", + String(SealedRoster.canonical("abc", 7, "Zm9v"))) + } + + /** + * โš ๏ธ The encoding must not depend on map iteration order, or two devices + * sealing the same roster produce different bytes โ€” and a signature over + * one does not verify the other. + */ + @Test + fun `the encoding is stable regardless of tag insertion order`() { + val key = SealedRoster.newGroupKey() + val a = SealedRoster.Contents(listOf(1L, 2L), mapOf("1" to "x", "2" to "y"), "G") + val b = SealedRoster.Contents(listOf(1L, 2L), linkedMapOf("2" to "y", "1" to "x"), "G") + // Sealing uses a random nonce, so compare what comes back out instead. + assertEquals( + SealedRoster.open(SealedRoster.seal(a, key), key).tags, + SealedRoster.open(SealedRoster.seal(b, key), key).tags) + } + + /** Two groups minted by the same person share nothing. */ + @Test + fun `two groups share no key material`() { + assertFalse(SealedRoster.newGroupKey().contentEquals(SealedRoster.newGroupKey())) + } +}