From fefd87e482d4beb001f8bd847679b1f73d1519d8 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 31 Aug 2026 11:07:03 -0400 Subject: [PATCH] Sealed group membership changes, and secrets that survive a reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This had been sitting uncommitted and unbuilt. It builds. `Session.updateSealedGroupMembers` is owner-only and refuses locally rather than sending a request the relay would reject: we hold the owner's signing key only if we created the group. The version must increase, because a roster signature stays valid for ever โ€” a stale one could otherwise be replayed to re-add somebody who was just removed, with a signature that genuinely verifies. Existing members keep their tags; only newcomers get fresh ones, since reusing a tag would hand somebody the token another member had held. The vault carries the group secrets through export and import. Without them a restored device loses its sealed groups for good: the relay cannot say what you are a member of โ€” that is the point โ€” so the tag is the only way to fetch a group and the key the only way to read it. A backup with the messages but not these restores a device that can see old conversations and can never rejoin the groups they came from. Import fills gaps only, the same rule the names use. Verified: :engine:test passes, :app:assembleDebug succeeds. Closes #1 Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/Session.kt | 47 ++++++++++++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 25 +++++++++ .../io/flamenet/e2e/SealedRosterTest.kt | 53 +++++++++++++++++++ 3 files changed, 125 insertions(+) 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 dc456f0..5d519d5 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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, remove: List) { + 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, key: ByteArray, diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 95e3abd..f241cd1 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt index 09dfdcd..9457b05 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt @@ -18,6 +18,19 @@ import kotlin.test.assertTrue */ class SealedRosterTest { + 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 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`() { -- 2.54.0