Sealed group membership changes, and secrets that survive a reinstall

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 11:07:03 -04:00
co-authored by Claude Opus 4.8
parent d56ae82c59
commit fefd87e482
3 changed files with 125 additions and 0 deletions
@@ -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`() {