Merge remote-tracking branch 'origin/main' into claude/cli-event-store-database-ef2U2
This commit is contained in:
+30
-1
@@ -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 -> {
|
||||
|
||||
+46
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+847
-90
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -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 ---
|
||||
|
||||
|
||||
+31
@@ -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) {
|
||||
|
||||
+43
-21
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+654
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user