The app never actually created a sealed group
Everything underneath was built and tested and no screen called any of it, so every group this app made still handed the relay one readable `gid -> user_id` row per member. Production is four groups, four of them plaintext. Making a group now seals it. The relay gets a blob it has no key for and one opaque tag each. Old groups get their own answer, because the migration is additive on purpose: the relay keeps a plaintext roster until a client replaces it, so a group made before sealing stays readable for ever unless something does it. The owner gets a button. It keeps the gid — a new one would strand the thread, the history and everyone else's copy — and reads the members out of the roster we already hold rather than asking for them. 🔴 The group list now prefers the sealed roster, and that is the security- relevant half. A sealed group's members come out of a blob the owner signed; `GET /e2e/groups` carries no signature at all. Letting the relay's answer for the same gid win — or merely shadow it — puts the server back in charge of deciding who is in a group. Reverting just that merge fails the new test with the relay's roster [1, 2, 99] replacing the blob's [1, 2]. Membership edits and leaving route on whether the group is sealed. Sending a sealed group down the plaintext route asks the relay to edit a table it no longer has: the call returns fine and the member is not there. One entry point, so no screen has to branch to be correct. The button says what it does and does not buy. It protects the list if the database is stolen or handed over; it does not hide the group from whoever runs the server, because messages are addressed to each device and who receives a group's traffic still gives it away. 604 tests, 0 failures. assembleDebug succeeds. Closes #3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
16ebf04d4c
commit
0006d82de6
@@ -2485,6 +2485,31 @@ private fun ColumnScope.GroupMembers(
|
||||
) { Text("Save") }
|
||||
}
|
||||
|
||||
// 🔴 **Only the owner, and only while it still applies.** A group made
|
||||
// before sealing keeps a readable member list on the server until somebody
|
||||
// replaces it, and only the owner can. Recomposes away once sealed, because
|
||||
// sealing refreshes the group list.
|
||||
if (model.canSealGroup(group)) {
|
||||
Text(
|
||||
// ⚠️ Says what is true now, what changes, and what it does NOT buy.
|
||||
// "The server cannot see who you talk to" would be the easy sentence
|
||||
// and it is not true: messages are addressed to each device, so who
|
||||
// receives a group's traffic still gives the group away to whoever
|
||||
// runs the server.
|
||||
"This group was made before the server stopped keeping member lists, so it still "
|
||||
+ "holds this one in the clear. Encrypting it puts something in its place that "
|
||||
+ "the server has no key for. Everyone stays in the group and nobody else has "
|
||||
+ "to do anything. It protects the list if the server's database is ever stolen "
|
||||
+ "or handed over; it does not hide the group from whoever runs the server day "
|
||||
+ "to day.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { model.sealGroup(group.id) },
|
||||
modifier = Modifier.testTag("seal-group"),
|
||||
) { Text("Encrypt the member list") }
|
||||
}
|
||||
|
||||
if (!editing) return
|
||||
|
||||
Text(
|
||||
|
||||
@@ -936,9 +936,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val made = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val group = current.relay.createGroup(memberIds)
|
||||
current.nameGroup(group.id, name)
|
||||
current.announceGroupName(group.id, group.memberIds)
|
||||
// 🔴 **Sealed, always.** The relay used to be handed one
|
||||
// readable `gid -> user_id` row per member; it is now handed a
|
||||
// blob it has no key for and one opaque tag each. The name and
|
||||
// the tags reach the members over their pairwise ratchets, so
|
||||
// `createSealedGroup` does its own announcing.
|
||||
current.createSealedGroup(name, memberIds)
|
||||
current.namedGroups()
|
||||
}
|
||||
}
|
||||
@@ -986,13 +989,45 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
|
||||
}
|
||||
|
||||
/** Whether this group's roster is sealed. For offering the upgrade. */
|
||||
fun isGroupSealed(groupId: String): Boolean = session?.isSealed(groupId) == true
|
||||
|
||||
/**
|
||||
* Whether to offer sealing this group: we own it and it is not sealed yet.
|
||||
*
|
||||
* ⚠️ The check that MATTERS is in `Session.upgradeGroupToSealed`, and the
|
||||
* relay refuses a roster it has no signature for regardless. This exists so
|
||||
* a button that could only fail is not shown.
|
||||
*/
|
||||
fun canSealGroup(group: io.flamenet.client.RelayClient.Group): Boolean {
|
||||
val phase = signedIn() ?: return false
|
||||
return group.owner == phase.userId && !isGroupSealed(group.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the relay's readable roster for an old group with one it cannot
|
||||
* open. Owner only; the relay refuses it from anybody else.
|
||||
*/
|
||||
fun sealGroup(groupId: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.upgradeGroupToSealed(groupId)
|
||||
current.namedGroups()
|
||||
}
|
||||
}
|
||||
done.onSuccess { list -> update { it.copy(busy = false, groups = list) } }
|
||||
.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
fun updateGroupMembers(groupId: String, add: List<Long>, remove: List<Long>) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.relay.updateGroupMembers(groupId, add, remove)
|
||||
current.updateMembers(groupId, add, remove)
|
||||
// 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)
|
||||
@@ -1021,7 +1056,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.relay.leaveGroup(groupId)
|
||||
current.leaveAnyGroup(groupId)
|
||||
current.clearHistory(Vault.threadForGroup(groupId))
|
||||
// The name goes with it. It only ever lived here because the
|
||||
// group did, and the server has no copy to fall back on.
|
||||
|
||||
@@ -1062,8 +1062,93 @@ class Session(
|
||||
* 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<RelayClient.Group> = relay.listGroups().map { group ->
|
||||
vault.groupName(group.id)?.let { group.copy(name = it) } ?: group
|
||||
fun namedGroups(): List<RelayClient.Group> {
|
||||
// 🔴 **The sealed ones cannot come from `listGroups`.** The relay holds
|
||||
// no membership rows for them, so it has nothing to answer with — they
|
||||
// are found only by presenting the tags this device holds. Without this
|
||||
// merge a sealed group is invisible on every screen.
|
||||
val sealed = sealedGroups()
|
||||
val sealedIds = sealed.map { it.id }.toSet()
|
||||
// ⚠️ Merge rather than replace: a group made before sealing still comes
|
||||
// from `listGroups`, and dropping those would make somebody's older
|
||||
// groups vanish the moment sealing shipped.
|
||||
//
|
||||
// 🔴 And sealed wins on a collision. Its roster came out of a blob the
|
||||
// owner signed; the relay's answer for the same gid is unauthenticated,
|
||||
// so preferring it would put the server back in charge of membership.
|
||||
val plain = relay.listGroups().filterNot { it.id in sealedIds }
|
||||
return (sealed + plain).map { group ->
|
||||
vault.groupName(group.id)?.let { group.copy(name = it) } ?: group
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this group's roster is the sealed kind. For offering the upgrade. */
|
||||
fun isSealed(gid: String): Boolean = vault.groupSecrets(gid) != null
|
||||
|
||||
/**
|
||||
* Seal a group that already exists in the clear, keeping its gid.
|
||||
*
|
||||
* 🔴 **The whole point of the feature for anybody who already has groups.**
|
||||
* The migration is additive by design — the relay keeps a plaintext roster
|
||||
* until a client replaces it — so a group made before sealing stays readable
|
||||
* in the database for ever unless something does this.
|
||||
*
|
||||
* ⚠️ Owner only, and the members are read out of the roster we already hold
|
||||
* rather than asked for, so an upgrade cannot quietly change who is in the
|
||||
* group.
|
||||
*/
|
||||
fun upgradeGroupToSealed(gid: String): RelayClient.Group {
|
||||
require(vault.groupSecrets(gid) == null) { "that group is already sealed" }
|
||||
val existing = relay.listGroups().firstOrNull { it.id == gid }
|
||||
?: throw IllegalStateException("unknown group")
|
||||
require(existing.owner == userId) { "only the group's owner can seal it" }
|
||||
|
||||
val members = existing.memberIds.distinct().sorted()
|
||||
val name = vault.groupName(gid) ?: ""
|
||||
val groupKey = SealedRoster.newGroupKey()
|
||||
val owner = SealedRoster.newOwnerKey()
|
||||
val tags = members.associate { it.toString() to SealedRoster.newTag() }
|
||||
|
||||
val blob = SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey)
|
||||
val sig = SealedRoster.signRoster(owner.seed, gid, 1, blob)
|
||||
// The relay takes the same request as a create: it replaces the
|
||||
// plaintext row in place and deletes its membership rows in the same
|
||||
// transaction.
|
||||
relay.createSealedGroup(gid, SealedRoster.ownerPublic(owner), blob,
|
||||
tags.values.sorted(), sig)
|
||||
|
||||
vault.saveGroupSecrets(gid, Vault.GroupSecrets(
|
||||
tag = tags[userId.toString()] ?: "", key = groupKey,
|
||||
ownerPrivate = owner.seed, version = 1))
|
||||
if (name.isNotEmpty()) vault.saveGroupName(gid, name)
|
||||
announceSealedGroup(gid, tags, groupKey, name, members)
|
||||
|
||||
return RelayClient.Group(gid, name, userId, members)
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership edits and leaving, routed by whether the group is sealed.
|
||||
*
|
||||
* 🔴 **One entry point, so no screen has to branch to be correct.** Sending
|
||||
* a sealed group down the plaintext route asks the relay to edit a table it
|
||||
* no longer has: the call returns fine and the member is not there.
|
||||
*/
|
||||
fun updateMembers(gid: String, add: List<Long>, remove: List<Long>) {
|
||||
if (isSealed(gid)) updateSealedGroupMembers(gid, add, remove)
|
||||
else relay.updateGroupMembers(gid, add, remove)
|
||||
}
|
||||
|
||||
fun leaveAnyGroup(gid: String) {
|
||||
val secrets = vault.groupSecrets(gid)
|
||||
if (secrets != null) {
|
||||
// A sealed group is left by surrendering the tag that let us in.
|
||||
// The relay has no account row to remove; the tag IS the membership.
|
||||
relay.leaveSealedGroup(gid, secrets.tag)
|
||||
vault.clearGroupSecrets(gid)
|
||||
} else {
|
||||
relay.leaveGroup(gid)
|
||||
}
|
||||
vault.clearGroupName(gid)
|
||||
}
|
||||
|
||||
/** Name a group on this device. The relay is not told. */
|
||||
|
||||
Reference in New Issue
Block a user