mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix(marmot): every member applies the commit that evicts a leaver, and it survives a restart
Two defects, both found by `leaver-removal-secrecy` and both isolated with a failing test first. Between them a departing member stayed in the tree — holding the group's keys and reading everything sent after they left — while the group believed the departure had been processed. A peer's proposal must be REFERENCED, not inlined -------------------------------------------------- `commit()` inlined every staged proposal, including ones another member authored. An inline proposal carries no sender, so a receiver attributes it to the committer. For a `SelfRemove` that is not cosmetic: the proposal means "remove my leaf", so inlining someone else's says "remove the COMMITTER's leaf". Now only our own proposals go inline; a peer's goes in by `ProposalOrRef.Reference`, which resolves against the receiver's own pool where their copy of the same standalone proposal already sits with the original proposer's leaf index. `PendingProposal.authenticatedContentBytes` documented this contract all along; the code did not implement it. A path-less commit must contribute a ZERO commit secret -------------------------------------------------------- `commit()` derives path secrets unconditionally — it needs them to build the UpdatePath when there is one — and then keyed the commit secret on whether those secrets existed rather than on whether the path was actually SENT. A SelfRemove-only commit omits the path (RFC 9420 §12.4.1), so every receiver used the zero vector while the committer used a derived one: different epoch secrets, and every witness rejected the commit with a confirmation-tag mismatch and fell an epoch behind. The same branch also overwrote `pathPrivateKeys` with keys that were never published, discarding the ones that could still decrypt commits addressed to our ancestors. The pool is an obligation, so it is durable -------------------------------------------- `MlsGroupState` gains `pendingProposals` (STATE_VERSION 4; older blobs decode with an empty pool), and staging a peer's standalone proposal now persists the group the way an epoch change does — it only mutated memory before. Losing the pool to a restart does not lose a message, it loses the obligation: nobody is left holding the proposal that evicts the leaver. That also makes `stageCommit`'s explicit hand-off of the pool redundant, so it goes back to the shared `stage` helper and `adoptPendingProposals` is removed. `leaver-removal-secrecy` now replays instead of asserting its own divergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
+110
-8
@@ -27,6 +27,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -44,14 +45,18 @@ class MarmotLeaveProposalTest {
|
||||
|
||||
private class Fixture {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val manager =
|
||||
MarmotManager(
|
||||
signer,
|
||||
SnapshotStateStore(),
|
||||
SnapshotMessageStore(),
|
||||
SnapshotBundleStore(),
|
||||
publisher = ACCEPTING_RELAY,
|
||||
)
|
||||
val mlsStore = SnapshotStateStore()
|
||||
val messageStore = SnapshotMessageStore()
|
||||
val bundleStore = SnapshotBundleStore()
|
||||
var manager = build()
|
||||
|
||||
private fun build() = MarmotManager(signer, mlsStore, messageStore, bundleStore, publisher = ACCEPTING_RELAY)
|
||||
|
||||
/** Drop the process and come back over the same durable stores. */
|
||||
suspend fun restart() {
|
||||
manager = build()
|
||||
manager.restoreAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,4 +101,101 @@ class MarmotLeaveProposalTest {
|
||||
"the group's keys and keep reading everything sent after they left",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every member applies the commit that evicts the leaver, not just the committer`() =
|
||||
runBlocking {
|
||||
// Three members, because the bug only shows with a WITNESS: alice
|
||||
// commits carol's departure, and bob has to reach the same state
|
||||
// from the commit alone.
|
||||
val alice = Fixture()
|
||||
val bob = Fixture()
|
||||
val carol = Fixture()
|
||||
alice.manager.createCurrentProfileGroup(
|
||||
nostrGroupId = nostrGroupId,
|
||||
relays = listOf("wss://relay.invalid"),
|
||||
profile = GroupProfileV1("departures", ""),
|
||||
)
|
||||
|
||||
val (commit, welcomes) =
|
||||
alice.manager.addMembers(
|
||||
nostrGroupId,
|
||||
listOf(
|
||||
bob.manager.generateKeyPackageEvent(relays = emptyList()),
|
||||
carol.manager.generateKeyPackageEvent(relays = emptyList()),
|
||||
),
|
||||
emptyList(),
|
||||
)
|
||||
welcomes.forEach { delivery ->
|
||||
when (delivery.recipientPubKey) {
|
||||
bob.signer.pubKey -> bob.manager.ingest(delivery.giftWrapEvent)
|
||||
carol.signer.pubKey -> carol.manager.ingest(delivery.giftWrapEvent)
|
||||
}
|
||||
}
|
||||
alice.manager.ingest(commit.signedEvent)
|
||||
assertEquals(3, alice.manager.memberCount(nostrGroupId))
|
||||
|
||||
// Carol departs. Her proposal reaches everyone, as it does on the
|
||||
// wire — it is published as its own group event.
|
||||
val proposal = carol.manager.leaveGroup(nostrGroupId)
|
||||
alice.manager.ingest(proposal.signedEvent)
|
||||
bob.manager.ingest(proposal.signedEvent)
|
||||
|
||||
// Alice, the admin, commits it.
|
||||
val eviction = alice.manager.commitPendingProposals(nostrGroupId, emptyList())
|
||||
assertNotNull(eviction, "the staged SelfRemove must produce a commit")
|
||||
assertEquals(2, alice.manager.memberCount(nostrGroupId))
|
||||
|
||||
// Bob applies that commit. He must land exactly where alice is.
|
||||
bob.manager.ingest(eviction.signedEvent)
|
||||
|
||||
assertEquals(
|
||||
alice.manager.groupEpoch(nostrGroupId),
|
||||
bob.manager.groupEpoch(nostrGroupId),
|
||||
"a witness that stays an epoch behind cannot read anything the group sends next",
|
||||
)
|
||||
assertEquals(2, bob.manager.memberCount(nostrGroupId))
|
||||
|
||||
// And the right person left. An inline SelfRemove is attributed to
|
||||
// whoever committed it, so getting this wrong evicts the COMMITTER.
|
||||
val remaining =
|
||||
bob.manager
|
||||
.memberPubkeys(nostrGroupId)
|
||||
.map { it.pubkey }
|
||||
.toSet()
|
||||
assertEquals(setOf(alice.signer.pubKey, bob.signer.pubKey), remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a staged SelfRemove survives a restart`() =
|
||||
runBlocking {
|
||||
val alice = Fixture()
|
||||
val bob = Fixture()
|
||||
alice.manager.createCurrentProfileGroup(
|
||||
nostrGroupId = nostrGroupId,
|
||||
relays = listOf("wss://relay.invalid"),
|
||||
profile = GroupProfileV1("durable departures", ""),
|
||||
)
|
||||
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
|
||||
val (commit, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
|
||||
bob.manager.ingest(welcome!!.giftWrapEvent)
|
||||
alice.manager.ingest(commit.signedEvent)
|
||||
|
||||
// Bob departs and alice stages his proposal — then alice's process
|
||||
// dies before anyone commits it.
|
||||
alice.manager.ingest(bob.manager.leaveGroup(nostrGroupId).signedEvent)
|
||||
assertTrue(alice.manager.groupManager.hasPendingProposals(nostrGroupId))
|
||||
|
||||
alice.restart()
|
||||
|
||||
// The obligation has to come back. Losing it is not losing a
|
||||
// message — it leaves bob in the tree holding the group's keys,
|
||||
// with nobody holding the proposal that evicts him.
|
||||
assertTrue(
|
||||
alice.manager.groupManager.hasPendingProposals(nostrGroupId),
|
||||
"a staged SelfRemove must survive a restart, or the leaver never leaves",
|
||||
)
|
||||
assertNotNull(alice.manager.commitPendingProposals(nostrGroupId, emptyList()))
|
||||
assertEquals(1, alice.manager.memberCount(nostrGroupId))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-23
@@ -125,32 +125,19 @@ class MarmotScenarioVectorTest {
|
||||
fun restartDeliveryFaults() = replay("restart-delivery-faults.v1.json")
|
||||
|
||||
/**
|
||||
* `leaver-removal-secrecy` gets most of the way and stops at a narrower
|
||||
* bug than the one it found.
|
||||
* A leaver stops being able to read the group at the commit that evicts
|
||||
* them — and every OTHER member applies that commit too.
|
||||
*
|
||||
* It surfaced that a departing member's `SelfRemove` was staged, committed,
|
||||
* and then NOT applied — `MlsGroup.saveState()` does not carry the
|
||||
* staged-proposal pool, so the staging clone committed an empty proposal
|
||||
* list, advanced the epoch, and left the leaver in the tree with the keys.
|
||||
* That is fixed (see `MarmotLeaveProposalTest`), and the committer now
|
||||
* reaches the expected epoch and membership.
|
||||
*
|
||||
* What remains: a PEER that has the same proposal staged does not apply the
|
||||
* commit carrying it inline — bob stays an epoch behind and cannot read
|
||||
* what follows. Asserted rather than deleted so it stays visible; the day
|
||||
* that is fixed this test fails and the vector moves up to [replay].
|
||||
* This vector found two real defects. The staged-proposal pool did not
|
||||
* travel with the group state, so the commit meant to evict the leaver
|
||||
* carried an empty proposal list and left them in the tree with the keys.
|
||||
* And a peer's proposal was inlined into that commit, which attributes it
|
||||
* to the committer, while the committer derived a path-based commit secret
|
||||
* for a commit that carries no path — so every witness rejected it and
|
||||
* fell an epoch behind.
|
||||
*/
|
||||
@Test
|
||||
fun aPeerWithTheSameProposalStagedStillMissesTheCommit() {
|
||||
val thrown =
|
||||
assertFailsWith<IllegalStateException> {
|
||||
runBlocking { MarmotScenarioRunner(load("leaver-removal-secrecy.v1.json")).run() }
|
||||
}
|
||||
assertTrue(
|
||||
thrown.message.orEmpty().contains("bob[default] epoch 2, expected 3"),
|
||||
"the remaining divergence must still be the peer left behind: ${thrown.message}",
|
||||
)
|
||||
}
|
||||
fun leaverRemovalSecrecy() = replay("leaver-removal-secrecy.v1.json")
|
||||
|
||||
/**
|
||||
* `convergence-committer-selected` concludes with a `convergence_decision`
|
||||
|
||||
+6
-4
@@ -666,11 +666,13 @@ class MarmotInboundProcessor(
|
||||
// without this every other member silently dropped the
|
||||
// proposal and the admin's commit then failed with "Commit
|
||||
// references unknown proposal" (marmot-interop test 15).
|
||||
val group =
|
||||
groupManager.getGroup(groupId)
|
||||
?: return GroupEventResult.Error(groupId, "Group not found")
|
||||
if (groupManager.getGroup(groupId) == null) {
|
||||
return GroupEventResult.Error(groupId, "Group not found")
|
||||
}
|
||||
try {
|
||||
group.receivePublicMessageProposal(pubMsg)
|
||||
// Staged AND persisted: the pool is an obligation to
|
||||
// commit, so it has to outlive this process.
|
||||
groupManager.receiveStandaloneProposal(groupId, pubMsg)
|
||||
GroupEventResult.ProposalStaged(groupId, pubMsg.sender.leafIndex)
|
||||
} catch (e: Exception) {
|
||||
GroupEventResult.Error(
|
||||
|
||||
+46
-18
@@ -187,21 +187,6 @@ class MlsGroup private constructor(
|
||||
*/
|
||||
fun hasPendingProposals(): Boolean = pendingProposals.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Replace this group's staged-proposal pool with [proposals].
|
||||
*
|
||||
* Exists for one reason: [saveState] does NOT serialize the pool, so a
|
||||
* clone made for staging a commit starts empty, and `commit()` on it
|
||||
* produces an EMPTY commit — the epoch advances and every proposal the
|
||||
* commit was meant to apply is silently dropped. A departing member's
|
||||
* `SelfRemove` is the case that bites: the group looks like it processed
|
||||
* the departure, and the leaver is still in the tree holding the keys.
|
||||
*/
|
||||
internal fun adoptPendingProposals(proposals: List<PendingProposal>) {
|
||||
pendingProposals.clear()
|
||||
pendingProposals.addAll(proposals)
|
||||
}
|
||||
|
||||
/**
|
||||
* The GroupContext extension list as it stands. Test-only: callers
|
||||
* that want the dictionary should use [appDataDictionary], which
|
||||
@@ -337,6 +322,11 @@ class MlsGroup private constructor(
|
||||
// key+nonce within this epoch (RFC 9420 §9).
|
||||
senderRatchetStates = secretTree.exportSenderStates(),
|
||||
pathPrivateKeys = pathPrivateKeys.toMap(),
|
||||
// A staged proposal is an obligation, not a message: a departing
|
||||
// member's SelfRemove sits here until someone commits it, and a
|
||||
// restart that forgot it would leave the leaver in the tree with
|
||||
// the group's keys and nobody holding the proposal to evict them.
|
||||
pendingProposals = pendingProposals.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -668,7 +658,27 @@ class MlsGroup private constructor(
|
||||
// `ValidationError(InvalidMembershipTag)`.
|
||||
val preCommitExtensions = groupContext.extensions
|
||||
|
||||
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
|
||||
// Inline only what WE authored. A proposal from another member has to
|
||||
// go in by REFERENCE, because an inline proposal carries no sender: a
|
||||
// receiving peer attributes it to the committer (see the
|
||||
// `ProposalOrRef.Inline` branch of `processCommitInner`). For a
|
||||
// `SelfRemove` that is not a cosmetic difference — the proposal means
|
||||
// "remove my leaf", so inlining someone else's says "remove the
|
||||
// committer's leaf", and every witness either evicts the wrong member
|
||||
// or refuses the commit outright and falls an epoch behind.
|
||||
//
|
||||
// A reference resolves against the receiver's own pending pool, which
|
||||
// is where their copy of the same standalone proposal already sits,
|
||||
// carrying the ORIGINAL proposer's leaf index.
|
||||
val proposalOrRefs =
|
||||
proposals.map { pending ->
|
||||
if (pending.senderLeafIndex == myLeafIndex) {
|
||||
ProposalOrRef.Inline(pending.proposal)
|
||||
} else {
|
||||
val refValue = pending.authenticatedContentBytes ?: pending.proposal.toTlsBytes()
|
||||
ProposalOrRef.Reference(MlsCryptoProvider.refHash("MLS 1.0 Proposal Reference", refValue))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need an UpdatePath. RFC 9420 §12.4.1: the path value
|
||||
// MUST be populated if the proposal list is empty (pure forward-
|
||||
@@ -707,7 +717,13 @@ class MlsGroup private constructor(
|
||||
// We just minted the keys for our whole direct path. Keep the private
|
||||
// halves: the next committer will address us at one of these nodes,
|
||||
// not at our leaf, as soon as our subtree is merged.
|
||||
run {
|
||||
//
|
||||
// ONLY when this commit actually carries the path. A commit that omits
|
||||
// the UpdatePath never publishes these public halves, so the tree keeps
|
||||
// the old keys and a peer still encrypts to those — storing the fresh
|
||||
// private halves here would overwrite the ones that can actually
|
||||
// decrypt the next commit addressed to our ancestors.
|
||||
if (needsPath && pathSecrets.isNotEmpty()) {
|
||||
val fullPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
|
||||
pathPrivateKeys.keys.retainAll(fullPath.toSet())
|
||||
for ((i, nodeIdx) in fullPath.withIndex()) {
|
||||
@@ -890,8 +906,19 @@ class MlsGroup private constructor(
|
||||
// encryption-key seed rather than the key-schedule contribution. That
|
||||
// one-step gap silently diverged the two sides' epoch_secret and made
|
||||
// every cross-impl commit fail `ConfirmationTagMismatch`.
|
||||
//
|
||||
// Keyed on whether the commit CARRIES a path, not on whether we happened
|
||||
// to derive path secrets. RFC 9420 §12.4.2: a commit with no
|
||||
// `update_path` contributes a zero commit_secret, which is exactly what
|
||||
// every receiver uses (see the `commit.updatePath != null` branch of
|
||||
// `processCommitInner`). We derive `pathSecrets` unconditionally to
|
||||
// build the path when it is needed; using them for the key schedule
|
||||
// when the path was OMITTED gives the committer an epoch secret nobody
|
||||
// else can reach, and every member rejects the commit with a
|
||||
// confirmation-tag mismatch. A SelfRemove-only commit — a departing
|
||||
// member's eviction — is precisely the case that omits the path.
|
||||
val commitSecret =
|
||||
if (pathSecrets.isNotEmpty()) {
|
||||
if (updatePath != null && pathSecrets.isNotEmpty()) {
|
||||
MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
|
||||
} else {
|
||||
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||
@@ -4133,6 +4160,7 @@ class MlsGroup private constructor(
|
||||
encryptionPrivateKey = state.encryptionPrivateKey,
|
||||
interimTranscriptHash = state.interimTranscriptHash,
|
||||
pathPrivateKeys = state.pathPrivateKeys.toMutableMap(),
|
||||
pendingProposals = state.pendingProposals.toMutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+24
-13
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.marmot.mls.group
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.ExternalJoinResult
|
||||
@@ -468,6 +469,24 @@ class MlsGroupManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a peer's standalone proposal and PERSIST the group.
|
||||
*
|
||||
* Staging alone only mutates memory, and a staged proposal is an
|
||||
* obligation rather than a message: a departing member's `SelfRemove` sits
|
||||
* in the pool until someone commits it. Losing it to a restart leaves the
|
||||
* leaver in the tree, still holding the group's keys, with nobody holding
|
||||
* the proposal that would evict them — so this writes through the same way
|
||||
* an epoch change does.
|
||||
*/
|
||||
suspend fun receiveStandaloneProposal(
|
||||
nostrGroupId: HexKey,
|
||||
pubMsg: PublicMessage,
|
||||
) = mutex.withLock {
|
||||
requireGroup(nostrGroupId).receivePublicMessageProposal(pubMsg)
|
||||
persistGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
/** Whether [nostrGroupId] has a staged proposal waiting for a Commit. */
|
||||
fun hasPendingProposals(nostrGroupId: HexKey): Boolean = groups[nostrGroupId]?.hasPendingProposals() == true
|
||||
|
||||
@@ -476,20 +495,12 @@ class MlsGroupManager(
|
||||
*
|
||||
* Unlike every other `stage*` entry point this one has nothing of its own
|
||||
* to propose — the proposals are already in the LIVE group's pool, put
|
||||
* there by ingesting a peer's standalone proposal. [MlsGroup.saveState]
|
||||
* does not carry that pool, so the clone must be handed it explicitly;
|
||||
* without that this commits an empty proposal list, advances the epoch,
|
||||
* and drops the very proposal it was called to apply.
|
||||
* there by ingesting a peer's standalone proposal. That pool travels with
|
||||
* [MlsGroup.saveState], so the clone inherits it; when it did not, this
|
||||
* committed an empty proposal list, advanced the epoch, and dropped the
|
||||
* very proposal it was called to apply.
|
||||
*/
|
||||
suspend fun stageCommit(nostrGroupId: HexKey): StagedCommit =
|
||||
mutex.withLock {
|
||||
val live = requireGroup(nostrGroupId)
|
||||
val priorState = live.saveState()
|
||||
val clone = MlsGroup.restore(priorState)
|
||||
clone.adoptPendingProposals(live.pendingProposalsSnapshot())
|
||||
val result = clone.commit()
|
||||
StagedCommit(result, priorState, clone.saveState())
|
||||
}
|
||||
suspend fun stageCommit(nostrGroupId: HexKey): StagedCommit = stage(nostrGroupId) { it.commit() }
|
||||
|
||||
/**
|
||||
* Process a received Commit, advancing the epoch.
|
||||
|
||||
+51
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.marmot.mls.group
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Proposal
|
||||
import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets
|
||||
import com.vitorpamplona.quartz.marmot.mls.schedule.SenderRatchetState
|
||||
|
||||
@@ -71,6 +72,16 @@ data class MlsGroupState(
|
||||
* across a restart makes the same group undecryptable on relaunch.
|
||||
*/
|
||||
val pathPrivateKeys: Map<Int, ByteArray> = emptyMap(),
|
||||
/**
|
||||
* Proposals staged but not yet committed (STATE_VERSION 4+).
|
||||
*
|
||||
* Mostly this pool holds a departing member's standalone `SelfRemove`,
|
||||
* waiting for an authorized member to commit it. Dropping it on restart
|
||||
* does not lose a message — it loses the OBLIGATION: the leaver stays in
|
||||
* the tree, still holding the group's keys, and nobody is left holding
|
||||
* the proposal that would evict them.
|
||||
*/
|
||||
val pendingProposals: List<PendingProposal> = emptyList(),
|
||||
) {
|
||||
fun encodeTls(): ByteArray {
|
||||
val writer = TlsWriter()
|
||||
@@ -133,6 +144,17 @@ data class MlsGroupState(
|
||||
writer.putOpaqueVarInt(key)
|
||||
}
|
||||
|
||||
// Staged proposals (STATE_VERSION 4+). The AuthenticatedContent bytes
|
||||
// travel with each entry because they, not the bare proposal, are what
|
||||
// a later `ProposalRef` hashes (RFC 9420 §5.2) — a restored pool that
|
||||
// lost them could no longer be matched by a commit that references it.
|
||||
writer.putUint32(pendingProposals.size.toLong())
|
||||
for (pending in pendingProposals) {
|
||||
writer.putUint32(pending.senderLeafIndex.toLong())
|
||||
writer.putOpaqueVarInt(pending.proposal.toTlsBytes())
|
||||
writer.putOpaqueVarInt(pending.authenticatedContentBytes ?: ByteArray(0))
|
||||
}
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
@@ -156,8 +178,11 @@ data class MlsGroupState(
|
||||
* v3: appends [pathPrivateKeys] so a restore can still decrypt an
|
||||
* UpdatePath addressed at one of our ancestors. Older blobs decode
|
||||
* with an empty map and refill on the next commit we process.
|
||||
* v4: appends [pendingProposals] so a departing member's staged
|
||||
* `SelfRemove` survives a restart instead of leaving them in the
|
||||
* tree. Older blobs decode with an empty pool.
|
||||
*/
|
||||
private const val STATE_VERSION = 3
|
||||
private const val STATE_VERSION = 4
|
||||
|
||||
fun decodeTls(data: ByteArray): MlsGroupState {
|
||||
val reader = TlsReader(data)
|
||||
@@ -234,6 +259,30 @@ data class MlsGroupState(
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
// v4+: proposals staged and not yet committed. Absent for older
|
||||
// blobs, which restore with an empty pool — the same behaviour
|
||||
// every version before this one had.
|
||||
val pendingProposals =
|
||||
if (version >= 4 && reader.hasRemaining) {
|
||||
val count = reader.readUint32().toInt()
|
||||
buildList {
|
||||
repeat(count) {
|
||||
val senderLeafIndex = reader.readUint32().toInt()
|
||||
val proposal = Proposal.decodeTls(TlsReader(reader.readOpaqueVarInt()))
|
||||
val authenticatedContentBytes = reader.readOpaqueVarInt()
|
||||
add(
|
||||
PendingProposal(
|
||||
proposal = proposal,
|
||||
senderLeafIndex = senderLeafIndex,
|
||||
authenticatedContentBytes = authenticatedContentBytes.takeIf { it.isNotEmpty() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return MlsGroupState(
|
||||
groupContext = groupContext,
|
||||
treeBytes = treeBytes,
|
||||
@@ -246,6 +295,7 @@ data class MlsGroupState(
|
||||
encryptionSecret = encryptionSecret,
|
||||
senderRatchetStates = senderRatchetStates,
|
||||
pathPrivateKeys = pathPrivateKeys,
|
||||
pendingProposals = pendingProposals,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user