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