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
@@ -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,