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,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<Long>,
val tags: Map<String, String>,
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<String, String>()
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()
}
@@ -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()))
}
}