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
@@ -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)}" }
@@ -206,10 +206,31 @@ object SealedSender {
* JSON in somebody's conversation.
*/
@JvmOverloads
fun deliveryKeyEnvelope(key: ByteArray, tokens: List<String> = 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<String> = 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}"
}
}
@@ -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.
*