Sealed roster primitives (§8.9b)

The group key, the member tag, and the blob. Mirrors the Swift engine —
same JSON shape, same canonical signing string, same nonce||ct||tag box
layout, so a roster sealed on either platform opens on the other. The
Swift suite pins that with a vector produced by this code.

Tags are random rather than derived from the account id: anything derived
is a pseudonym the relay can link across groups, which is the social graph
again wearing a hash.

The encoding writes its keys in a fixed order — `JSONObject` promises none,
and a roster whose bytes depend on map iteration order is a roster whose
signature depends on it too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 08:05:22 -04:00
co-authored by Claude Opus 4.8
parent 556580f4fb
commit 58fb20246e
2 changed files with 225 additions and 0 deletions
@@ -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<Aead.DecryptionFailed> {
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<Aead.DecryptionFailed> { 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()))
}
}