fix(marmot): enforce required_capabilities on Add and Welcome (RFC 9420 §7.2)

When a group installs a `required_capabilities` extension (every Marmot
group does, listing MarmotGroupData=0xF2EE, SelfRemove=0x000A, Basic
credential), every member's leaf MUST advertise those types in its own
[Capabilities]. We were validating none of that:

- `applyProposalAdd` checked version + ciphersuite but never matched the
  new KP's capabilities against the group's required_capabilities. A
  non-conformant member silently joined; the next commit that touched
  their leaf got rejected by spec-conformant peers, splitting the group.
- `processWelcome` similarly never checked the joiner's own KP against
  the group's required set, nor did it sweep existing members' leaves.
  A misconfigured GroupInfo signer could invite us into an incoherent
  group whose first commit we'd silently reject forever.

Adds two helpers in `MlsGroup.Companion`:
- `findRequiredCapabilities(extensions)`: decodes the §7.2 struct from
  a GroupContext extension list, or returns null if absent.
- `requireCapabilitiesMeetRequirements(caps, req, who)`: throws with a
  specific (extensions=, proposals=, credentials=) diff naming the
  missing types — turns silent interop breaks into one debuggable line.

Wires the gate in two places:
- `applyProposalAdd` rejects the Add proposal if the new leaf falls
  short of the group's required set.
- `processWelcome` rejects the join if either (a) our own KP doesn't
  meet the group's requirements or (b) any existing member's leaf
  doesn't — the latter catches a malformed GroupInfo at join time.

5 new tests: round-trip decoding, rejection on missing extension /
proposal, acceptance when caps are a superset, and an end-to-end
`addMember` rejection of a hand-crafted KP with SelfRemove stripped
(re-signed so the rejection is from the capability gate, not the
signature check). Quartz test suite green; marmot interop 16/16.

Closes audit gaps #4, #5, #10.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
This commit is contained in:
Claude
2026-04-25 04:22:42 +00:00
parent d6a61d5ac5
commit a42499435b
2 changed files with 240 additions and 0 deletions
@@ -2053,6 +2053,16 @@ class MlsGroup private constructor(
"KeyPackage does not support ciphersuite 0x0001"
}
// RFC 9420 §12.4.2: an Add proposal MUST be rejected if the new
// member's leaf capabilities don't advertise every type listed in
// the group's `required_capabilities` extension. Without this gate
// a non-conformant member silently joins, and any subsequent commit
// that touches their leaf is rejected by peers that DO enforce the
// requirement — splitting the group on the next epoch.
findRequiredCapabilities(groupContext.extensions)?.let { req ->
requireCapabilitiesMeetRequirements(caps, req, "Add KeyPackage leaf")
}
return tree.addLeaf(leafNode)
}
@@ -2545,6 +2555,67 @@ class MlsGroup private constructor(
)
}
/**
* Parsed view of the RFC 9420 §7.2 `required_capabilities` extension.
*
* The on-wire struct is three `uint16<V>` vectors —
* `extensions / proposals / credentials` — that name the types every
* member of the group MUST advertise in their leaf [Capabilities].
*/
internal data class RequiredCapabilities(
val extensions: List<Int>,
val proposals: List<Int>,
val credentials: List<Int>,
)
/**
* Decode the `required_capabilities` extension from the GroupContext
* extension list, or `null` if the group hasn't installed one (some
* peers may omit it; treat missing as "no restriction").
*/
internal fun findRequiredCapabilities(extensions: List<Extension>): RequiredCapabilities? {
val ext = extensions.find { it.extensionType == REQUIRED_CAPABILITIES_EXTENSION_TYPE } ?: return null
val r = TlsReader(ext.extensionData)
fun readUint16List(data: ByteArray): List<Int> {
val rr = TlsReader(data)
val list = mutableListOf<Int>()
while (rr.hasRemaining) list.add(rr.readUint16())
return list
}
val exts = readUint16List(r.readOpaqueVarInt())
val props = readUint16List(r.readOpaqueVarInt())
val creds = readUint16List(r.readOpaqueVarInt())
return RequiredCapabilities(exts, props, creds)
}
/**
* RFC 9420 §7.2 + §12.4.2: every member's leaf [Capabilities] MUST
* advertise every type listed in the group's `required_capabilities`
* extension. Adding a member whose KeyPackage doesn't meet the
* requirement leaves the group in a non-conformant state where
* peers that DO enforce the requirement will reject any commit that
* touches that leaf.
*
* Throws [IllegalStateException] with a precise diff so debugging
* an interop break against another implementation is one log line.
*/
internal fun requireCapabilitiesMeetRequirements(
caps: Capabilities,
req: RequiredCapabilities,
who: String,
) {
val missingExts = req.extensions.filter { it !in caps.extensions }
val missingProps = req.proposals.filter { it !in caps.proposals }
val missingCreds = req.credentials.filter { it !in caps.credentials }
if (missingExts.isNotEmpty() || missingProps.isNotEmpty() || missingCreds.isNotEmpty()) {
throw IllegalStateException(
"$who capabilities don't meet required_capabilities: " +
"missing extensions=$missingExts proposals=$missingProps credentials=$missingCreds",
)
}
}
/**
* Default MLS leaf Capabilities that advertise support for Marmot's
* required extensions and proposals so new members can join a group
@@ -2734,6 +2805,26 @@ class MlsGroup private constructor(
"GroupInfo tree_hash does not match ratchet_tree extension"
}
// RFC 9420 §7.2 + §12.4.3.1: a joiner MUST refuse to join a
// group whose `required_capabilities` lists types the joiner's
// own KeyPackage doesn't advertise — peers that DO enforce the
// requirement would reject every commit the joiner produces
// anyway. Catching this at join time turns a silent "your
// commits get dropped forever" into an actionable error.
findRequiredCapabilities(groupContext.extensions)?.let { req ->
val myLeaf = tree.getLeaf(myLeafIndex)
requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" }
requireCapabilitiesMeetRequirements(myLeaf.capabilities, req, "Joiner KeyPackage")
// Mirror the same check across every existing member —
// if a peer's leaf doesn't meet the group's stated
// requirements, the GroupInfo signer mis-installed the
// requirement set and the group is incoherent.
for (i in 0 until tree.leafCount) {
val leaf = tree.getLeaf(i) ?: continue
requireCapabilitiesMeetRequirements(leaf.capabilities, req, "Member leaf $i")
}
}
// Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3)
// For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh)
val epochSecret =
@@ -551,6 +551,155 @@ class MarmotMipBehaviorTest {
)
}
// ----------------------------------------------------------------------
// RFC 9420 §7.2 / §12.4.2 required_capabilities enforcement
// ----------------------------------------------------------------------
/**
* Round-trip: the `required_capabilities` extension Marmot installs on
* every fresh group decodes back to its declared (extensions, proposals,
* credentials) triple.
*/
@Test
fun findRequiredCapabilities_decodesMarmotExtensionInstalledByCreate() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val req =
MlsGroup.findRequiredCapabilities(alice.extensions)
?: error("required_capabilities must be present after create()")
assertEquals(listOf(0xF2EE), req.extensions, "MarmotGroupData (0xF2EE) must be required")
assertEquals(listOf(0x000A), req.proposals, "SelfRemove (0x000A) must be required")
assertEquals(listOf(0x0001), req.credentials, "Basic credential must be required")
}
/**
* `requireCapabilitiesMeetRequirements` must throw when ANY required
* type is missing extension OR proposal OR credential and the
* error must name the missing types so an interop debugger can
* diagnose without grepping.
*/
@Test
fun requireCapabilitiesMeetRequirements_rejectsMissingExtension() {
val req =
MlsGroup.Companion.RequiredCapabilities(
extensions = listOf(0xF2EE),
proposals = listOf(0x000A),
credentials = listOf(0x0001),
)
// Missing 0xF2EE.
val caps =
com.vitorpamplona.quartz.marmot.mls.tree.Capabilities(
extensions = emptyList(),
proposals = listOf(0x000A),
credentials = listOf(0x0001),
)
val ex =
assertFailsWith<IllegalStateException> {
MlsGroup.requireCapabilitiesMeetRequirements(caps, req, "test")
}
assertTrue(
ex.message!!.contains("extensions=[62190]") || ex.message!!.contains("0xF2EE"),
"error must name the missing extension type: ${ex.message}",
)
}
@Test
fun requireCapabilitiesMeetRequirements_rejectsMissingProposal() {
val req =
MlsGroup.Companion.RequiredCapabilities(
extensions = emptyList(),
proposals = listOf(0x000A),
credentials = emptyList(),
)
val caps =
com.vitorpamplona.quartz.marmot.mls.tree.Capabilities(
extensions = emptyList(),
proposals = emptyList(),
credentials = listOf(0x0001),
)
assertFailsWith<IllegalStateException> {
MlsGroup.requireCapabilitiesMeetRequirements(caps, req, "test")
}
}
@Test
fun requireCapabilitiesMeetRequirements_passesWhenCapsAreSuperset() {
val req =
MlsGroup.Companion.RequiredCapabilities(
extensions = listOf(0xF2EE),
proposals = listOf(0x000A),
credentials = listOf(0x0001),
)
val caps =
com.vitorpamplona.quartz.marmot.mls.tree.Capabilities(
extensions = listOf(0xF2EE, 0x1234),
proposals = listOf(0x000A, 0x000B),
credentials = listOf(0x0001, 0x0002),
)
// Should not throw.
MlsGroup.requireCapabilitiesMeetRequirements(caps, req, "test")
}
/**
* End-to-end gate: `addMember` MUST reject a KeyPackage whose leaf
* doesn't advertise the group's `required_capabilities`. We tamper the
* KP's leaf capabilities to drop SelfRemove, then re-encode and re-sign
* to keep the KP signature valid (RFC 9420 §10.1) so the rejection is
* coming from the capability gate and not the signature check.
*/
@Test
fun addMember_rejectsKeyPackageMissingRequiredProposal() =
runBlocking<Unit> {
// Setup: standard Marmot group (required_capabilities lists
// SelfRemove + MarmotGroupData + Basic).
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
// Bob's KP, but with SelfRemove stripped from his leaf
// capabilities. He's still announcing himself as a Marmot peer
// — just lying about supporting SelfRemove.
val tampered = createKeyPackageWithoutSelfRemove(bobId)
assertFailsWith<IllegalStateException> {
manager.addMember(groupId, tampered.toTlsBytes())
}
}
/**
* Build a KeyPackage whose leaf [Capabilities] does NOT list 0x000A
* (SelfRemove), then re-sign so the KP's outer signature still
* validates. Useful for testing the §7.2 gate in isolation.
*/
private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage {
val tempGroup = MlsGroup.create(identity.hexToByteArray())
val bundle = tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0))
val original = bundle.keyPackage
val originalLeaf = original.leafNode
// Strip SelfRemove (0x000A) from the leaf's advertised proposals.
val tamperedCaps =
originalLeaf.capabilities.copy(
proposals = originalLeaf.capabilities.proposals.filter { it != 0x000A },
)
// Re-build the leaf node and re-sign its TBS so the leaf signature
// still verifies (otherwise we'd hit the LeafNode signature check
// before the capability gate fires).
val tamperedLeaf =
originalLeaf.copy(capabilities = tamperedCaps).let { lf ->
val tbs = lf.encodeTbs()
val sig =
com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
.signWithLabel(bundle.signaturePrivateKey, "LeafNodeTBS", tbs)
lf.copy(signature = sig)
}
// Re-sign the KeyPackage TBS over the new leaf.
val unsigned = original.copy(leafNode = tamperedLeaf, signature = ByteArray(0))
return unsigned.copy(
signature =
com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
.signWithLabel(bundle.signaturePrivateKey, "KeyPackageTBS", unsigned.encodeTbs()),
)
}
// ----------------------------------------------------------------------
// RFC 9420 §5.3 psk_secret derivation
// ----------------------------------------------------------------------