fix: reject truncated proofs in validateCompleteness (GHSA-r9fq-g486-v8pg)#317
Conversation
…v8pg) validateCompleteness's left-traversal loop exited on either `leafIndex == proof.Start()` or `len(nodes) == 0`. When the nodes slice was exhausted before leafIndex caught up, rightSubtrees was set to the (now empty) nodes slice, the right-side namespace check ran zero iterations, and the function silently returned nil — a completeness-gate bypass. The end-to-end VerifyLeafHashes / ComputeRootWithBasicValidation path was still protected by computeRoot, so the public API did not silently accept forged proofs. This is a defense-in-depth fix: any future caller that separates completeness checking from root verification would have been exposed. Add an explicit post-loop guard so early node exhaustion becomes an error, and cover both the empty-nodes and partially-consumed variants with unit tests. The existing right-side check is validated by a control test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request addresses a security vulnerability (GHSA-r9fq-g486-v8pg) by adding a guard in validateCompleteness to ensure the proof is not truncated before reaching the start index. It also includes new unit tests to verify this behavior. Feedback suggests wrapping the new error with the ErrFailedCompletenessCheck sentinel for consistency with existing checks and updating the corresponding tests to use require.ErrorIs for more precise error verification.
Addresses review feedback on #317. The truncated-proof failure is semantically a completeness-check failure — same category as the two existing returns in this function — so wrap the sentinel with %w and assert identity via errors.Is/require.ErrorIs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Visual explainer — how the completeness check works and what this PR fixesDiagram 1 — What a namespace proof looks likeConcrete example: 8-leaf NMT, target namespace flowchart TB
classDef target fill:#ffd6d6,stroke:#c33,stroke-width:2px,color:#000
classDef left fill:#d4e8ff,stroke:#2a5f99,color:#000
classDef right fill:#ffe6c7,stroke:#995a00,color:#000
classDef note fill:#fafafa,stroke:#888,color:#000
Q["Query: give me every leaf with namespace=05<br/>Prover: it is leaves [4, 6), here is the proof"]:::note
subgraph T["NMT leaves sorted by namespace"]
direction LR
L0["leaf 0<br/>ns=01"]
L1["leaf 1<br/>ns=01"]
L2["leaf 2<br/>ns=02"]
L3["leaf 3<br/>ns=02"]
L4["leaf 4<br/>ns=05"]:::target
L5["leaf 5<br/>ns=05"]:::target
L6["leaf 6<br/>ns=09"]
L7["leaf 7<br/>ns=09"]
end
LS["leftSibling<br/>covers [0,4)<br/>min=01, max=02<br/>check: max < 05 ✓"]:::left
RS["rightSibling<br/>covers [6,8)<br/>min=09, max=09<br/>check: min > 05 ✓"]:::right
P["proof.Start=4, proof.End=6<br/>proof.nodes = leftSibling, rightSibling"]:::note
Q --> P
P --> LS
P --> RS
LS -.-> L0
LS -.-> L3
RS -.-> L6
RS -.-> L7
How it works:
If both pass → the proof is complete (no leaves with the target namespace exist outside the proven range). Diagram 2 — The bug and the fixThe loop exits when either flowchart TB
classDef bad fill:#ffd6d6,stroke:#c33,stroke-width:2px,color:#000
classDef good fill:#d9f2d9,stroke:#2a7a2a,stroke-width:2px,color:#000
classDef step fill:#f9f9f9,stroke:#888,color:#000
Input(["Truncated proof from attacker:<br/>proof.Start = 8, proof.nodes = empty<br/>targetNID = 05"]):::step
subgraph Before["BEFORE this PR"]
direction TB
b1{"loop: leafIndex != 8<br/>AND len nodes > 0?"}:::step
b2["exits on len=0<br/>leafIndex still 0"]:::step
b3["rightSubtrees = nodes = empty"]:::step
b4["left check: 0 iterations"]:::step
b5["right check: 0 iterations"]:::step
b6(["return nil<br/>proof silently accepted"]):::bad
b1 -- "no: len=0" --> b2 --> b3 --> b4 --> b5 --> b6
end
subgraph After["AFTER this PR"]
direction TB
a1{"loop: leafIndex != 8<br/>AND len nodes > 0?"}:::step
a2["exits on len=0<br/>leafIndex still 0"]:::step
a3{"NEW GUARD:<br/>leafIndex == proof.Start?"}:::step
a4(["return ErrFailedCompletenessCheck<br/>proof nodes insufficient: reached 0, expected 8"]):::good
a1 -- "no: len=0" --> a2 --> a3
a3 -- "no: 0 is not 8" --> a4
end
Input --> b1
Input --> a1
The fix is 3 lines: after the loop, check whether |
Summary
Fixes a defense-in-depth gap in
validateCompletenessreported in GHSA-r9fq-g486-v8pg.The left-traversal loop in
validateCompletenessexits when eitherleafIndex == proof.Start()orlen(nodes) == 0. In the second case,rightSubtreesgets the (now empty) nodes slice, the right-side namespace-check loop iterates zero times, and the function silently returnsnil— even though no completeness check actually ran.Add an explicit post-loop guard that returns a wrapped
ErrFailedCompletenessCheckso early node exhaustion fails loudly and is identifiable viaerrors.Is, matching the two existing sentinel returns in the same function.Impact
Narrow. The end-to-end
VerifyLeafHashes/ComputeRootWithBasicValidationpaths were still protected becausecomputeRootrejects truncated proofs (wrong root hash). The reporter's own PoC confirmedVerifyNamespacestill returnsfalse. But any future caller that split completeness checking from root verification would have been exposed, and the inner invariant was simply wrong — hence the fix.Tests
Three unit tests in
proof_completeness_test.go, written before the fix to verify red/green:TestValidateCompleteness_TruncatedProof_RejectsEmptyNodes—start=8, emptynodes. Loop exits immediately; without the fix, returns nil.TestValidateCompleteness_TruncatedProof_RejectsInsufficientNodes—start=12, one node. Loop consumes it on the first iteration, advancesleafIndexto 8, then exits becausenodesis empty even thoughleafIndex != 12.TestValidateCompleteness_RightSideCheck_StillRejectsSmallNamespace— control:start=0with a right-subtree node whose namespace is<=the target. The existing right-side check still fires (returnsErrFailedCompletenessCheck), confirming the fix narrowly rejects truncation rather than breaking the normal path.Both truncation tests assert identity via
require.ErrorIs(err, ErrFailedCompletenessCheck).Verified red/green:
proof.gochange: two new tests fail, control passes.go test ./...suite green.Review follow-ups
fd647f5: addressedgemini-code-assistreview — wrapErrFailedCompletenessCheckwith%win the new return and userequire.ErrorIsin the tests for consistency with the other completeness-check returns in this function.Addresses GHSA-r9fq-g486-v8pg.
Test plan
go test ./...passeserrors.Is-identifiable asErrFailedCompletenessCheck🤖 Generated with Claude Code