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
@@ -94,6 +94,17 @@ sealed class GroupEventResult {
val retainedEpochCount: Int,
) : GroupEventResult()
/**
* A standalone Proposal (currently only `SelfRemove`) was decoded,
* verified, and staged in the group's pending-proposals pool. The
* group epoch did not advance — the proposal is dormant until a
* subsequent Commit references it by hash.
*/
data class ProposalStaged(
val groupId: HexKey,
val senderLeafIndex: Int,
) : GroupEventResult()
/**
* The event could not be processed.
*/
@@ -474,7 +485,25 @@ class MarmotInboundProcessor(
}
ContentType.PROPOSAL -> {
GroupEventResult.Error(groupId, "Standalone proposals not yet supported")
// wn/openmls publishes SelfRemove as a standalone PublicMessage
// proposal — admins fold it into their next commit. Stage it
// locally so a subsequent commit's `ProposalRef` can resolve;
// 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")
try {
group.receivePublicMessageProposal(pubMsg)
GroupEventResult.ProposalStaged(groupId, pubMsg.sender.leafIndex)
} catch (e: Exception) {
GroupEventResult.Error(
groupId,
"Failed to stage standalone proposal: ${e.message}",
e,
)
}
}
ContentType.APPLICATION -> {
@@ -1260,8 +1260,18 @@ class MlsGroup private constructor(
val refHash = proposalOrRef.proposalRef
val resolved =
pendingProposals.find { pending ->
val proposalBytes = pending.proposal.toTlsBytes()
val hash = MlsCryptoProvider.refHash("MLS 1.0 Proposal Reference", proposalBytes)
// RFC 9420 §5.2: a ProposalRef hashes the encoded
// AuthenticatedContent that delivered the proposal,
// not just the bare Proposal struct. For inbound
// proposals (e.g. C's standalone SelfRemove) we
// captured those bytes at receive time. For local
// proposals (which we always inline in our own
// commits, never reference) we fall back to the
// bare-proposal hash — keeps existing flows
// working without needing an AC reconstruction
// round-trip on the send side.
val refValue = pending.authenticatedContentBytes ?: pending.proposal.toTlsBytes()
val hash = MlsCryptoProvider.refHash("MLS 1.0 Proposal Reference", refValue)
hash.contentEquals(refHash)
}
requireNotNull(resolved) {
@@ -3083,11 +3093,135 @@ class MlsGroup private constructor(
)
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes() to preCommitExporterSecret
}
/**
* Receive a standalone-proposal PublicMessage and stage it locally so a
* subsequent commit that references it (via `ProposalRef`) can resolve.
*
* Sent today by `wn`/openmls when a non-admin member self-removes — the
* member can't commit themselves out (admins reject `RequiredPathNotFound`),
* so they publish a `SelfRemove` proposal as a `PublicMessage` and wait
* for an admin to fold it into the next commit. Without this receiver
* side every other member silently drops the proposal, and the admin's
* subsequent commit then fails with "Commit references unknown proposal
* (ref not found in pending proposals)" — which is exactly the failure
* mode of marmot-interop test 15.
*
* Validation mirrors what every member-sender PublicMessage commit goes
* through: epoch + group_id match the current epoch, FramedContentTBS
* signature verifies against the sender's leaf signing key, and the
* membership_tag verifies against the current epoch's membership key.
* Anything missing or mismatched is a hard reject — the same threat
* model as PublicMessage commit reception.
*/
fun receivePublicMessageProposal(pubMsg: PublicMessage) {
require(pubMsg.contentType == ContentType.PROPOSAL) {
"Expected PublicMessage with content_type == PROPOSAL, got ${pubMsg.contentType}"
}
require(pubMsg.epoch == groupContext.epoch) {
"Proposal epoch ${pubMsg.epoch} doesn't match current epoch ${groupContext.epoch}"
}
require(pubMsg.groupId.contentEquals(groupContext.groupId)) {
"Proposal group_id doesn't match current group"
}
require(pubMsg.sender.senderType == SenderType.MEMBER) {
"Standalone proposals from non-members are not accepted"
}
val senderLeafIndex = pubMsg.sender.leafIndex
require(senderLeafIndex in 0 until tree.leafCount) {
"Sender leaf index $senderLeafIndex out of range"
}
val senderLeaf =
requireNotNull(tree.getLeaf(senderLeafIndex)) {
"Sender leaf is blank at index $senderLeafIndex"
}
// Reconstruct FramedContentTBS exactly as the sender did in
// `buildSelfRemoveProposalMessage` so the signature and the
// membership_tag both verify against bit-identical bytes.
val tbsWriter = TlsWriter()
tbsWriter.putUint16(MlsMessage.MLS_VERSION_10)
tbsWriter.putUint16(WireFormat.PUBLIC_MESSAGE.value)
tbsWriter.putOpaqueVarInt(pubMsg.groupId)
tbsWriter.putUint64(pubMsg.epoch)
encodeSender(tbsWriter, pubMsg.sender)
tbsWriter.putOpaqueVarInt(pubMsg.authenticatedData)
tbsWriter.putUint8(ContentType.PROPOSAL.value)
tbsWriter.putBytes(pubMsg.content) // proposal struct, no length prefix
tbsWriter.putBytes(groupContext.toTlsBytes())
val tbs = tbsWriter.toByteArray()
require(MlsCryptoProvider.verifyWithLabel(senderLeaf.signatureKey, "FramedContentTBS", tbs, pubMsg.signature)) {
"Invalid FramedContentTBS signature on PublicMessage proposal from leaf $senderLeafIndex"
}
val membershipTag =
requireNotNull(pubMsg.membershipTag) {
"PublicMessage proposal from leaf $senderLeafIndex is missing membership_tag"
}
val tbmWriter = TlsWriter()
tbmWriter.putBytes(tbs)
tbmWriter.putOpaqueVarInt(pubMsg.signature)
require(verifyMembershipTag(tbmWriter.toByteArray(), membershipTag)) {
"Invalid membership_tag on PublicMessage proposal from leaf $senderLeafIndex"
}
val proposal = Proposal.decodeTls(TlsReader(pubMsg.content))
// SelfRemove is the only proposal type wn currently emits as a
// standalone PublicMessage. Other types come bundled inside a
// commit's `proposals` list. Defending against e.g. a standalone
// Add/Remove here would also work — they'd land in pendingProposals
// and be picked up by the next commit that references them — but we
// refuse anything other than SelfRemove for now to keep the receive
// side minimal until there's an interop reason to widen it.
require(proposal is Proposal.SelfRemove) {
"Only standalone SelfRemove proposals are accepted; got ${proposal::class.simpleName}"
}
// Capture the AuthenticatedContent bytes (RFC 9420 §6.1) so a
// subsequent commit's `ProposalRef` lookup can hash them per §5.2:
//
// AuthenticatedContent = wire_format || FramedContent || FramedContentAuthData
//
// FramedContentAuthData for a PROPOSAL is just `signature` (no
// confirmation_tag). The TBS prefix (version + GroupContext) is
// NOT part of the AuthenticatedContent — those go into the
// signature input only.
val acWriter = TlsWriter()
acWriter.putUint16(WireFormat.PUBLIC_MESSAGE.value)
// FramedContent
acWriter.putOpaqueVarInt(pubMsg.groupId)
acWriter.putUint64(pubMsg.epoch)
encodeSender(acWriter, pubMsg.sender)
acWriter.putOpaqueVarInt(pubMsg.authenticatedData)
acWriter.putUint8(ContentType.PROPOSAL.value)
acWriter.putBytes(pubMsg.content)
// FramedContentAuthData (PROPOSAL: signature only)
acWriter.putOpaqueVarInt(pubMsg.signature)
val authenticatedContentBytes = acWriter.toByteArray()
pendingProposals.add(
PendingProposal(
proposal = proposal,
senderLeafIndex = senderLeafIndex,
authenticatedContentBytes = authenticatedContentBytes,
),
)
}
}
data class PendingProposal(
val proposal: Proposal,
val senderLeafIndex: Int,
/**
* Encoded `AuthenticatedContent` bytes for the message that delivered
* this proposal — the input to `MakeProposalRef` per RFC 9420 §5.2.
* Null when the proposal was created locally and never went through
* the MLS framing layer; in that case the lookup falls back to the
* bare `proposal.toTlsBytes()` since local commits inline rather than
* reference our own pending proposals.
*/
val authenticatedContentBytes: ByteArray? = null,
)
data class DecryptedMessage(