Merge branch '1-sealed-rosters-android' into 'main'

Sealed group membership changes, and secrets that survive a reinstall

Closes #1

See merge request paulhitt/flamenet-messenger-android!1
This commit was merged in pull request #28.
This commit is contained in:
Paul Hitt
2026-08-31 15:12:30 +00:00
3 changed files with 125 additions and 0 deletions
@@ -989,6 +989,53 @@ class Session(
return out
}
/**
* Add or remove members of a sealed group.
*
* 🔴 **Owner only, and the relay checks a signature rather than an
* account.** We hold the owner's private key iff we created the group; a
* member who is not the owner has nothing to sign with, and is refused here
* rather than sent to be rejected.
*
* ⚠️ The version must increase. A roster signature stays valid for ever, so
* a stale roster could otherwise be replayed to re-add somebody who was just
* removed — with a signature that genuinely verifies.
*/
fun updateSealedGroupMembers(gid: String, add: List<Long>, remove: List<Long>) {
val secrets = vault.groupSecrets(gid)
val ownerSeed = secrets?.ownerPrivate
?: throw IllegalStateException("only the group's owner can change its members")
val current = sealedGroups().firstOrNull { it.id == gid }
?: throw IllegalStateException("unknown group")
val roster = (current.memberIds + add - remove.filter { it != userId }.toSet())
.distinct().sorted()
require(roster.size <= 32) { "groups are capped at 32 members" }
// Existing members keep their tags; only newcomers get fresh ones.
// Reusing a tag would hand somebody a token another member had held.
val previous = runCatching {
SealedRoster.open(
relay.fetchSealedGroups(listOf(secrets.tag)).first { it.id == gid }.rosterBlob,
secrets.key)
}.getOrNull()?.tags ?: emptyMap()
val tags = roster.associate { m ->
m.toString() to (previous[m.toString()] ?: SealedRoster.newTag())
}
val name = vault.groupName(gid) ?: ""
val version = secrets.version + 1
val blob = SealedRoster.seal(SealedRoster.Contents(roster, tags, name), secrets.key)
val sig = SealedRoster.signRoster(ownerSeed, gid, version, blob)
relay.replaceRoster(gid, blob, version, sig, tags.values.sorted())
vault.saveGroupSecrets(gid, secrets.copy(version = version))
// Anyone newly added must be told their tag and the key, or they cannot
// fetch the group at all.
announceSealedGroup(gid, tags, secrets.key, name, add.filter { it in roster })
}
/** Hand each member their tag and the group key, over the ratchet. */
private fun announceSealedGroup(
gid: String, tags: Map<String, String>, key: ByteArray,
@@ -1219,6 +1219,18 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
// of groups all called "Group" — the exact loss this file exists to
// prevent.
.also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } }
// 🔴 **Without these a restored device loses its sealed groups for
// good.** The relay cannot say what you are a member of — that is
// the point of sealing — so the tag is the only way to fetch a group
// and the key the only way to read it. A backup carrying the
// messages but not these restores a device that can see old
// conversations and can never rejoin the groups they came from.
//
// ⚠️ The most sensitive thing this export holds: a group key opens
// every roster it ever sealed. The export is already encrypted under
// a passphrase-derived key, which is what makes carrying it
// defensible at all.
.also { p -> readJson(GROUP_SECRETS)?.let { p.put("groupSecrets", it) } }
}
data class ImportCounts(val messages: Int, val stories: Int)
@@ -1329,6 +1341,19 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
}
}
// §8.9b — gaps only, like the names. A backup is older than the device
// by definition, and a roster version that went backwards would be
// refused by the relay on the next rewrite anyway.
payload.optJSONObject("groupSecrets")?.let { imported ->
val existing = readJson(GROUP_SECRETS) ?: JSONObject()
var changed = false
for (gid in imported.keys()) {
if (existing.has(gid)) continue
imported.optJSONObject(gid)?.let { existing.put(gid, it); changed = true }
}
if (changed) writeJson(GROUP_SECRETS, existing)
}
return ImportCounts(newMessages, newStories)
}
@@ -18,6 +18,19 @@ import kotlin.test.assertTrue
*/
class SealedRosterTest {
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 vault(): Vault = Vault(
MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
).also { it.saveIdentity(DeviceIdentity.create()) }
private fun contents() = SealedRoster.Contents(
members = listOf(41L, 42L, 43L),
tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()),
@@ -112,6 +125,46 @@ class SealedRosterTest {
SealedRoster.open(SealedRoster.seal(b, key), key).tags)
}
/**
* 🔴 **A backup that loses the tags loses the groups for good.** The relay
* cannot say what you are a member of, so the tag is the only way to fetch
* a group and the key the only way to read it. Restoring the messages
* without these gives a device that can read old conversations and can
* never rejoin the groups they came from.
*/
@Test
fun `an exported backup carries sealed group secrets`() {
val old = vault()
val secrets = Vault.GroupSecrets(
tag = SealedRoster.newTag(), key = SealedRoster.newGroupKey(),
ownerPrivate = null, version = 3)
old.saveGroupSecrets("g1", secrets)
val payload = old.exportPayload()
val fresh = vault()
assertEquals(null, fresh.groupSecrets("g1"))
fresh.mergeImportedPayload(payload, 0.0)
assertEquals(secrets, fresh.groupSecrets("g1"))
}
/** ⚠️ Gaps only — a backup is older than the device by definition. */
@Test
fun `a restored backup does not overwrite secrets we already hold`() {
val old = vault()
old.saveGroupSecrets("g1", Vault.GroupSecrets(
tag = "old", key = SealedRoster.newGroupKey(), version = 1))
val payload = old.exportPayload()
val current = vault()
val live = Vault.GroupSecrets(
tag = "live", key = SealedRoster.newGroupKey(), version = 9)
current.saveGroupSecrets("g1", live)
current.mergeImportedPayload(payload, 0.0)
assertEquals(live, current.groupSecrets("g1"))
}
/** Two groups minted by the same person share nothing. */
@Test
fun `two groups share no key material`() {