Merge remote-tracking branch 'origin/main' into claude/cli-event-store-database-ef2U2

This commit is contained in:
Claude
2026-04-25 05:06:11 +00:00
13 changed files with 1920 additions and 128 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 -> {
@@ -196,11 +196,17 @@ data class PublicMessage(
override fun hashCode(): Int = groupId.contentHashCode()
companion object {
/** Same DoS cap that PrivateMessage applies to its AAD field. */
const val MAX_AUTHENTICATED_DATA_BYTES = 1 shl 16 // 64 KiB
fun decodeTls(reader: TlsReader): PublicMessage {
val groupId = reader.readOpaqueVarInt()
val epoch = reader.readUint64()
val sender = decodeSender(reader)
val authenticatedData = reader.readOpaqueVarInt()
require(authenticatedData.size <= MAX_AUTHENTICATED_DATA_BYTES) {
"PublicMessage authenticated_data too large: ${authenticatedData.size} > $MAX_AUTHENTICATED_DATA_BYTES"
}
val contentType = ContentType.fromValue(reader.readUint8())
// RFC 9420 §6 FramedContent.content body varies by content_type.
@@ -289,15 +295,47 @@ data class PrivateMessage(
override fun hashCode(): Int = groupId.contentHashCode()
companion object {
fun decodeTls(reader: TlsReader): PrivateMessage =
PrivateMessage(
groupId = reader.readOpaqueVarInt(),
epoch = reader.readUint64(),
contentType = ContentType.fromValue(reader.readUint8()),
authenticatedData = reader.readOpaqueVarInt(),
encryptedSenderData = reader.readOpaqueVarInt(),
ciphertext = reader.readOpaqueVarInt(),
/**
* Per-field DoS cap for `authenticated_data` and
* `encrypted_sender_data`. The underlying [TlsReader] already
* refuses any opaque<V> field larger than 1 MiB (its global
* `MAX_OPAQUE_SIZE`), but those fields shouldn't carry anything
* close to that — `encrypted_sender_data` is always exactly the
* sender-data AEAD ciphertext (a couple hundred bytes), and
* Marmot doesn't put anything in `authenticated_data` today.
* Tightening to 64 KiB makes the per-frame memory floor
* predictable and turns "we'd allocate up to 3 MiB on every
* inbound packet that escapes verification" into "we'll
* allocate at most ~1 MiB even before AEAD."
*/
const val MAX_AAD_BYTES = 1 shl 16 // 64 KiB
fun decodeTls(reader: TlsReader): PrivateMessage {
val groupId = reader.readOpaqueVarInt()
val epoch = reader.readUint64()
val contentType = ContentType.fromValue(reader.readUint8())
val authenticatedData = reader.readOpaqueVarInt()
require(authenticatedData.size <= MAX_AAD_BYTES) {
"PrivateMessage authenticated_data too large: ${authenticatedData.size} > $MAX_AAD_BYTES"
}
val encryptedSenderData = reader.readOpaqueVarInt()
require(encryptedSenderData.size <= MAX_AAD_BYTES) {
"PrivateMessage encrypted_sender_data too large: ${encryptedSenderData.size} > $MAX_AAD_BYTES"
}
// `ciphertext` size is bounded by [TlsReader.MAX_OPAQUE_SIZE]
// (1 MiB) — which dominates any peer-side practical limit
// (Marmot kind:445 events ride a Nostr relay's per-event
// ceiling, typically 64 KiB). No tighter cap needed here.
val ciphertext = reader.readOpaqueVarInt()
return PrivateMessage(
groupId = groupId,
epoch = epoch,
contentType = contentType,
authenticatedData = authenticatedData,
encryptedSenderData = encryptedSenderData,
ciphertext = ciphertext,
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -158,9 +158,18 @@ class MlsGroupManager(
fun activeGroupIds(): Set<HexKey> = groups.keys.toSet()
/**
* Check if we are a member of the given group.
* Check if we are a (live) member of the given group.
*
* After processing a commit that removes us, the group entry stays in
* [groups] so callers can still inspect the post-Remove tree, but our
* own leaf has been set to null and the leaf count may have shrunk
* past `myLeafIndex`. Either condition means we cannot encrypt any
* more messages, propose anything, or decrypt future epochs i.e.
* we are functionally not a member, and surface as such here so
* callers (`amy marmot group show`, the inbound processor) get a
* clean `not_member` answer instead of a misleading `true`.
*/
fun isMember(nostrGroupId: HexKey): Boolean = groups.containsKey(nostrGroupId)
fun isMember(nostrGroupId: HexKey): Boolean = groups[nostrGroupId]?.isLocalMember() ?: false
// --- Group Creation ---
@@ -71,6 +71,23 @@ class SecretTree(
/** Maximum consumed generation entries to track per sender before pruning. */
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
/**
* Hard cap on how many ratchet steps a single decrypt request may
* fast-forward through. Without this an attacker who can deliver a
* single PrivateMessage with a forged `generation` field can force
* the receiver into ~`generation` HKDF-Expand steps trivially
* many seconds of CPU per packet. The skipped-key cache itself
* stops at [MAX_SKIPPED_KEYS] entries, but the ratchet keeps
* advancing past that point, so the cache cap doesn't bound
* compute cost.
*
* 4096 leaves room for a realistic application/handshake gap
* (e.g. mobile catching up after a long sleep) while making the
* attack worst-case ~4096 SHA-256 invocations bounded enough
* that the receiver stays responsive.
*/
const val MAX_RATCHET_STEPS_PER_CALL = 4096
}
/**
@@ -149,6 +166,15 @@ class SecretTree(
require(generation >= state.applicationGeneration) {
"Generation $generation already consumed (current: ${state.applicationGeneration})"
}
// DoS guard: a malicious sender can put any uint32 in the
// PrivateMessage generation field, and without this cap a single
// packet would force unbounded HKDF-Expand work. See
// MAX_RATCHET_STEPS_PER_CALL.
require(generation - state.applicationGeneration <= MAX_RATCHET_STEPS_PER_CALL) {
"Application generation jump too large: " +
"${generation - state.applicationGeneration} steps (cap $MAX_RATCHET_STEPS_PER_CALL); " +
"sender $leafIndex from ${state.applicationGeneration} to $generation"
}
// Replay detection: reject if this (sender, generation) was already used
val senderConsumed = consumedGenerations.getOrPut(leafIndex) { mutableSetOf() }
@@ -219,6 +245,11 @@ class SecretTree(
require(generation >= state.handshakeGeneration) {
"Handshake generation $generation already consumed (current: ${state.handshakeGeneration})"
}
require(generation - state.handshakeGeneration <= MAX_RATCHET_STEPS_PER_CALL) {
"Handshake generation jump too large: " +
"${generation - state.handshakeGeneration} steps (cap $MAX_RATCHET_STEPS_PER_CALL); " +
"sender $leafIndex from ${state.handshakeGeneration} to $generation"
}
val senderConsumed = consumedHandshakeGenerations.getOrPut(leafIndex) { mutableSetOf() }
require(generation !in senderConsumed) {
@@ -88,34 +88,40 @@ object BinaryTree {
/**
* Parent of a node in a tree with [nodeCount] total nodes.
* Uses the left-balanced tree parent formula per RFC 9420 Appendix C.
*
* The root and any index [nodeCount] have no defined parent the
* left-balanced formula keeps walking up through virtual parents that
* never come back into range, integer-overflows after ~31 doublings,
* and either returns garbage or OOMs the call site that's storing the
* result. Both modes are signs of a tree-accounting bug upstream
* (e.g., calling directPath with a leafIndex past the post-Remove
* leafCount); fail loudly here so callers can't silently corrupt
* the rest of the commit-processing pipeline.
*/
fun parent(
nodeIndex: Int,
nodeCount: Int,
): Int {
// For left-balanced trees with non-power-of-2 leaves,
// the parent might be beyond nodeCount. In that case,
// the node's parent is the next valid ancestor.
val k = level(nodeIndex)
val b = (nodeIndex shr (k + 1)) and 1
val p =
if (b == 0) {
nodeIndex + (1 shl k)
} else {
nodeIndex - (1 shl k)
}
return if (p < nodeCount) {
p
} else {
// Parent is out of range. This node is at the rightmost
// edge of a non-full tree. Walk up through virtual parents
// until we find one within range.
parentInRange(p, nodeCount)
require(nodeCount > 0) { "nodeCount must be positive, got $nodeCount" }
require(nodeIndex in 0 until nodeCount) {
"nodeIndex $nodeIndex out of range [0, $nodeCount)"
}
// The structural root of a left-balanced tree with `leafCount`
// leaves sits at (1 << ceil(log2(leafCount))) - 1, derivable from
// nodeCount via leafCount = (nodeCount + 1) / 2.
val leafCount = (nodeCount + 1) / 2
require(nodeIndex != root(leafCount)) {
"nodeIndex $nodeIndex is the root of a $leafCount-leaf tree and has no parent"
}
return parentRec(nodeIndex, nodeCount)
}
private fun parentInRange(
/**
* Inner walker that assumes [nodeIndex] has at least one valid parent
* within [nodeCount]. Public callers go through [parent] which performs
* the bounds check above.
*/
private fun parentRec(
nodeIndex: Int,
nodeCount: Int,
): Int {
@@ -127,7 +133,7 @@ object BinaryTree {
} else {
nodeIndex - (1 shl k)
}
return if (p < nodeCount) p else parentInRange(p, nodeCount)
return if (p < nodeCount) p else parentRec(p, nodeCount)
}
/** Root node index for a tree with [leafCount] leaves */
@@ -141,11 +147,20 @@ object BinaryTree {
/**
* Direct path from a leaf to the root (excluding the leaf itself).
* These are the parent nodes along the path from leaf to root.
*
* [leafIndex] must be in `[0, leafCount)`. Calling with `leafIndex >=
* leafCount` is an upstream bug most often a stale local index that
* was valid before the tree shrank under a Remove proposal and used
* to OOM here because `parent()` would loop on the out-of-range start.
*/
fun directPath(
leafIndex: Int,
leafCount: Int,
): List<Int> {
require(leafCount > 0) { "leafCount must be positive, got $leafCount" }
require(leafIndex in 0 until leafCount) {
"leafIndex $leafIndex out of range [0, $leafCount)"
}
val nodeIdx = leafToNode(leafIndex)
val n = nodeCount(leafCount)
val rootIdx = root(leafCount)
@@ -162,11 +177,18 @@ object BinaryTree {
/**
* Copath of a leaf: the siblings of each node on the direct path.
* The copath determines which nodes need to receive encrypted path secrets.
*
* Same range contract as [directPath]: `leafIndex` must be in
* `[0, leafCount)`.
*/
fun copath(
leafIndex: Int,
leafCount: Int,
): List<Int> {
require(leafCount > 0) { "leafCount must be positive, got $leafCount" }
require(leafIndex in 0 until leafCount) {
"leafIndex $leafIndex out of range [0, $leafCount)"
}
val nodeIdx = leafToNode(leafIndex)
val n = nodeCount(leafCount)
val rootIdx = root(leafCount)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -160,4 +161,121 @@ class BinaryTreeTest {
// Subtree of leaf 0 is just [0]
assertEquals(listOf(0), BinaryTree.subtreeLeaves(0, 4))
}
// ----- Non-power-of-2 tree shapes ----------------------------------------
//
// RFC 9420 Appendix C only worked example is the 4-leaf tree, but every
// group with ≠ a power-of-2 members exercises the parentInRange branch in
// BinaryTree.parent. Those branches were entirely uncovered before, and a
// bug there (infinite recursion / infinite loop in directPath) is what
// OOM'd amy when wn removed her in marmot-interop test 14.
@Test
fun testRoot_nonPowerOfTwo() {
// root = (1 << ceil(log2(n))) - 1
assertEquals(3, BinaryTree.root(3)) // ceil(log2(3))=2, 2^2-1 = 3
assertEquals(7, BinaryTree.root(5)) // ceil(log2(5))=3, 2^3-1 = 7
assertEquals(7, BinaryTree.root(6))
assertEquals(7, BinaryTree.root(7))
assertEquals(15, BinaryTree.root(9))
}
@Test
fun testDirectPath3Leaves() {
// 3 leaves, n=5, root=3:
// 3
// / \
// 1 4
// / \ \
// 0 2 (leaf 2 sits at node 4; node 5 doesn't exist)
// Node 4 is a leaf node here because the tree is not full — its
// "parent slot" 5 would be ≥ nodeCount and is collapsed away by
// parentInRange.
assertEquals(listOf(1, 3), BinaryTree.directPath(0, 3))
assertEquals(listOf(1, 3), BinaryTree.directPath(1, 3))
assertEquals(listOf(3), BinaryTree.directPath(2, 3))
}
@Test
fun testDirectPath5Leaves() {
// 5 leaves, n=9, root=7:
// 7
// / \
// 3 8 ← leaf 4 collapsed up
// / \
// 1 5
// / \ / \
// 0 2 4 6
assertEquals(listOf(1, 3, 7), BinaryTree.directPath(0, 5))
assertEquals(listOf(1, 3, 7), BinaryTree.directPath(1, 5))
assertEquals(listOf(5, 3, 7), BinaryTree.directPath(2, 5))
assertEquals(listOf(5, 3, 7), BinaryTree.directPath(3, 5))
assertEquals(listOf(7), BinaryTree.directPath(4, 5))
}
@Test
fun testDirectPathTerminatesForAllLeafCountsUpTo32() {
// Property: every valid leaf in any tree size 1..32 has a directPath
// that ends at root(leafCount) and has length == log2-ish. We don't
// assert exact lengths — we just want to catch any future regression
// where some (leafCount, leafIndex) triggers the parent() infinite
// loop. Each call is wrapped in a generous timeout via the kotlin
// test runner (default test timeout is fine: a non-terminating call
// would OOM long before any test deadline).
for (leafCount in 1..32) {
val rootIdx = BinaryTree.root(leafCount)
for (leafIndex in 0 until leafCount) {
val dp = BinaryTree.directPath(leafIndex, leafCount)
if (leafCount == 1) {
assertTrue(dp.isEmpty(), "leafCount=1 has no path")
} else {
assertEquals(rootIdx, dp.last(), "directPath($leafIndex, $leafCount) should end at root")
// copath must align with the directPath one-for-one
assertEquals(dp.size, BinaryTree.copath(leafIndex, leafCount).size)
}
}
}
}
// ----- Out-of-range inputs -----------------------------------------------
//
// Marmot-interop test 14 fails with a Java OOM because amy calls
// `BinaryTree.directPath(myLeafIndex, tree.leafCount)` with `myLeafIndex
// == tree.leafCount` (her own leaf was just removed and the tree shrank
// past it). `parent()` then walks node indices ≥ nodeCount forever and
// the result list explodes.
//
// These tests pin down that boundary so the failure becomes a clean
// IllegalArgumentException instead of an OOM, AND so the MLS code path
// that hits it has a deterministic regression.
@Test
fun testDirectPathRejectsOutOfRangeLeafIndex() {
// leafIndex == leafCount — the exact shape the post-Remove path hits.
assertFails { BinaryTree.directPath(2, 2) }
// leafIndex > leafCount.
assertFails { BinaryTree.directPath(5, 3) }
}
@Test
fun testDirectPathRejectsNegativeLeafIndex() {
assertFails { BinaryTree.directPath(-1, 4) }
}
@Test
fun testCopathRejectsOutOfRangeLeafIndex() {
assertFails { BinaryTree.copath(2, 2) }
assertFails { BinaryTree.copath(-1, 4) }
}
@Test
fun testParentRejectsRootOrAbove() {
// The root has no parent. Asking anyway is the symptom of a tree
// accounting bug — surface it loudly instead of looping.
val n = BinaryTree.nodeCount(4)
assertFails { BinaryTree.parent(BinaryTree.root(4), n) }
// Index strictly above nodeCount also has no defined parent.
assertFails { BinaryTree.parent(n, n) }
assertFails { BinaryTree.parent(n + 5, n) }
}
}
@@ -367,6 +367,162 @@ class MarmotMipBehaviorTest {
assertNotNull(relaysTag, "MIP-02: rumor MUST carry a relays tag")
}
// ----------------------------------------------------------------------
// MIP-03 inbound authorization gates
// ----------------------------------------------------------------------
//
// The local-commit path has always run the authorization-set + admin-
// depletion guards (see `commit_adminDepletionGuardRejectsEmptyingAdminList`
// above). The inbound counterpart was missing — `processCommitInner`
// didn't call them, so a peer could send a commit our local code would
// never have produced and we'd accept it. These tests exercise the
// refactored guard functions directly with a `committerLeafIndex`
// parameter, which is the shape `processCommitInner` calls them in.
@Test
fun enforceAuthorizedProposalSet_rejectsNonAdminCommitterRemove() =
runBlocking<Unit> {
// Group with Alice as the only configured admin.
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
manager.updateGroupExtensions(
groupId,
listOf(MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId)).toExtension()),
)
// Add Bob (non-admin) so leaf index 1 is occupied.
val bobBundle = createStandaloneKeyPackage(bobId)
manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val alice = manager.getGroup(groupId)!!
// Bob (leaf 1) is NOT an admin. Pretend he committed a Remove of
// himself authored by himself — `enforceAuthorizedProposalSet`
// must reject because Remove is admin-only.
val proposals =
listOf(
com.vitorpamplona.quartz.marmot.mls.group
.PendingProposal(
proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Remove(removedLeafIndex = 0),
senderLeafIndex = 1,
),
)
val ex =
assertFailsWith<IllegalStateException> {
alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 1)
}
assertTrue(
ex.message!!.contains("non-admin members may only commit"),
"expected MIP-03 violation message, got: ${ex.message}",
)
}
@Test
fun enforceAuthorizedProposalSet_acceptsAdminCommitterFoldingAnotherMembersSelfRemove() =
runBlocking<Unit> {
// Mirrors marmot-interop test 15: Bob (admin) commits a
// SelfRemove proposal authored by Carol. Inline-as-fold flows
// tag the proposal with the committer's leaf index, so Bob's
// Remove-style fold of Carol's leaf still authenticates.
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
manager.updateGroupExtensions(
groupId,
listOf(MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId)).toExtension()),
)
val alice = manager.getGroup(groupId)!!
// Alice (leaf 0) is admin. The committer-is-admin shortcut fires
// before the per-proposal author check, so even a heterogeneous
// proposal list passes.
val proposals =
listOf(
com.vitorpamplona.quartz.marmot.mls.group
.PendingProposal(
proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.SelfRemove(),
senderLeafIndex = 99,
),
)
// Should not throw.
alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 0)
}
@Test
fun enforceNoAdminDepletion_rejectsCommitThatEmptiesAdminList() =
runBlocking<Unit> {
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
manager.updateGroupExtensions(
groupId,
listOf(MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId)).toExtension()),
)
val alice = manager.getGroup(groupId)!!
val proposals =
listOf(
com.vitorpamplona.quartz.marmot.mls.group
.PendingProposal(
proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.GroupContextExtensions(
extensions =
listOf(
MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = emptyList(),
).toExtension(),
),
),
senderLeafIndex = 0,
),
)
assertFailsWith<IllegalStateException> {
alice.enforceNoAdminDepletion(proposals)
}
}
// ----------------------------------------------------------------------
// RFC 9420 §5.2 ProposalRef hashing — local standalone proposals
// ----------------------------------------------------------------------
@Test
fun buildSelfRemoveProposalMessage_stagesPendingWithAuthenticatedContentBytes() =
runBlocking<Unit> {
// Setup: Alice creates a group where she is NOT admin (a
// throwaway bobId is the sole configured admin). Without that,
// `buildSelfRemoveProposalMessage` rejects per MIP-01.
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
manager.updateGroupExtensions(
groupId,
listOf(MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(bobId)).toExtension()),
)
val alice = manager.getGroup(groupId)!!
assertEquals(0, alice.pendingProposalsSnapshot().size)
val (_, _) = alice.buildSelfRemoveProposalMessage()
val staged = alice.pendingProposalsSnapshot()
assertEquals(1, staged.size, "buildSelfRemoveProposalMessage must also stage to pending pool")
val entry = staged.single()
assertIs<com.vitorpamplona.quartz.marmot.mls.messages.Proposal.SelfRemove>(entry.proposal)
assertEquals(alice.leafIndex, entry.senderLeafIndex)
// The captured AC bytes are what RFC 9420 §5.2's MakeProposalRef
// hashes — must be present so a peer's commit referencing this
// proposal by hash resolves against our pool.
assertNotNull(
entry.authenticatedContentBytes,
"RFC 9420 §5.2: standalone-published proposals must carry the encoded AuthenticatedContent",
)
assertTrue(entry.authenticatedContentBytes.isNotEmpty())
}
// ----------------------------------------------------------------------
// MIP-03 group event h-tag shape
// ----------------------------------------------------------------------
@@ -395,6 +551,504 @@ class MarmotMipBehaviorTest {
)
}
// ----------------------------------------------------------------------
// RFC 9420 §6.3.2 standalone PrivateMessage proposal RX
// ----------------------------------------------------------------------
/**
* Round-trip: Bob (member 1) encrypts a SelfRemove as a
* PrivateMessage PROPOSAL, Alice (admin, member 0) decrypts it.
* Alice's pending pool picks it up with the AC bytes captured for
* RFC 9420 §5.2 ProposalRef matching, so a follow-on commit
* referencing the proposal by hash resolves.
*/
@Test
fun decrypt_acceptsStandaloneSelfRemoveAsPrivateMessage() =
runBlocking<Unit> {
val (alice, bob) = build2MemberGroupWithBobJoined()
val proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.SelfRemove()
val before = alice.pendingProposalsSnapshot().size
val wireBytes = bob.encryptProposalAsPrivateMessage(proposal)
val result = alice.decrypt(wireBytes)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.ContentType.PROPOSAL,
result.contentType,
)
assertEquals(bob.leafIndex, result.senderLeafIndex)
val after = alice.pendingProposalsSnapshot()
assertEquals(before + 1, after.size, "decrypt must stage the proposal in pending pool")
val staged = after.last()
assertIs<com.vitorpamplona.quartz.marmot.mls.messages.Proposal.SelfRemove>(staged.proposal)
assertEquals(bob.leafIndex, staged.senderLeafIndex)
assertNotNull(
staged.authenticatedContentBytes,
"PrivateMessage proposal must capture AC bytes for RFC 9420 §5.2 ProposalRef matching",
)
assertTrue(staged.authenticatedContentBytes.isNotEmpty())
}
/**
* Standalone PrivateMessage proposals are restricted to SelfRemove
* (mirrors `receivePublicMessageProposal`'s policy). A peer that
* tries to pre-stage e.g. a PSK proposal must be rejected, even
* though the AEAD layer accepted the frame.
*/
@Test
fun decrypt_rejectsNonSelfRemovePrivateMessageProposal() =
runBlocking<Unit> {
val (alice, bob) = build2MemberGroupWithBobJoined()
val proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Psk(pskType = 1, pskId = ByteArray(16) { 0xAB.toByte() }, pskNonce = ByteArray(16))
val wireBytes = bob.encryptProposalAsPrivateMessage(proposal)
val ex =
assertFailsWith<IllegalArgumentException> {
alice.decrypt(wireBytes)
}
assertTrue(
ex.message!!.contains("Only SelfRemove"),
"rejection must explain the policy: ${ex.message}",
)
}
/**
* Build a two-member group via the real Welcome flow so Alice and
* Bob have INDEPENDENT [MlsGroup] instances (independent secret-tree
* consumed-generation sets) required for any test that
* encrypts on one side and decrypts on the other.
*/
private suspend fun build2MemberGroupWithBobJoined(): Pair<MlsGroup, MlsGroup> {
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
val bobBundle = createStandaloneKeyPackage(bobId)
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val alice = manager.getGroup(groupId)!!
val welcomeBytes =
requireNotNull(commitResult.welcomeBytes) { "addMember must produce a Welcome" }
val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle)
return alice to bob
}
// ----------------------------------------------------------------------
// DoS guards: forward-ratchet cap + PrivateMessage size caps
// ----------------------------------------------------------------------
/**
* A receiver that's been silent at generation 0 for one peer must
* NOT be forced to do unbounded HKDF-Expand work just because the
* peer (or an attacker) sets a multi-billion `generation` field on
* an inbound PrivateMessage. The SecretTree caps the per-call
* ratchet jump at 4096 anything past that throws before any HKDF
* runs.
*/
@Test
fun secretTree_rejectsRatchetJumpsBeyondCap() {
val st =
com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree(
encryptionSecret = ByteArray(32),
leafCount = 1,
)
// 4096 is allowed (boundary case — fast-forwards 4096 steps).
st.applicationKeyNonceForGeneration(0, 4096)
// 4097 above the previous head is rejected.
val ex =
assertFailsWith<IllegalArgumentException> {
st.applicationKeyNonceForGeneration(0, 4096 + 4097 + 1)
}
assertTrue(ex.message!!.contains("jump too large"), "must explain the cap: ${ex.message}")
}
/**
* Marmot tightens `authenticated_data` to 64 KiB even though the
* underlying TlsReader cap is 1 MiB those fields are never
* legitimately that large, and tightening turns a 1 MiB pre-
* verification allocation into 64 KiB. Hand-build a frame with a
* 65 KiB authenticated_data and verify rejection.
*/
@Test
fun privateMessage_rejectsOversizedAuthenticatedData() {
val w =
com.vitorpamplona.quartz.marmot.mls.codec
.TlsWriter()
w.putOpaqueVarInt(ByteArray(32)) // group_id
w.putUint64(0L) // epoch
w.putUint8(1) // content_type = APPLICATION
w.putOpaqueVarInt(ByteArray((1 shl 16) + 1)) // authenticated_data: 64 KiB + 1
w.putOpaqueVarInt(ByteArray(0)) // encrypted_sender_data
w.putOpaqueVarInt(ByteArray(64)) // ciphertext (small)
val ex =
assertFailsWith<IllegalArgumentException> {
com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(w.toByteArray()),
)
}
assertTrue(
ex.message!!.contains("authenticated_data too large"),
"must name the field: ${ex.message}",
)
}
// ----------------------------------------------------------------------
// RFC 9420 §7.9 parent_hash chain validation on Welcome
// ----------------------------------------------------------------------
/**
* A freshly-created single-member tree has no COMMIT-source leaves
* (the seed leaf is KEY_PACKAGE source) and no parents the
* static-tree validator must accept it as trivially valid.
*/
@Test
fun verifyTreeParentHashesForJoin_acceptsSingleMemberTree() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val tree =
com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(alice.exportTreeBytes()),
)
assertNull(MlsGroup.verifyTreeParentHashesForJoin(tree))
}
/**
* Tampering a COMMIT-source leaf's parent_hash produces a clear
* rejection from the validator. This is the exact attack a
* misconfigured GroupInfo signer (or a malicious one) could mount
* the tree_hash check would still pass, but parent_hash chain
* validation must catch it.
*/
@Test
fun verifyTreeParentHashesForJoin_rejectsTamperedLeafParentHash() =
runBlocking<Unit> {
// Build a 3-member group so we have at least one
// COMMIT-source leaf with a real parent_hash chain.
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
manager.addMember(groupId, createStandaloneKeyPackage(bobId).keyPackage.toTlsBytes())
// Trigger a self-update on Alice so her leaf becomes
// COMMIT-source with a non-empty parent_hash chain. (Using
// updateGroupExtensions as a stand-in to force a commit
// that touches the path.)
val seed = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId))
manager.updateGroupExtensions(groupId, listOf(seed.toExtension()))
val alice = manager.getGroup(groupId)!!
val originalTree =
com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(alice.exportTreeBytes()),
)
// Sanity: the original tree validates.
assertNull(MlsGroup.verifyTreeParentHashesForJoin(originalTree))
// Find the first COMMIT-source leaf and tamper its parent_hash.
var tamperedLeafIdx = -1
for (i in 0 until originalTree.leafCount) {
val leaf = originalTree.getLeaf(i) ?: continue
if (leaf.leafNodeSource ==
com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource.COMMIT
) {
val tampered = leaf.copy(parentHash = ByteArray(32) { 0x99.toByte() })
originalTree.setLeaf(i, tampered)
tamperedLeafIdx = i
break
}
}
assertTrue(tamperedLeafIdx >= 0, "test setup must produce a COMMIT-source leaf")
val reason = MlsGroup.verifyTreeParentHashesForJoin(originalTree)
assertNotNull(reason)
assertTrue(
reason.contains("leaf $tamperedLeafIdx parent_hash mismatch"),
"rejection message must name the tampered leaf: $reason",
)
}
// ----------------------------------------------------------------------
// 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
// ----------------------------------------------------------------------
/**
* Empty PSK list MUST collapse to the all-zero `default_psk_secret`
* (RFC 9420 §8.1) every epoch where no PSKs are proposed feeds zeros
* into the joiner_secret extract step.
*/
@Test
fun computePskSecret_emptyListReturnsAllZeros() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val out = alice.computePskSecret(emptyList())
assertEquals(32, out.size, "psk_secret length must be Nh = 32 for SHA-256")
assertTrue(out.all { it == 0.toByte() }, "default_psk_secret is all zeros")
}
/**
* Single external PSK case verify the derived `psk_secret` matches the
* RFC 9420 §5.3 reference computation:
*
* ```
* psk_extracted_0 = HKDF.Extract(salt = 0, ikm = psk_0)
* psk_input_0 = ExpandWithLabel(psk_extracted_0, "derived psk",
* PSKLabel(id_0, 0, 1), 32)
* psk_secret_0 = HKDF.Extract(salt = 0, ikm = psk_input_0)
* ```
*
* The previous implementation HKDF-Extracted the bare PSK value with the
* running pskSecret as salt and never built a PSKLabel its output
* would not match this expected value.
*/
@Test
fun computePskSecret_singleExternalPsk_matchesSpecDerivation() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val pskId = ByteArray(16) { (it + 1).toByte() }
val pskNonce = ByteArray(16) { (0x80 or it).toByte() }
val pskValue = ByteArray(32) { (0xA0 or (it and 0x0F)).toByte() }
alice.registerPsk(pskId, pskValue)
val proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Psk(pskType = 1, pskId = pskId, pskNonce = pskNonce)
val actual = alice.computePskSecret(listOf(proposal))
// Reference computation per §5.3 (PSKType=1, no usage/group/epoch).
val zero = ByteArray(32)
val crypto = com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
val pskExtracted = crypto.hkdfExtract(salt = zero, ikm = pskValue)
val labelWriter =
com.vitorpamplona.quartz.marmot.mls.codec
.TlsWriter()
labelWriter.putUint8(1) // PSKType external
labelWriter.putOpaqueVarInt(pskId)
labelWriter.putOpaqueVarInt(pskNonce)
labelWriter.putUint16(0) // index
labelWriter.putUint16(1) // count
val pskInput =
crypto.expandWithLabel(
secret = pskExtracted,
label = "derived psk",
context = labelWriter.toByteArray(),
length = 32,
)
val expected = crypto.hkdfExtract(salt = zero, ikm = pskInput)
assertContentEquals(expected, actual, "psk_secret must match RFC 9420 §5.3 derivation")
}
/**
* Resumption PSK (psktype = 2) carries usage/psk_group_id/psk_epoch
* fields that aren't representable on `Proposal.Psk` today. Encoding a
* PSKLabel without them would silently diverge from spec-conformant
* peers we reject loudly until the proposal type is widened.
*/
@Test
fun computePskSecret_resumptionPskRejectsUntilProposalWidened() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val pskId = ByteArray(16) { it.toByte() }
alice.registerPsk(pskId, ByteArray(32))
val proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Psk(pskType = 2, pskId = pskId, pskNonce = ByteArray(16))
assertFailsWith<IllegalStateException> {
alice.computePskSecret(listOf(proposal))
}
}
/**
* The `(index, count)` tail of PSKLabel ensures peers that resolve the
* SAME PSK in different list positions derive DIFFERENT psk_secret
* the previous implementation ignored ordering entirely.
*/
@Test
fun computePskSecret_orderingChangesOutput() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val idA = ByteArray(16) { 0x11 }
val idB = ByteArray(16) { 0x22 }
alice.registerPsk(idA, ByteArray(32) { 0x33 })
alice.registerPsk(idB, ByteArray(32) { 0x44 })
val pskA =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Psk(pskType = 1, pskId = idA, pskNonce = ByteArray(8))
val pskB =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Psk(pskType = 1, pskId = idB, pskNonce = ByteArray(8))
val ab = alice.computePskSecret(listOf(pskA, pskB))
val ba = alice.computePskSecret(listOf(pskB, pskA))
assertTrue(
!ab.contentEquals(ba),
"PSKLabel index/count means [A,B] and [B,A] must derive distinct psk_secret",
)
}
@Test
fun processGroupEvent_acceptsInnerEventWithMatchingPubkey() =
runBlocking<Unit> {