Files
amethyst/quartz
Claude 57c3265ca0 feat(marmot): receive standalone PrivateMessage proposals (RFC 9420 §6.3.2)
The PROPOSAL branch of `MlsGroup.decrypt` previously threw
`IllegalStateException("Standalone PrivateMessage proposals not yet
supported")`. That worked in the openmls-as-Whitenoise topology because
MDK emits SelfRemove as a PublicMessage — but any peer using the
AlwaysCiphertext wire-format policy (RFC 9420 §6.3.2) wraps standalone
proposals in PrivateMessage, and we'd hard-fail on first contact.

Implements the PROPOSAL branch symmetrically with the existing COMMIT
branch:

1. Decode the Proposal struct from PrivateMessageContent (no length
   prefix), read signature<V>, drain zero-padding.
2. Restrict to SelfRemove (mirrors `receivePublicMessageProposal`'s
   policy — the only standalone proposal type that needs to ride
   outside a commit; widening is one-line if interop demands it).
3. Verify the FramedContentTBS signature with `wire_format =
   PRIVATE_MESSAGE` against the sender's leaf signature_key.
4. Stage the proposal in `pendingProposals` together with the encoded
   AuthenticatedContent (wire_format ‖ FramedContent ‖ signature) so
   a subsequent inbound commit folding it in by ProposalRef can
   resolve via the §5.2 hash. PrivateMessage AC bytes carry no
   membership_tag — auth = signature only.

Adds a symmetric `encryptProposalAsPrivateMessage` helper (internal,
mirrors `encrypt` for application data) so tests can exercise the
inbound path with a real wire frame produced by the same code path
peers use.

2 new tests: round-trip Bob→Alice SelfRemove via Welcome flow with
independent MlsGroup instances, verifies the proposal lands in
Alice's pending pool with AC bytes captured; and rejection of a
non-SelfRemove standalone PrivateMessage proposal (PSK in the test).

Marmot test suite green; marmot-interop-headless 16/16.

Closes audit gap #1.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 05:00:24 +00:00
..