From 0006d82de64c63c4403c9a518cc72f6378cbc865 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 31 Aug 2026 11:23:20 -0400 Subject: [PATCH] The app never actually created a sealed group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 25 +++ .../messenger/app/MessengerViewModel.kt | 45 ++++- .../io/flamenet/messenger/app/Session.kt | 89 +++++++++- .../messenger/app/SealedGroupListTest.kt | 166 ++++++++++++++++++ 4 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index a802b0c..d21e040 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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( 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 54a631d..78a5a0a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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, remove: List) = 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. 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 5d519d5..f471d32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 = relay.listGroups().map { group -> - vault.groupName(group.id)?.let { group.copy(name = it) } ?: group + fun namedGroups(): List { + // 🔴 **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, remove: List) { + 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. */ diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt new file mode 100644 index 0000000..cfbb5e7 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt @@ -0,0 +1,166 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.SealedRoster +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Which roster the group list believes, when the relay offers a different one. + * + * 🔴 **The relay must not win.** A sealed group's members come out of a blob + * the owner signed. `GET /e2e/groups` carries no signature at all, so if the + * plaintext answer were preferred — or merely allowed to shadow the sealed one + * for the same gid — the server would be back in charge of deciding who is in + * a group, which is the whole thing sealing exists to stop. + * + * ⚠️ Robolectric for the same reason as the other `:app` tests: this is an + * Android module, so its unit-test classpath has the stubbed `org.json` that + * returns null from everything, and the vault stores JSON. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SealedGroupListTest { + + private lateinit var server: MockWebServer + + /** The blob the relay hands back for the sealed fetch. */ + private var sealedBlob: String = "" + + /** Who the relay CLAIMS is in `g1` over the plaintext route. */ + private var plaintextRoster: List = emptyList() + + /** + * Answers by path, not by queue order — `namedGroups()` makes two calls and + * a fixture matched on order hands one of them the other's body. + */ + private inner class Relay : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/groups/fetch" in path -> json( + """{"groups":[{"gid":"g1","owner_key":"","roster_blob":"$sealedBlob",""" + + """"roster_version":1}]}""") + "/e2e/groups" in path -> json( + """{"groups":[{"gid":"g1","name":"Group","owner":1,"members":[""" + + plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}") + else -> json("{}") + } + } + } + + @BeforeTest fun start() { + server = MockWebServer().also { it.dispatcher = Relay(); 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 key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private fun session(): Pair { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + val s = Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) + return s to vault + } + + private fun json(body: String) = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + /** Hold the tag and key for `g1`, sealing the roster the test names. */ + private fun holdSealed(vault: Vault, members: List, name: String) { + val groupKey = SealedRoster.newGroupKey() + val tags = members.associate { it.toString() to SealedRoster.newTag() } + sealedBlob = SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey) + vault.saveGroupSecrets("g1", Vault.GroupSecrets( + tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1)) + } + + @Test + fun `a sealed group is listed at all`() { + val (session, vault) = session() + holdSealed(vault, listOf(1L, 2L), "Book club") + plaintextRoster = emptyList() + + val groups = session.namedGroups() + val g1 = groups.firstOrNull { it.id == "g1" } + assertTrue(g1 != null, "the sealed group is missing from the list entirely") + assertEquals(listOf(1L, 2L), g1.memberIds.sorted()) + assertEquals("Book club", g1.name) + } + + /** + * 🔴 The relay names a third account as a member of a group whose roster is + * sealed. The blob says otherwise, and the blob is what the owner signed. + */ + @Test + fun `the relay cannot add a member to a sealed group`() { + val (session, vault) = session() + holdSealed(vault, listOf(1L, 2L), "Book club") + // The same gid, over the unsigned route, with an extra member. + plaintextRoster = listOf(1L, 2L, 99L) + + val g1 = session.namedGroups().first { it.id == "g1" } + assertEquals(listOf(1L, 2L), g1.memberIds.sorted(), + "the relay's roster replaced the one out of the blob") + assertTrue(99L !in g1.memberIds, "a relay-supplied member entered a sealed group") + } + + /** And the group appears once, not twice, when both routes answer for it. */ + @Test + fun `a group answered by both routes is listed once`() { + val (session, vault) = session() + holdSealed(vault, listOf(1L, 2L), "Book club") + plaintextRoster = listOf(1L, 2L) + + assertEquals(1, session.namedGroups().count { it.id == "g1" }, + "the group is listed twice, sealed and plaintext") + } + + /** + * ⚠️ A group we hold no secrets for is still the relay's to describe. That + * is the unsealed model, unchanged — dropping those would make somebody's + * older groups vanish the moment sealing shipped. + */ + @Test + fun `an unsealed group still comes from the relay`() { + val (session, _) = session() + plaintextRoster = listOf(1L, 5L) + + val g1 = session.namedGroups().firstOrNull { it.id == "g1" } + assertTrue(g1 != null, "the unsealed group vanished") + assertEquals(listOf(1L, 5L), g1.memberIds.sorted()) + } +} -- 2.54.0