Group names live on the devices, not the relay

The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder. The real name is held in the
sealed vault and travels between members: on every group envelope, and as
an announcement riding the `delivery_key` control kind at creation and on
adding a member.

`delivery_key` rather than a new kind because a new one would arrive on
every phone running v5 as a bubble full of raw JSON.

An announced name is recorded as a CLAIM, not adopted. Conversations does
not know the rosters, and a name taken on the sender's word would let
anyone who can reach you write whatever they liked into your chat list
under a group heading you have no reason to doubt. Session checks it
against the relay's roster and only then stores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 20:24:03 -04:00
co-authored by Claude Opus 4.8
parent 8061f54e96
commit 4a6be0ba6b
9 changed files with 525 additions and 22 deletions
@@ -44,6 +44,25 @@ class Conversations(
* clears it to know the avatar map needs re-reading. */
@Volatile var avatarsUpdated: Boolean = false
/**
* Group names contacts have announced, awaiting a membership check.
*
* 🔴 **Claims, not facts.** Anyone who can reach us can send one of these,
* naming any gid they like, and a name adopted on the sender's word alone
* would put whatever a stranger wants into somebody's chat list — a place
* to write a lie where the reader has no reason to doubt it. The roster
* lives a layer up, so the check does too; see `Session.drainGroupNames`.
*/
private val claimedGroupNames = mutableListOf<Triple<Long, String, String>>()
/** Take the pending claims, leaving none behind. */
fun takeClaimedGroupNames(): List<Triple<Long, String, String>> =
synchronized(claimedGroupNames) {
val out = claimedGroupNames.toList()
claimedGroupNames.clear()
out
}
/** One decrypted message, interpreted, and where it came from. */
data class Received(
val id: Long,
@@ -136,7 +155,7 @@ class Conversations(
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent {
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
val body = Content.groupText(text, group.id)
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id))
for (member in group.memberIds) {
if (member == myUserId) continue
@@ -180,8 +199,11 @@ class Conversations(
bytes = sealed.blob.size, mime = mime,
width = width, height = height, duration = duration,
)
return sendPlaintext(toUserId, Content.attachment(ref, caption, groupId))
.copy(attachment = ref)
return sendPlaintext(
toUserId,
Content.attachment(ref, caption, groupId,
groupName = groupId?.let(vault::groupName)),
).copy(attachment = ref)
}
/**
@@ -207,7 +229,8 @@ class Conversations(
kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest,
bytes = sealed.blob.size, mime = mime, width = width, height = height,
)
val body = Content.attachment(ref, caption, group.id)
val body = Content.attachment(ref, caption, group.id,
groupName = vault.groupName(group.id))
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
@@ -1262,6 +1285,39 @@ class Conversations(
return vault.receivedSinceIssue(userId) >= issued.size / 2
}
/**
* Tell these members what a group is called, over their pairwise ratchets.
*
* 🔴 **The relay has no copy to fall back on.** It used to store group
* names in the clear, in a column it never read; it stopped, so the members
* are now the only record and somebody who is never told stays looking at a
* generic placeholder.
*
* Rides the `delivery_key` control kind rather than a new one — a new kind
* would arrive on every phone running v5 as a bubble full of raw JSON. See
* [SealedSender.deliveryKeyEnvelope].
*
* ⚠️ Best-effort per member: one unreachable device must not stop the rest
* of the group learning what the group is called. Every group envelope
* repeats the name anyway, so a missed announcement heals on first traffic.
*
* ⚠️ **Our own other devices are not told**, because fan-out excludes our
* own account. A second phone belonging to whoever named the group shows
* the placeholder until any OTHER member sends something — which does reach
* it, since fan-out is per device. Closing it properly needs a
* copy-to-own-devices path this protocol does not have.
*/
fun announceGroupName(groupId: String, memberIds: List<Long>) {
val name = vault.groupName(groupId) ?: return
val key = vault.loadDelivery()?.key ?: return
val payload = SealedSender.deliveryKeyEnvelope(
key, groupId = groupId, groupName = name)
for (member in memberIds) {
if (member == myUserId) continue
runCatching { sendPlaintext(member, payload) }
}
}
/** Take in the control messages this layer owns, rather than showing them. */
/**
* ⚠️ `internal`, not private: this is the seam where a control envelope
@@ -1271,6 +1327,15 @@ class Conversations(
internal fun consumeControl(fromUser: Long, content: Content) {
when (content.kind) {
Content.Kind.DELIVERY_KEY -> {
// A member telling us what a group is called (§6.7). Recorded
// as a CLAIM, not adopted: this layer does not know the group
// rosters, and a name is only trustworthy from someone who is
// actually in the group. `Session` drains this and checks.
val gid = content.raw?.optString("gid")?.takeIf { it.isNotEmpty() }
val gname = content.raw?.optString("gname")?.takeIf { it.isNotEmpty() }
if (gid != null && gname != null) {
synchronized(claimedGroupNames) { claimedGroupNames += Triple(fromUser, gid, gname) }
}
// 🔴 **The batch is stored first, and independently.** Peer
// delivery keys live inside our OWN delivery record, so the
// original `loadDelivery() ?: return` dropped everything in
@@ -379,9 +379,21 @@ class RelayClient(
}
}
fun createGroup(name: String, memberIds: List<Long>): Group =
/**
* Create a group. **The name is not a parameter, deliberately.**
*
* The relay used to store it, in the clear, in a column it never read —
* "Chemotherapy support", "Union organising", sitting in a table that can
* be dumped or subpoenaed. It stopped, so the request now carries only the
* roster, which is the part the relay has to route. Members are told what
* the group is called over their pairwise ratchets, by [Conversations].
*
* The `name` that comes back on [Group] is a placeholder — the same word
* for every group — kept so builds that predate the change do not draw a
* blank row. Callers substitute the name they hold locally.
*/
fun createGroup(memberIds: List<Long>): Group =
Group.fromWire(postJson("/e2e/groups", JSONObject()
.put("name", name)
.put("members", JSONArray(memberIds))))
fun listGroups(): List<Group> {
@@ -0,0 +1,223 @@
package io.flamenet.client
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.json.JSONObject
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Group names, after the relay stopped holding them.
*
* 🔴 **This was the largest single thing the server knew about anybody.** Group
* membership it needs, to route. The NAME it never needed and never read — no
* push, no enforcement, no lookup — and it was sitting in a column in the
* clear: "Chemotherapy support", "Union organising", a sentence about somebody
* on a machine that can be dumped, seized or subpoenaed.
*
* So the name moved to the members, who already have a way to tell each other
* things the server cannot read. What these tests hold down is the pair of
* claims that makes that a real change rather than a rearrangement: the name
* never reaches the relay, and it still reaches the people in the group.
*/
class GroupNameTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
@AfterTest fun stop() { server.shutdown() }
private class MapStorage : Vault.Storage {
private val records = mutableMapOf<String, ByteArray>()
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(),
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
).also { it.saveIdentity(DeviceIdentity.create()) }
private fun conversations(vault: Vault): Conversations {
val identity = vault.loadIdentity() ?: DeviceIdentity.create()
val relay = RelayClient(server.url("/").toString(), "alice", "app pass word")
return Conversations(identity, vault, relay)
}
// -- What the relay is told -----------------------------------------------
/**
* 🔴 **Absent, not blank.** A request that carried an empty `name` would
* still be a request in which the client had decided to trust the relay
* with the field; this asserts the field is not there at all, so there is
* no version of the server that could start writing it down again without
* a client change.
*/
@Test
fun `creating a group sends no name to the relay`() {
server.enqueue(MockResponse().setBody(
"""{"ok":true,"gid":"g1","name":"Group","owner":1,"members":[{"user_id":2}]}"""))
val relay = RelayClient(server.url("/").toString(), "alice", "app pass word")
relay.createGroup(listOf(2L))
val body = server.takeRequest().body.readUtf8()
assertFalse(body.contains("\"name\""), "a name field is on the create request: $body")
assertTrue(body.contains("\"members\""), "the roster is missing: $body")
}
// -- What the members are told --------------------------------------------
/**
* ⚠️ Every group envelope repeats the name. The announcement sent at
* creation only reaches who was online and only the devices that existed
* then; a phone set up later would otherwise sit on the relay's placeholder
* for ever, because there is nobody left to ask.
*/
@Test
fun `a group message carries the group name`() {
val wire = Content.groupText("we leave at eight", "g1", groupName = "Trip planning")
assertEquals("Trip planning", Content.parse(wire).raw?.optString("gname"))
assertEquals("g1", Content.parse(wire).groupId)
assertEquals("we leave at eight", Content.parse(wire).text)
}
/** A 1:1 message has no group and must not carry one. */
@Test
fun `an ordinary message carries no group name`() {
val wire = Content.text("hello", messageId = "m1")
assertEquals("", Content.parse(wire).raw?.optString("gname") ?: "")
}
/**
* 🔴 **The announcement rides `delivery_key` precisely because that kind is
* old.** A new `group_name` kind would arrive on every phone running v5 as
* a bubble full of raw JSON: those builds recognise control traffic by kind
* and read the `ctl` flag with `optBoolean`, which does not coerce the
* number §6.10 specifies. This asserts the envelope is still a well-formed
* delivery-key message — the half a v5 phone acts on — with the name as
* extra fields it will ignore.
*/
@Test
fun `the announcement is a delivery key envelope an older build still understands`() {
val key = ByteArray(32) { it.toByte() }
val wire = SealedSender.deliveryKeyEnvelope(key, groupId = "g1", groupName = "Book club")
assertTrue(wire.startsWith("""{"fnc":1,"kind":"delivery_key""""),
"the marker or kind moved, so an older build would render this: $wire")
val json = JSONObject(wire)
assertEquals("delivery_key", json.optString("kind"))
assertTrue(json.optString("key").isNotEmpty(), "the key an older build reads is gone")
assertEquals("g1", json.optString("gid"))
assertEquals("Book club", json.optString("gname"))
}
/** With nothing to say about a group, the envelope is exactly what it was. */
@Test
fun `an announcement without a group is the plain delivery key envelope`() {
val wire = SealedSender.deliveryKeyEnvelope(ByteArray(32))
assertFalse(wire.contains("gname"))
assertFalse(wire.contains("gid"))
}
// -- What is believed ------------------------------------------------------
/**
* 🔴 **A claim, not a fact, and this layer must not adopt it.** Anyone who
* can reach us can send one of these naming any gid they like. Adopting on
* the sender's word would put whatever a stranger wants into somebody's
* chat list, under a group heading the reader has no reason to doubt. The
* roster lives a layer up, so the check does too — `Session.drainGroupNames`
* is what turns a claim into a name.
*/
@Test
fun `an announced name is recorded as a claim, not stored`() {
val vault = vault()
val conversations = conversations(vault)
val wire = SealedSender.deliveryKeyEnvelope(
ByteArray(32), groupId = "g1", groupName = "Everything is fine")
conversations.consumeControl(99L, Content.parse(wire))
assertNull(vault.groupName("g1"), "the name was adopted without a membership check")
assertEquals(listOf(Triple(99L, "g1", "Everything is fine")),
conversations.takeClaimedGroupNames())
}
/** ⚠️ Drained, so the same claim is not re-checked on every poll. */
@Test
fun `claims are taken once`() {
val conversations = conversations(vault())
conversations.consumeControl(99L, Content.parse(
SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "n")))
assertEquals(1, conversations.takeClaimedGroupNames().size)
assertTrue(conversations.takeClaimedGroupNames().isEmpty())
}
/**
* 🔴 The batch and the key still have to survive an envelope that also
* carries a group name. They are independent halves of the same message and
* a name must not be able to take either of them down with it.
*/
@Test
fun `a named announcement still delivers the delivery key`() {
val vault = vault()
vault.saveDelivery(Vault.Delivery(key = ByteArray(32) { 7 }, published = true))
val conversations = conversations(vault)
val theirKey = ByteArray(32) { 9 }
conversations.consumeControl(42L, Content.parse(
SealedSender.deliveryKeyEnvelope(theirKey, groupId = "g1", groupName = "Book club")))
assertTrue(vault.loadDelivery()!!.peers[42L]!!.contentEquals(theirKey),
"the delivery key was lost because the envelope also named a group")
}
// -- Where it is kept ------------------------------------------------------
@Test
fun `a name is kept, replaced and forgotten`() {
val vault = vault()
assertNull(vault.groupName("g1"))
vault.saveGroupName("g1", "Book club")
assertEquals("Book club", vault.groupName("g1"))
vault.saveGroupName("g1", "Book club, Tuesdays")
assertEquals("Book club, Tuesdays", vault.groupName("g1"))
// Leaving a group forgets it. The one string the server was asked to
// stop holding should not outlive the membership that justified it.
vault.clearGroupName("g1")
assertNull(vault.groupName("g1"))
}
/** ⚠️ A peer chooses this string, so it is bounded before it is stored. */
@Test
fun `a name is capped`() {
val vault = vault()
vault.saveGroupName("g1", "x".repeat(500))
assertEquals(80, vault.groupName("g1")!!.length)
}
/** Names are per group; one must not answer for another. */
@Test
fun `names do not leak between groups`() {
val vault = vault()
vault.saveGroupName("g1", "Book club")
assertNull(vault.groupName("g2"))
}
}
@@ -247,9 +247,15 @@ class LiveConversationTest {
val bob = signUp(base, "l")
val carol = signUp(base, "m")
val group = alice.relay.createGroup("test group", listOf(bob.userId, carol.userId))
val group = alice.relay.createGroup(listOf(bob.userId, carol.userId))
assertTrue(group.id.isNotEmpty(), "no group id came back")
assertEquals("test group", group.name)
// 🔴 **The relay does not know what the group is called**, and this
// asserts it against a live one. It used to store the name in the clear
// in a column it never read — the single most revealing string it held
// — and now answers with the same generic placeholder for every group.
// The real name travels between members inside their ratchets.
assertEquals("Group", group.name,
"the relay is serving a real group name again")
assertEquals(alice.userId, group.owner)
// Creator plus the two invited. If this is 2, the relay does not add
// the owner and the client must.