Merge branch '3-sealed-groups-ui' into 'main'

The app never actually created a sealed group

Closes #3

See merge request paulhitt/flamenet-messenger-android!3
This commit was merged in pull request #30.
This commit is contained in:
Paul Hitt
2026-08-31 15:24:58 +00:00
4 changed files with 318 additions and 7 deletions
@@ -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. */
@@ -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<Long> = 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<String, ByteArray>()
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<Session, Vault> {
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<Long>, 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())
}
}