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
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user