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
@@ -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`() {