spec: bind each member's SpendRecord.nullifiers to its committed input_nullifiers_root - #83
Conversation
…t_nullifiers_root (C_batch) Closes a fork double-spend / inflation hole of the same class as the coin-amount binding (#82), applied to the double-spend commitment itself. The nullifiers inserted into the global accumulator came from the free-witness SpendRecord.nullifiers list (§2.2 clause 3: batch_nullifiers = union of member lists), but no clause bound that list to the input_nullifiers_root (inr) each per-account proof committed. clause 1 binds only the root inr to ProofData (root-to-root) and never opens it to the individual nf, and clause 9 keeps the nf private to C. A spender could prove an honest spend (inr = NullifiersRoot([nf_C]), signed message = inr ‖ ocr) yet hand the publisher a SpendRecord with nullifiers = [], k = 0 — the AggregateBatchProof still verifies, nf_C is never inserted, and the coin is re-spent in a fork. Fix: §2.2 clause 3 now requires, for each member, NullifiersRoot(SpendRecordⱼ.nullifiers) == ProofDataⱼ.input_nullifiers_root and k == |nullifiers| (the §1.7.5 NullifiersRoot construction). C_batch cannot re-derive the nf from nk (private to C), but it can open the committed root to the declared list — both are available — so batch_nullifiers is provably the opening of each member's inr and no nf can be dropped or substituted. NullifiersRoot([]) = L_⊥ ≠ inr makes the nullifiers=[] proof unsatisfiable; the empty-list case (mint/receive, inr = L_⊥, k = 0) passes trivially. Also anchors the check at the operative insertion site (§2.5 leaf mode, clauses 1–3) and hardens the §2.1 clause 4 cross-reference and the §2.4 / §3.6 step 7-8 prose. No test vectors affected.
|
Ready for review. Found by an adversarial soundness sweep (six attack lenses over the current spec — the follow-up to the #81/#82 fix, applying the same 'is every value-bearing field bound to a verifier-checked commitment?' question to the rest of the protocol). Three lenses converged on this one independently; the verify pass confirmed no in-circuit or receive-side check catches it. Two review passes (soundness + consistency in parallel each round). Pass 1 confirmed the core is sound but found the §2.5 leaf-mode enumeration still listed clauses 1–2 — the operative insertion site where the new opening check must live — plus two minors (an Wording/normative only, no test vectors affected. |
Why (soundness — fork double-spend / inflation)
Same class as the coin-amount binding (#81/#82), applied to the anti-double-spend commitment itself. The
nfvalues inserted into the global nullifier accumulator come from the free-witnessSpendRecord.nullifierslist — §2.2 clause 3 setsbatch_nullifiers= the multiset union of member lists — but no clause bound that list to theinput_nullifiers_root(inr) each per-account proof committed. Clause 1 binds only the rootinrtoProofData(root-to-root) and never opens it to the individualnf; clause 9 keeps thenfprivate toC.The attack
C(amount 100), true nullifiernf_C.C→ pay Bob 100;inr = NullifiersRoot([nf_C]); signmessage = inr ‖ ocr, S2C overH(ProofData). Verifies.SpendRecordhanded to the publisher, setnullifiers = [],k = 0, leaving the signedmessageuntouched.C_batchverifies (clause 3 union =[], clause 4 empty insert), batch admitted —nf_Cnever inserted. Bob credits 100 (clause 10(d) never inspects the list).C:nf_Cis still absent from the live accumulator → passes → pay Carol 100.C(100) spent once, 200 delivered. Unbounded by repetition.Found by an adversarial soundness sweep (three attack lenses converged on it independently; the verify pass confirmed no in-circuit or receive-side check catches it).
Fix
§2.2 clause 3 now requires, for each member:
using the §1.7.5
NullifiersRootconstruction (tag,L_⊥padding).C_batchcannot re-derive thenffromnk(private toC), but it can open the committed root to the declared list — bothinr(inProofData) and the list (in theSpendRecord) are available, nonkneeded. SinceCalready guaranteesinr = NullifiersRoot(correctly-derivednf),NullifiersRoot(list) == inrproves the inserted list is the opening of each member's committed root — nonfcan be dropped or substituted.NullifiersRoot([]) = L_⊥ ≠ inrmakes thenullifiers=[]proof unsatisfiable; the empty-list case (mint/receive,inr = L_⊥,k = 0) passes trivially.The binding is also anchored at the operative insertion site (§2.5 leaf mode, now clauses 1–3), and the §2.1 clause 4 cross-reference and the §2.4 / §3.6 step 7-8 prose are hardened to state the opening rather than merely assert "correctly derived".
Scope
6 line-level edits in
specification.md; no test vectors affected (pure normative addition over the existinginrdefinition andkfield). Reviewed adversarially for residual vectors (inr forgery, 2nd-preimage,kconsistency, padding, mint case, interaction with #82) — all closed.