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:
co-authored by
Claude Opus 4.8
parent
8061f54e96
commit
4a6be0ba6b
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user