fix(marmot): hard-fail UpdatePath silent decrypt + verify parent_hash on Welcome
Two RFC 9420 hardening gaps in the inbound path: **#3 — UpdatePath silent decrypt failures.** When `processCommit` was handed a commit whose UpdatePath either (a) had no node at our common ancestor with the sender or (b) had no ciphertext encrypted to a node in our copath resolution, the path-decrypt block silently fell through and left `commit_secret = 0`. The downstream confirmation_tag check caught it and rolled back, but the rollback surfaced as a generic "ConfirmationTagMismatch" instead of pointing at the real cause — a tree-shape mismatch between our view and the sender's. Now hard-fail with a precise error naming the indices involved. **#6 — parent_hash chain verification on Welcome.** `processWelcome` was checking the GroupInfo signature and the GroupContext.tree_hash, but neither gates a forged parent_hash inside a COMMIT-source leaf — the tree_hash check only proves the ratchet_tree extension matches the bytes the signer signed, not that the stored parent_hash values are consistent with the tree shape. A peer that DOES validate parent_hash (per RFC 9420 §7.9) would reject every commit produced from such a tree, splitting the group on the next epoch. Adds `verifyTreeParentHashesForJoin(tree)` as a static-tree counterpart to `verifyParentHash` (which only handles the post-UpdatePath dynamic case). For each COMMIT-source leaf, recompute the parent_hash chain top-down on the leaf's filtered direct path and compare to the stored value. Returns null on success or a human- readable mismatch reason. Wired into `processWelcome` right after the tree_hash check, before any capability or key-schedule work. Also moves `encodeParentHashInput` into the companion object so the static and instance variants share one TLS encoder, and adds an `exportTreeBytes()` test accessor. 2 new tests: trivial single-member tree accepts; a tampered COMMIT-source parent_hash on a 3-member tree is rejected with a message naming the offending leaf. Quartz marmot tests green; marmot-interop-headless 16/16. (Two unrelated NostrClient network tests fail under the full :quartz:jvmTest run — pre-existing flake, no MLS code touched.) Closes audit gaps #3 and #6. https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
This commit is contained in:
+203
-49
@@ -153,6 +153,18 @@ class MlsGroup private constructor(
|
|||||||
*/
|
*/
|
||||||
internal fun pendingProposalsSnapshot(): List<PendingProposal> = pendingProposals.toList()
|
internal fun pendingProposalsSnapshot(): List<PendingProposal> = pendingProposals.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the current ratchet tree the same way it's serialized into
|
||||||
|
* the GroupInfo's `ratchet_tree` extension on a Welcome — a freshly-
|
||||||
|
* decoded copy is what a joiner sees, so this is the right input for
|
||||||
|
* exercising [verifyTreeParentHashesForJoin] in tests.
|
||||||
|
*/
|
||||||
|
internal fun exportTreeBytes(): ByteArray {
|
||||||
|
val w = TlsWriter()
|
||||||
|
tree.encodeTls(w)
|
||||||
|
return w.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Marmot admin helpers (MIP-01 / MIP-03) ---
|
// --- Marmot admin helpers (MIP-01 / MIP-03) ---
|
||||||
|
|
||||||
/** Raw BasicCredential identity bytes of the member at the given leaf, or null. */
|
/** Raw BasicCredential identity bytes of the member at the given leaf, or null. */
|
||||||
@@ -1441,50 +1453,65 @@ class MlsGroup private constructor(
|
|||||||
val commonAncestorNode =
|
val commonAncestorNode =
|
||||||
if (commonAncestorUnfilteredIdx >= 0) unfilteredDirectPath[commonAncestorUnfilteredIdx] else -1
|
if (commonAncestorUnfilteredIdx >= 0) unfilteredDirectPath[commonAncestorUnfilteredIdx] else -1
|
||||||
val commonAncestorFilteredIdx = filteredDp.indexOf(commonAncestorNode)
|
val commonAncestorFilteredIdx = filteredDp.indexOf(commonAncestorNode)
|
||||||
if (commonAncestorFilteredIdx >= 0 && commonAncestorFilteredIdx < updatePath.nodes.size) {
|
|
||||||
val pathNode = updatePath.nodes[commonAncestorFilteredIdx]
|
|
||||||
val copathNodeIdx = filteredCp[commonAncestorFilteredIdx]
|
|
||||||
val resolution =
|
|
||||||
tree.resolution(copathNodeIdx).filterNot { resNode ->
|
|
||||||
BinaryTree.isLeaf(resNode) &&
|
|
||||||
BinaryTree.nodeToLeaf(resNode) in newLeavesInCommit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find which encrypted secret corresponds to our position
|
// RFC 9420 §7.6: every existing member that survives the commit
|
||||||
val myNodeIdx = BinaryTree.leafToNode(myLeafIndex)
|
// MUST be able to decrypt the path_secret at the common ancestor
|
||||||
val myResIdx = resolution.indexOf(myNodeIdx)
|
// — otherwise the sender's tree shape doesn't match ours and
|
||||||
|
// we'd silently advance the epoch with `commit_secret = 0`,
|
||||||
if (myResIdx >= 0 && myResIdx < pathNode.encryptedPathSecret.size) {
|
// diverging from the sender's key schedule. The confirmation_tag
|
||||||
val ct = pathNode.encryptedPathSecret[myResIdx]
|
// check below catches that downstream and rolls back, but the
|
||||||
val pathSecret =
|
// mismatch surfaces as a generic "ConfirmationTagMismatch"
|
||||||
MlsCryptoProvider.decryptWithLabel(
|
// instead of pointing at the real cause. Hard-fail here with a
|
||||||
encryptionPrivateKey,
|
// specific error so an interop break is immediately diagnosable.
|
||||||
"UpdatePathNode",
|
check(commonAncestorFilteredIdx in 0 until updatePath.nodes.size) {
|
||||||
pathDecContextBytes,
|
"UpdatePath has no node at the common ancestor of leaves $senderLeafIndex / $myLeafIndex " +
|
||||||
ct.kemOutput,
|
"(filtered_dp_idx=$commonAncestorFilteredIdx, update_path_nodes=${updatePath.nodes.size})"
|
||||||
ct.ciphertext,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Derive remaining path secrets from common ancestor up to root,
|
|
||||||
// then one more step to reach the `commit_secret` (RFC 9420 §9.2:
|
|
||||||
// commit_secret = DeriveSecret(root_path_secret, "path")). Openmls
|
|
||||||
// advances one step past the root; quartz was stopping at the root
|
|
||||||
// and diverging — that's what caused `ConfirmationTagMismatch` on
|
|
||||||
// every cross-impl commit.
|
|
||||||
//
|
|
||||||
// Step count is measured against the UNFILTERED direct
|
|
||||||
// path — the path_secret chain advances one KDF step per
|
|
||||||
// level regardless of whether that level emits a
|
|
||||||
// UpdatePath node, so filtering changes which nodes carry
|
|
||||||
// ciphertext but not the number of KDF steps.
|
|
||||||
val stepsToRoot = unfilteredDirectPath.size - commonAncestorUnfilteredIdx - 1
|
|
||||||
var currentSecret = pathSecret
|
|
||||||
repeat(stepsToRoot) {
|
|
||||||
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
|
|
||||||
}
|
|
||||||
commitSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val pathNode = updatePath.nodes[commonAncestorFilteredIdx]
|
||||||
|
val copathNodeIdx = filteredCp[commonAncestorFilteredIdx]
|
||||||
|
val resolution =
|
||||||
|
tree.resolution(copathNodeIdx).filterNot { resNode ->
|
||||||
|
BinaryTree.isLeaf(resNode) &&
|
||||||
|
BinaryTree.nodeToLeaf(resNode) in newLeavesInCommit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which encrypted secret corresponds to our position
|
||||||
|
val myNodeIdx = BinaryTree.leafToNode(myLeafIndex)
|
||||||
|
val myResIdx = resolution.indexOf(myNodeIdx)
|
||||||
|
check(myResIdx in 0 until pathNode.encryptedPathSecret.size) {
|
||||||
|
"UpdatePath at common ancestor carries no ciphertext for us " +
|
||||||
|
"(my_leaf=$myLeafIndex, my_node=$myNodeIdx, resolution=$resolution, " +
|
||||||
|
"encrypted_path_secrets=${pathNode.encryptedPathSecret.size})"
|
||||||
|
}
|
||||||
|
|
||||||
|
val ct = pathNode.encryptedPathSecret[myResIdx]
|
||||||
|
val pathSecret =
|
||||||
|
MlsCryptoProvider.decryptWithLabel(
|
||||||
|
encryptionPrivateKey,
|
||||||
|
"UpdatePathNode",
|
||||||
|
pathDecContextBytes,
|
||||||
|
ct.kemOutput,
|
||||||
|
ct.ciphertext,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Derive remaining path secrets from common ancestor up to root,
|
||||||
|
// then one more step to reach the `commit_secret` (RFC 9420 §9.2:
|
||||||
|
// commit_secret = DeriveSecret(root_path_secret, "path")). Openmls
|
||||||
|
// advances one step past the root; quartz was stopping at the root
|
||||||
|
// and diverging — that's what caused `ConfirmationTagMismatch` on
|
||||||
|
// every cross-impl commit.
|
||||||
|
//
|
||||||
|
// Step count is measured against the UNFILTERED direct
|
||||||
|
// path — the path_secret chain advances one KDF step per
|
||||||
|
// level regardless of whether that level emits a
|
||||||
|
// UpdatePath node, so filtering changes which nodes carry
|
||||||
|
// ciphertext but not the number of KDF steps.
|
||||||
|
val stepsToRoot = unfilteredDirectPath.size - commonAncestorUnfilteredIdx - 1
|
||||||
|
var currentSecret = pathSecret
|
||||||
|
repeat(stepsToRoot) {
|
||||||
|
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
|
||||||
|
}
|
||||||
|
commitSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update transcript hashes (RFC 9420 Section 8.2)
|
// Update transcript hashes (RFC 9420 Section 8.2)
|
||||||
@@ -1861,13 +1888,7 @@ class MlsGroup private constructor(
|
|||||||
encryptionKey: ByteArray,
|
encryptionKey: ByteArray,
|
||||||
parentHash: ByteArray,
|
parentHash: ByteArray,
|
||||||
originalSiblingTreeHash: ByteArray,
|
originalSiblingTreeHash: ByteArray,
|
||||||
): ByteArray {
|
): ByteArray = Companion.encodeParentHashInput(encryptionKey, parentHash, originalSiblingTreeHash)
|
||||||
val w = TlsWriter()
|
|
||||||
w.putOpaqueVarInt(encryptionKey)
|
|
||||||
w.putOpaqueVarInt(parentHash)
|
|
||||||
w.putOpaqueVarInt(originalSiblingTreeHash)
|
|
||||||
return w.toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun capturePreUpdateSiblingHashes(senderLeafIndex: Int): Map<Int, ByteArray> {
|
private fun capturePreUpdateSiblingHashes(senderLeafIndex: Int): Map<Int, ByteArray> {
|
||||||
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
||||||
@@ -2616,6 +2637,127 @@ class MlsGroup private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9420 §7.9.2 ParentHashInput TLS encoding:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* struct {
|
||||||
|
* HPKEPublicKey encryption_key;
|
||||||
|
* opaque parent_hash<V>;
|
||||||
|
* opaque original_sibling_tree_hash<V>;
|
||||||
|
* } ParentHashInput;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
internal fun encodeParentHashInput(
|
||||||
|
encryptionKey: ByteArray,
|
||||||
|
parentHash: ByteArray,
|
||||||
|
originalSiblingTreeHash: ByteArray,
|
||||||
|
): ByteArray {
|
||||||
|
val w = TlsWriter()
|
||||||
|
w.putOpaqueVarInt(encryptionKey)
|
||||||
|
w.putOpaqueVarInt(parentHash)
|
||||||
|
w.putOpaqueVarInt(originalSiblingTreeHash)
|
||||||
|
return w.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9420 §7.9 parent_hash chain verification for a STATIC tree —
|
||||||
|
* specifically, the ratchet_tree extension a joiner reconstructs
|
||||||
|
* from a Welcome's GroupInfo. Without this, a malicious or
|
||||||
|
* misconfigured GroupInfo signer could ship a tree whose stored
|
||||||
|
* parent_hash values are inconsistent with the actual tree shape;
|
||||||
|
* peers that DO validate would reject every commit produced from
|
||||||
|
* this tree, but the joiner wouldn't notice until the next epoch
|
||||||
|
* silently rolled back.
|
||||||
|
*
|
||||||
|
* For each leaf with `source == COMMIT` (the only source that
|
||||||
|
* carries a parent_hash payload), recompute the parent_hash chain
|
||||||
|
* top-down on the leaf's filtered direct path and verify the
|
||||||
|
* leaf's stored parent_hash matches what the chain produces.
|
||||||
|
*
|
||||||
|
* Returns `null` on success, or a human-readable failure reason.
|
||||||
|
* Skips KEY_PACKAGE and UPDATE leaves — those don't carry a
|
||||||
|
* meaningful parent_hash on the wire.
|
||||||
|
*/
|
||||||
|
internal fun verifyTreeParentHashesForJoin(tree: RatchetTree): String? {
|
||||||
|
if (tree.leafCount == 0) return null
|
||||||
|
val nodeCount = BinaryTree.nodeCount(tree.leafCount)
|
||||||
|
for (leafIdx in 0 until tree.leafCount) {
|
||||||
|
val leaf = tree.getLeaf(leafIdx) ?: continue
|
||||||
|
if (leaf.leafNodeSource != LeafNodeSource.COMMIT) continue
|
||||||
|
val expected = computeStaticLeafParentHash(tree, leafIdx, nodeCount)
|
||||||
|
val stored = leaf.parentHash ?: ByteArray(0)
|
||||||
|
if (!stored.contentEquals(expected)) {
|
||||||
|
return "leaf $leafIdx parent_hash mismatch (stored=${stored.size}B, expected=${expected.size}B)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-down recomputation of the parent_hash that a COMMIT-source
|
||||||
|
* leaf at [leafIdx] should carry, given the current tree shape.
|
||||||
|
* Mirrors [computeSenderParentHashes] but uses
|
||||||
|
* [RatchetTree.treeHashNode] for sibling tree hashes (no
|
||||||
|
* pre-update / post-update distinction in static validation).
|
||||||
|
*/
|
||||||
|
private fun computeStaticLeafParentHash(
|
||||||
|
tree: RatchetTree,
|
||||||
|
leafIdx: Int,
|
||||||
|
nodeCount: Int,
|
||||||
|
): ByteArray {
|
||||||
|
val (filteredDp, _) = tree.filteredDirectPath(leafIdx)
|
||||||
|
if (filteredDp.isEmpty()) return ByteArray(0)
|
||||||
|
|
||||||
|
// Walk top-down from root, propagating the expected parent_hash.
|
||||||
|
val hashes = mutableMapOf<Int, ByteArray>()
|
||||||
|
hashes[filteredDp.last()] = ByteArray(0)
|
||||||
|
for (i in filteredDp.size - 2 downTo 0) {
|
||||||
|
val xIdx = filteredDp[i]
|
||||||
|
val parentIdx = filteredDp[i + 1]
|
||||||
|
val parentNode = tree.getNode(parentIdx)
|
||||||
|
if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
|
||||||
|
hashes[xIdx] = ByteArray(0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// x's sibling under parent — parent has children left/right;
|
||||||
|
// sibling is whichever isn't x's ancestor.
|
||||||
|
val left = BinaryTree.left(parentIdx)
|
||||||
|
val right = BinaryTree.right(parentIdx)
|
||||||
|
val siblingIdx = if (xIdx == left) right else left
|
||||||
|
val siblingTreeHash = tree.treeHashNode(siblingIdx)
|
||||||
|
hashes[xIdx] =
|
||||||
|
MlsCryptoProvider.hash(
|
||||||
|
encodeParentHashInput(
|
||||||
|
encryptionKey = parentNode.parentNode.encryptionKey,
|
||||||
|
parentHash = hashes[parentIdx] ?: ByteArray(0),
|
||||||
|
originalSiblingTreeHash = siblingTreeHash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The leaf's expected parent_hash is the chain value AT the
|
||||||
|
// immediate parent (filteredDp[0]) — same convention as the
|
||||||
|
// committer-side computation in [computeSenderParentHashes].
|
||||||
|
val immediateParentIdx = filteredDp.first()
|
||||||
|
val immediateParent = tree.getNode(immediateParentIdx)
|
||||||
|
if (immediateParent !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
|
||||||
|
return ByteArray(0)
|
||||||
|
}
|
||||||
|
// Sibling of the leaf's node at the immediate parent.
|
||||||
|
val leafNodeIdx = BinaryTree.leafToNode(leafIdx)
|
||||||
|
val left = BinaryTree.left(immediateParentIdx)
|
||||||
|
val right = BinaryTree.right(immediateParentIdx)
|
||||||
|
val leafSiblingIdx = if (leafNodeIdx == left) right else left
|
||||||
|
return MlsCryptoProvider.hash(
|
||||||
|
encodeParentHashInput(
|
||||||
|
encryptionKey = immediateParent.parentNode.encryptionKey,
|
||||||
|
parentHash = hashes[immediateParentIdx] ?: ByteArray(0),
|
||||||
|
originalSiblingTreeHash = tree.treeHashNode(leafSiblingIdx),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default MLS leaf Capabilities that advertise support for Marmot's
|
* Default MLS leaf Capabilities that advertise support for Marmot's
|
||||||
* required extensions and proposals so new members can join a group
|
* required extensions and proposals so new members can join a group
|
||||||
@@ -2805,6 +2947,18 @@ class MlsGroup private constructor(
|
|||||||
"GroupInfo tree_hash does not match ratchet_tree extension"
|
"GroupInfo tree_hash does not match ratchet_tree extension"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC 9420 §7.9 parent_hash integrity. The tree_hash check
|
||||||
|
// above only proves the wire bytes match what the GroupInfo
|
||||||
|
// signer signed — it does NOT validate that the stored
|
||||||
|
// parent_hash values are consistent with the tree shape.
|
||||||
|
// Without this, every COMMIT-source leaf could carry a
|
||||||
|
// forged parent_hash and the group would accept it; every
|
||||||
|
// commit produced from this tree would then be rejected by
|
||||||
|
// peers that DO validate, splitting on the next epoch.
|
||||||
|
verifyTreeParentHashesForJoin(tree)?.let { reason ->
|
||||||
|
throw IllegalStateException("GroupInfo ratchet_tree fails parent_hash validation: $reason")
|
||||||
|
}
|
||||||
|
|
||||||
// RFC 9420 §7.2 + §12.4.3.1: a joiner MUST refuse to join a
|
// RFC 9420 §7.2 + §12.4.3.1: a joiner MUST refuse to join a
|
||||||
// group whose `required_capabilities` lists types the joiner's
|
// group whose `required_capabilities` lists types the joiner's
|
||||||
// own KeyPackage doesn't advertise — peers that DO enforce the
|
// own KeyPackage doesn't advertise — peers that DO enforce the
|
||||||
|
|||||||
+77
@@ -551,6 +551,83 @@ class MarmotMipBehaviorTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 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
|
// RFC 9420 §7.2 / §12.4.2 required_capabilities enforcement
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user