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,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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user