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:
co-authored by
Claude Opus 4.8
parent
d56ae82c59
commit
fefd87e482
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user