mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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