The app never actually created a sealed group #30
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user