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()) + } +}