fix(marmot): receive standalone SelfRemove proposals (test 15)

Two bugs that conspired to break interop test 15 once the test 14 OOM
was fixed:

1. **No receive path for standalone PublicMessage proposals.**
   wn/openmls publishes a non-admin's `SelfRemove` as a kind:445 carrying
   a `PublicMessage(content_type=PROPOSAL)` envelope and waits for an
   admin to fold it into the next commit. Quartz's `MarmotInboundProcessor`
   answered every such event with `Error("Standalone proposals not yet
   supported")` and dropped it. The admin's subsequent commit then
   failed with `Commit references unknown proposal (ref not found in
   pending proposals)` because nobody had staged the SelfRemove.

   Add `MlsGroup.receivePublicMessageProposal(pubMsg)` that:
   - rejects mismatched epoch / group_id / sender,
   - reconstructs the FramedContentTBS exactly as the proposer did and
     verifies the leaf signature,
   - verifies the membership_tag against the current epoch's
     membership_key (same threat model as inbound PublicMessage commits),
   - decodes the inner Proposal (only `SelfRemove` is accepted today —
     other types come bundled in commits' `proposals` lists),
   - stages the proposal in `pendingProposals` so a later commit can
     resolve its `ProposalRef`.

   `processPublicMessage` now routes `ContentType.PROPOSAL` through
   that helper and returns a new `GroupEventResult.ProposalStaged`
   variant (also surfaced as `MarmotIngestResult.ProposalStaged`),
   replacing the old hard-error path.

2. **Wrong `ProposalRef` hash input.** RFC 9420 §5.2 specifies that a
   `ProposalRef` hashes the **encoded `AuthenticatedContent`** that
   delivered the proposal, not the bare `Proposal` struct. Quartz was
   hashing `proposal.toTlsBytes()` only — fine for our local-only
   flows where commits inline rather than reference our own pending
   proposals, but fatal once we needed to match wn's reference to an
   inbound proposal.

   Extend `PendingProposal` with an optional `authenticatedContentBytes`
   field. The standalone-proposal receive path captures the full
   `wire_format || FramedContent || FramedContentAuthData` envelope at
   stage time. The reference-resolution code in `processCommitInner`
   prefers those bytes when present and falls back to the bare-proposal
   hash for locally-proposed entries (which never get referenced
   today).

Marmot interop score: 14/16 → 15/16 (test 9 — amy's kind:7 reaction
triggers a `SecretReuseError` on B's wn — is unrelated to this path
and remains for follow-up).

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
This commit is contained in:
Claude
2026-04-25 02:22:04 +00:00
parent 359b6069f1
commit 9d912ad82a
4 changed files with 185 additions and 3 deletions
@@ -61,6 +61,17 @@ sealed class MarmotIngestResult {
val inner: GroupEventResult.CommitProcessed,
) : MarmotIngestResult()
/**
* A kind:445 carried a standalone Proposal (currently only `SelfRemove`)
* which was staged in the group's pending pool. The group epoch did
* not advance — a later Commit referencing this proposal will pick
* it up.
*/
data class ProposalStaged(
val groupId: HexKey,
val senderLeafIndex: Int,
) : MarmotIngestResult()
/** A kind:445 whose outer layer we couldn't decrypt (pre-join epoch, etc). Debug-only. */
data class UndecryptableOuter(
val groupId: HexKey,
@@ -138,6 +149,10 @@ private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngest
MarmotIngestResult.Commit(result)
}
is GroupEventResult.ProposalStaged -> {
MarmotIngestResult.ProposalStaged(result.groupId, result.senderLeafIndex)
}
is GroupEventResult.Duplicate,
is GroupEventResult.CommitPending,
-> {
@@ -113,6 +113,10 @@ class MarmotManager(
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
}
is GroupEventResult.ProposalStaged -> {
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
}
is GroupEventResult.CommitPending,
is GroupEventResult.Duplicate,
is GroupEventResult.UndecryptableOuterLayer,