From 4a6be0ba6b0b3f97367ef4d6e104695dc01c01f7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 20:24:03 -0400 Subject: [PATCH] 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 --- .../messenger/app/MessengerViewModel.kt | 53 ++++- .../io/flamenet/messenger/app/Session.kt | 84 ++++++- .../io/flamenet/client/Conversations.kt | 73 +++++- .../kotlin/io/flamenet/client/RelayClient.kt | 16 +- .../io/flamenet/client/GroupNameTest.kt | 223 ++++++++++++++++++ .../flamenet/client/LiveConversationTest.kt | 10 +- .../main/kotlin/io/flamenet/e2e/Content.kt | 14 +- .../kotlin/io/flamenet/e2e/SealedSender.kt | 31 ++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 43 ++++ 9 files changed, 525 insertions(+), 22 deletions(-) create mode 100644 client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index c736f5e..ca47e6b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -362,7 +362,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { credential.relayBaseUrl, credential.username, credential.appPassword), ) val fetchedBuddies = runCatching { session!!.roster() } - val fetchedGroups = runCatching { session!!.relay.listGroups() } + val fetchedGroups = runCatching { session!!.namedGroups() } stored to Phase.SignedIn( handle = credential.username, userId = credential.userId, @@ -556,7 +556,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fnid?.let { ContactCard.canonical("$it@${config.host}") } val fetchedBuddies = runCatching { session!!.roster() } - val fetchedGroups = runCatching { session!!.relay.listGroups() } + val fetchedGroups = runCatching { session!!.namedGroups() } // ๐Ÿ”ด Restore the strict key-log requirement the user chose. Without // this it reverted to off on every launch: the switch was read from a @@ -916,18 +916,30 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun refreshGroups() = viewModelScope.launch { val current = session ?: return@launch - val fetched = withContext(Dispatchers.IO) { runCatching { current.relay.listGroups() } } + val fetched = withContext(Dispatchers.IO) { + runCatching { current.namedGroups() } + } fetched.onSuccess { list -> update { it.copy(groups = list, error = null) } } .onFailure { failure -> update { it.copy(error = describe(failure)) } } } + /** + * Create a group and tell its members what it is called. + * + * โš ๏ธ The name goes to the members, never to the relay โ€” `createGroup` takes + * only the roster. Announcing is best-effort: a member who is offline picks + * the name up from the first message instead, because every group envelope + * carries it. + */ fun createGroup(name: String, memberIds: List) = viewModelScope.launch { val current = session ?: return@launch update { it.copy(busy = true, error = null) } val made = withContext(Dispatchers.IO) { runCatching { - current.relay.createGroup(name, memberIds) - current.relay.listGroups() + val group = current.relay.createGroup(memberIds) + current.nameGroup(group.id, name) + current.announceGroupName(group.id, group.memberIds) + current.namedGroups() } } made.onSuccess { list -> update { it.copy(busy = false, groups = list) } } @@ -941,7 +953,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val done = withContext(Dispatchers.IO) { runCatching { current.relay.updateGroupMembers(groupId, add, remove) - current.relay.listGroups() + // Somebody just added has never been told the name, and + // would sit on the placeholder until the next message. + if (add.isNotEmpty()) current.announceGroupName(groupId, add) + current.namedGroups() } } done.onSuccess { list -> @@ -968,7 +983,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { runCatching { current.relay.leaveGroup(groupId) current.clearHistory(Vault.threadForGroup(groupId)) - current.relay.listGroups() + // The name goes with it. It only ever lived here because the + // group did, and the server has no copy to fall back on. + current.vault.clearGroupName(groupId) + current.namedGroups() } } done.onSuccess { list -> @@ -2228,6 +2246,27 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // A picture arriving is a control message: it touches no // thread, so it needs its own signal. Without this the // Home circles showed initials until the next sign-in. + // A group name arriving is control traffic: it touches no + // thread, so it needs its own signal. Without this a group + // someone else named stayed on the relay's placeholder + // until the next sign-in โ€” the server has no copy to fill + // it in from any more. + if (current.groupNamesUpdated) { + current.groupNamesUpdated = false + withContext(Dispatchers.IO) { runCatching { current.namedGroups() } } + .getOrNull()?.let { live -> + update { state -> + state.copy( + groups = live, + openChat = state.openChat?.let { chat -> + chat.copy(group = live.firstOrNull { + g -> g.id == chat.group?.id + } ?: chat.group) + }, + ) + } + } + } if (current.conversations.avatarsUpdated) { current.conversations.avatarsUpdated = false val fresh = withContext(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 860281e..4786ac1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -642,7 +642,8 @@ class Session( // no-device guard; each gets an entry carrying the SAME group body and // the group thread to restate. A member whose devices took it is not // queued โ€” resending to them would duplicate the message. - val body = io.flamenet.e2e.Content.groupText(text, group.id) + val body = io.flamenet.e2e.Content.groupText( + text, group.id, groupName = vault.groupName(group.id)) sent.failed.keys.filter { it.startsWith("user:") } .mapNotNull { it.removePrefix("user:").toLongOrNull() } .forEach { member -> @@ -689,6 +690,9 @@ class Session( fun receive(): Set { val everything = conversations.poll() + // Control traffic consumed during the poll may have carried group-name + // claims. Checked against the roster here, where the roster is known. + drainGroupNames() everything.forEach { message -> // โš ๏ธ Local calls only. Signalling is addressed by user id the // whole way down โ€” the answer, the ICE candidates, the hang-up โ€” @@ -745,6 +749,14 @@ class Session( if (known == null || message.fromUser !in known) { continue } + // โš ๏ธ Every group envelope carries the group's name, and this is + // the repair path for it. The announcement sent at creation + // reaches only who was online and only the devices that existed + // then; a phone set up later would otherwise sit on the relay's + // generic placeholder for ever. The membership check above has + // already run, so this is not a way in for a stranger. + message.content.raw?.optString("gname")?.takeIf { it.isNotEmpty() } + ?.let { adoptGroupName(gid, it) } } // ๐Ÿ”ด **A federated message files under its ADDRESS.** Their relay // told ours nothing about who sent it, so `fromUser` is 0 โ€” filing @@ -894,6 +906,76 @@ class Session( */ private var groupRosterCache: Pair>>? = null + /** + * The groups this account is in, each carrying the name we hold locally. + * + * ๐Ÿ”ด **The relay's `name` is a placeholder for every group.** It stopped + * storing names โ€” it never read them, and a column holding "Chemotherapy + * support" is a column that can be dumped or subpoenaed โ€” and answers with + * one generic word so builds that predate the change do not draw a blank + * row. Everything above this reads `Group.name`, so the substitution + * happens once, here, rather than at every screen that shows a group. + */ + fun namedGroups(): List = relay.listGroups().map { group -> + vault.groupName(group.id)?.let { group.copy(name = it) } ?: group + } + + /** Name a group on this device. The relay is not told. */ + fun nameGroup(gid: String, name: String) { + val trimmed = name.trim() + if (trimmed.isNotEmpty()) vault.saveGroupName(gid, trimmed) + } + + /** + * Push a group's name to its members over their pairwise ratchets. + * + * 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. + */ + fun announceGroupName(gid: String, memberIds: List) = + conversations.announceGroupName(gid, memberIds) + + /** + * Record a name a member gave a group, if it is news. + * + * โš ๏ธ Callers check membership first. There is no path here that takes a + * name on the sender's word. + */ + private fun adoptGroupName(gid: String, name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty() || vault.groupName(gid) == trimmed) return + vault.saveGroupName(gid, trimmed) + groupNamesUpdated = true + } + + /** + * Adopt the group-name claims that arrived as control traffic, checking + * each against the relay's roster. + * + * ๐Ÿ”ด **The check is the whole point.** A claim naming a group we are not in + * โ€” or one from somebody who is not in it โ€” is dropped. Without that, + * anyone able to reach us could write whatever they liked into our chat + * list under a group heading the reader has no reason to doubt. + */ + private fun drainGroupNames() { + for ((fromUser, gid, name) in conversations.takeClaimedGroupNames()) { + val known = groupRoster(gid) ?: continue + if (fromUser !in known) continue + adoptGroupName(gid, name) + } + } + + /** + * Set when a group name changed, so the UI knows to re-read the list. + * Control traffic touches no thread, so without a flag a name that arrived + * mid-session stayed invisible until the next sign-in. + */ + @Volatile var groupNamesUpdated: Boolean = false + private fun groupRoster(gid: String): Set? { val now = System.currentTimeMillis() val cached = groupRosterCache diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 8779294..ac9b9e9 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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>() + + /** Take the pending claims, leaving none behind. */ + fun takeClaimedGroupNames(): List> = + 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() val failed = LinkedHashMap() - 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() val failed = LinkedHashMap() @@ -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) { + 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 diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index cc93a67..f9815ce 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -379,9 +379,21 @@ class RelayClient( } } - fun createGroup(name: String, memberIds: List): 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): Group = Group.fromWire(postJson("/e2e/groups", JSONObject() - .put("name", name) .put("members", JSONArray(memberIds)))) fun listGroups(): List { diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt new file mode 100644 index 0000000..0b96ff7 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -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() + 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")) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt index c8b7168..af8a473 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt @@ -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. diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 67a528e..8cea422 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -296,8 +296,9 @@ data class Content( groupId: String, messageId: String? = null, about: Reference? = null, + groupName: String? = null, ): String = assemble("text", caption = body, groupId = groupId, - messageId = messageId, about = about) + messageId = messageId, about = about, groupName = groupName) fun attachment( ref: Attachment.Ref, @@ -307,6 +308,7 @@ data class Content( about: Reference? = null, /** A live stream this story embeds (ยง6.13); [ref] is then a poster. */ streamUrl: String? = null, + groupName: String? = null, ): String = assemble( kind = ref.kind, caption = caption?.takeIf { it.isNotEmpty() }, @@ -316,6 +318,7 @@ data class Content( messageId = messageId, about = about, streamUrl = streamUrl, + groupName = groupName, ) /** @@ -358,6 +361,7 @@ data class Content( about: Reference? = null, link: LinkPreview? = null, streamUrl: String? = null, + groupName: String? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") messageId?.let { parts += "\"mid\":${quote(it)}" } @@ -365,6 +369,14 @@ data class Content( caption?.let { parts += "\"caption\":${quote(it)}" } expires?.let { parts += "\"exp\":$it" } groupId?.let { parts += "\"gid\":${quote(it)}" } + // What the group is called, on every group envelope. + // + // ๐Ÿ”ด **The relay no longer stores this**, so the members are the + // only record of it and a member with no copy has nobody to ask. + // Repeating it on ordinary traffic means a phone set up later, or + // one that missed the announcement, fills the gap the first time + // anyone says anything. It is inside the ratchet either way. + groupName?.takeIf { it.isNotEmpty() }?.let { parts += "\"gname\":${quote(it)}" } attachment?.let { parts += "\"att\":${it.toJson()}" } link?.let { parts += "\"lnk\":${it.toJson()}" } streamUrl?.let { parts += "\"stream\":${quote(it)}" } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt index ee8de3a..9b7d7da 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -206,10 +206,31 @@ object SealedSender { * JSON in somebody's conversation. */ @JvmOverloads - fun deliveryKeyEnvelope(key: ByteArray, tokens: List = emptyList()): String { - val head = """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"""" - if (tokens.isEmpty()) return "$head}" - return head + ""","tokens":[""" + - tokens.joinToString(",") { "\"" + it + "\"" } + "]}" + /** + * @param groupId with [groupName], tells this contact what a group is called. + * + * ๐Ÿ”ด **This kind is the vehicle precisely because it is old.** The relay no + * longer stores group names, so members have to learn them from each other, + * and the obvious way โ€” a new `group_name` control kind โ€” would arrive on + * every phone running v5 as a bubble full of raw JSON: those builds read + * the `ctl` flag with `optBoolean`, which does not coerce the number ยง6.10 + * specifies. `delivery_key` is a kind they already consume in silence and + * already ignore unknown fields on. + */ + fun deliveryKeyEnvelope( + key: ByteArray, + tokens: List = emptyList(), + groupId: String? = null, + groupName: String? = null, + ): String { + var json = """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"""" + if (tokens.isNotEmpty()) { + json += ""","tokens":[""" + tokens.joinToString(",") { "\"" + it + "\"" } + "]" + } + if (groupId != null && groupName != null) { + json += ""","gid":${JSONObject.quote(groupId)}""" + + ""","gname":${JSONObject.quote(groupName)}""" + } + return "$json}" } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 638ae72..7e79a3e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -86,11 +86,18 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val SNOOZE = "snooze" private const val REPLY_REMINDER = "reply-reminder" private const val ROSTER = "roster" + private const val GROUP_NAMES = "group-names" private const val DEVICE_OWNER_PREFIX = "devowner/" private const val KT_ISSUER_PIN = "kt-issuer-pin" /** Kept because the whole log is re-sealed on every write. */ private const val CALL_LOG_MAX = 500 + /** + * Matches the bound the relay's old column had, so a name that used to + * be storable still is โ€” and caps what a peer can push into our vault. + */ + private const val MAX_GROUP_NAME = 80 + /** The two kinds of thread, named once so they cannot drift apart. */ fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" @@ -426,6 +433,42 @@ class Vault(private val storage: Storage, private val key: SecretKey) { return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull() } + // -- Group names --------------------------------------------------------- + + /** + * What each group is called. + * + * ๐Ÿ”ด **The relay used to hold this and no longer does.** A group name is + * the most revealing single string it had โ€” "Chemotherapy support", + * "Union organising" โ€” sitting in a column in the clear, for a field the + * relay never once read. It now lives only on the devices of the people in + * the group, learned over their pairwise ratchets like any other content, + * and here it is inside the sealed vault like everything else. + * + * โš ๏ธ A group we have not been told the name of returns null, and the UI + * shows the relay's generic placeholder rather than a gid. It fills in by + * itself the first time any member says anything, because every group + * envelope repeats the name. + */ + fun groupName(groupId: String): String? = + readJson(GROUP_NAMES)?.optString(groupId)?.takeIf { it.isNotEmpty() } + + fun saveGroupName(groupId: String, name: String) { + val json = readJson(GROUP_NAMES) ?: JSONObject() + json.put(groupId, name.take(MAX_GROUP_NAME)) + writeJson(GROUP_NAMES, json) + } + + /** + * Forget a group's name on leaving it. The one string the server was asked + * to stop holding should not outlive the membership that justified it. + */ + fun clearGroupName(groupId: String) { + val json = readJson(GROUP_NAMES) ?: return + json.remove(groupId) + writeJson(GROUP_NAMES, json) + } + /** * Record a contact's picture. *