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 class MapStorage : Vault.Storage { private val records = mutableMapOf() override fun read(name: String) = records[name] override fun write(name: String, bytes: ByteArray) { records[name] = bytes } override fun delete(name: String) { records.remove(name) } override fun names() = records.keys.toList() } private fun vault(): Vault = Vault( MapStorage(), javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), ).also { it.saveIdentity(DeviceIdentity.create()) } 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. * * โš ๏ธ **The values are long on purpose, and the search covers the decoded * bytes.** This sealed ids `41`/`42` and looked for `"41"`/`"42"` in the * BASE64 โ€” two characters out of a 64-symbol alphabet, so each had about a * 1-in-4096 chance at any position and roughly a **14% chance across a blob * this size**. It failed on exactly that, having passed for days: a coin * flip wearing the clothes of an assertion. The iOS twin had the same bug * and was fixed the same way; this one was missed because only one engine * was looked at. * * Nine-digit ids and a real-sounding name make a chance match impossible * rather than unlikely, and the byte-level search is the one that actually * states the property โ€” base64 could in principle hide a plaintext run the * raw bytes reveal. */ @Test fun `the sealed blob reveals nothing`() { val secret = SealedRoster.Contents( members = listOf(918273645L, 564738291L), tags = mapOf("918273645" to SealedRoster.newTag(), "564738291" to SealedRoster.newTag()), name = "Chemotherapy support", ) val blob = SealedRoster.seal(secret, SealedRoster.newGroupKey()) val raw = Bytes.unb64(blob) for (needle in listOf("918273645", "564738291", "Chemotherapy support", "members", "tags", "name")) { assertFalse(blob.contains(needle), "$needle is readable in the sealed roster") assertFalse( String(raw, Charsets.ISO_8859_1) .contains(String(needle.toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1)), "$needle is readable in the sealed bytes", ) } } /** * โš ๏ธ 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) } /** * ๐Ÿ”ด **A backup that loses the tags loses the groups for good.** The relay * cannot say what you are a member of, so the tag is the only way to fetch * a group and the key the only way to read it. Restoring the messages * without these gives a device that can read old conversations and can * never rejoin the groups they came from. */ @Test fun `an exported backup carries sealed group secrets`() { val old = vault() val secrets = Vault.GroupSecrets( tag = SealedRoster.newTag(), key = SealedRoster.newGroupKey(), ownerPrivate = null, version = 3) old.saveGroupSecrets("g1", secrets) val payload = old.exportPayload() val fresh = vault() assertEquals(null, fresh.groupSecrets("g1")) fresh.mergeImportedPayload(payload, 0.0) assertEquals(secrets, fresh.groupSecrets("g1")) } /** โš ๏ธ Gaps only โ€” a backup is older than the device by definition. */ @Test fun `a restored backup does not overwrite secrets we already hold`() { val old = vault() old.saveGroupSecrets("g1", Vault.GroupSecrets( tag = "old", key = SealedRoster.newGroupKey(), version = 1)) val payload = old.exportPayload() val current = vault() val live = Vault.GroupSecrets( tag = "live", key = SealedRoster.newGroupKey(), version = 9) current.saveGroupSecrets("g1", live) current.mergeImportedPayload(payload, 0.0) assertEquals(live, current.groupSecrets("g1")) } /** Two groups minted by the same person share nothing. */ @Test fun `two groups share no key material`() { assertFalse(SealedRoster.newGroupKey().contentEquals(SealedRoster.newGroupKey())) } }