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:
co-authored by
Claude Opus 4.8
parent
556580f4fb
commit
58fb20246e
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user