Sealed group membership changes, and secrets that survive a reinstall #28
@@ -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,
|
||||
|
||||
@@ -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`() {
|
||||
|
||||
Reference in New Issue
Block a user