Skip to content

Security fixes: bind PublicMerkleRoot(0) circuits, pin the demo's powers of tau - #12

Merged
mjthatch merged 2 commits into
mainfrom
research/security-fixes
Oct 23, 2026
Merged

mjthatch merged 2 commits into
mainfrom
research/security-fixes

Conversation

@mjthatch

Copy link
Copy Markdown
Collaborator

The two issues the security audit (build/security-audit/) reported, each with tests. Programs for bound circuits are unchanged byte for byte.

This is the first of four stacked PRs: security fixes, generator checks and tests, deploy, CI and docs. Merge them in order, and delete each branch on merge so that GitHub retargets the next PR to main.

Summary

before after
--values 0, or no --values depth 0: no transaction binding --values 0 binds at depth 1 (PublicMerkleRoot(0)); no flag is refused
a verifier without binding the default needs an explicit --unbound
the tool's key, proof and leaves checks assert, stripped by python -O refusals that -O keeps
the demo's powers of tau used unchecked: cache, PTAU, or a curl -sSL download used only if its BLAKE2b-512 digest is the recorded one

Commits

1. Bind circuits with no public values, and make the unbound mode explicit.

  • The issue. tools/groth16_snarkjs.py used --values 0, its default, to mean "no binding". But PublicMerkleRoot(0) is a valid case of the convention: a tree that holds only the sighash, of depth 1 in public_merkle.circom and merkle.depth_of. The README maps it to --values 0. That gave a program without the sig_all_hash check, so anyone who saw a spend could reuse its witness in another transaction to the same output. The audit reproduced this: one witness was pruned and executed in three different transactions. It rated it low: it needs a circuit with no public values and a deployer who ignores the "no transaction binding" line.
  • The fix.
    • --values N (N ≥ 0) or --leaves always binds, at depth merkle.depth_of(N + 1).
    • Depth 0 needs --unbound.
    • The tool refuses:
      • neither flag, or both;
      • a negative --values;
      • a --values that disagrees with --leaves;
      • public signals or values that are not below r.
  • No more asserts. The key, proof, leaves and proof-verification checks are no longer asserts, so python -O keeps them.
  • Docs. The README and docs/design.md say what --unbound gives up: anyone can reuse the witness, and the spender chooses the public inputs.
  • Tests. tests/test_tools.py is new, in make and CI. It checks:
    • the depth for each flag combination;
    • each refusal;
    • an off-curve key under python -O;
    • a witness whose leaves do not give the public signal.

2. Check the demo's powers of tau against a pinned digest.

  • The issue. snarkjs takes α, β and the IC points of the key from the phase-1 file, and the demo's single phase-2 contribution changes only δ. A substituted file with a known trapdoor would give a key for which anyone can prove false statements. The audit showed this locally with a zero-contribution file. curl also ran without --fail, so an HTTP error body was cached and reused.
  • The fix.
    • Whatever its source (download, cache or PTAU), the file is used only if its digest matches PTAU_BLAKE2B for its size. The PTAU_BLAKE2B environment variable supplies a digest for another file.
    • The download:
      • uses curl --fail with HTTPS only, redirects included;
      • is hashed before it is kept;
      • leaves nothing behind if it fails or is refused.
    • After the contribution, snarkjs zkey verify checks the key against the circuit and the powers of tau, and prints the contribution hash.
    • The contribution's entropy is random instead of a fixed string. This is not a security fix: snarkjs mixes in its own 64 random bytes either way.
    • *.ptau, *.zkey and *.wtns are ignored outside build/ too.
  • Tests. tests/test_tools.py checks that a wrong cached file and a wrong download are refused and leave nothing behind.

Please check one value before merging. The recorded digest is that of ppot_0080_16.ptau as PTAU_URL served it on 2026-10-14:

9532c6c04a21335577713724b6d46c266a93aa621b78882b8b64b26f3080a8f0d974aded00c4d781adbdf493a45c51db455108f7aeedb49971569d57a56971c3

I could not reach the ceremony's README from here. Please compare this with the digest listed for ppot_0080_16.ptau in privacy-ethereum/perpetualpowersoftau.

Intentional behaviour changes

  • groth16_snarkjs.py program vk.json --out x, with no flag, now fails. Give --values N or --unbound.
  • The witness command of an unbound key needs --unbound too.
  • --values 0 now builds a bound program.
  • main(argv=None) takes an argument list, so tests can call it.
  • circom_demo.py setup:
    • refuses a cached or downloaded ptau whose digest is not recorded;
    • takes about a minute longer, for zkey verify.

Testing

  • Byte for byte. make example regenerates examples/ byte for byte with --values 1.
  • Tests. tests/test_tools.py passes.
  • --values 0 on the example key.
    • On main: "no transaction binding", and no jet_sig_all_hash in the program.
    • On this branch: "bound to the transaction (tree depth 1)", with the jet. That is the same program as --values 1.
  • Demo. circom_demo.py setup and prove ran end to end with the pinned file:
    • ZKey Ok!;
    • the program is bound at tree depth 1;
    • the proof verifies.

🤖 Generated with Claude Code

tools/groth16_snarkjs.py used --values 0, its default, to mean "no
binding".  But PublicMerkleRoot(0), a circuit whose only leaf is the
sighash, is a valid case of the convention (public_merkle.circom and
merkle.depth_of give it a tree of depth 1), and the README maps it to
`--values 0`.  Such a circuit got a program without the sig_all_hash
check, so anyone who saw a spend could reuse its witness in another
transaction to the same output.

Now --values N (N >= 0) or --leaves always binds, at depth
merkle.depth_of(N + 1), and a program without binding needs an explicit
--unbound.  Giving neither, or both, a negative --values, or a --values
that disagrees with --leaves is refused.  The tool's checks of the key,
the proof and the leaves are no longer asserts, so python -O keeps them,
and public signals and values must be below r.

Programs for bound circuits are unchanged byte for byte.  The README and
docs/design.md say what --unbound gives up, and tests/test_tools.py (in
make and CI) checks the depth table, the refusals, the -O case and a
witness whose leaves do not give the public signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/circom_demo.py took the phase-1 powers of tau from a cached file,
from PTAU, or from a download of a fixed S3 URL, and checked none of
them.  snarkjs takes alpha, beta and the IC points of the key from that
file, and the one phase-2 contribution changes only delta, so a
substituted file with a known trapdoor gives a key for which anyone can
prove false statements.  curl also ran without --fail, so an HTTP error
body was cached and reused.

The file is now used only if its BLAKE2b-512 digest is the one recorded
for its size (PTAU_BLAKE2B gives another).  The download uses curl
--fail with HTTPS only, is hashed before it is kept, and a refused or
failed download leaves nothing behind.  After the contribution,
`snarkjs zkey verify` checks the key against the circuit and the powers
of tau and prints the contribution hash.  The contribution's entropy is
random instead of a fixed string (snarkjs adds its own randomness either
way).  The setup and trusted-setup files are ignored by git outside
build/ too.

The recorded digest is that of ppot_0080_16.ptau as the URL served it on
2026-10-14; compare it with the list in the ceremony's README
(github.com/privacy-ethereum/perpetualpowersoftau).  tests/test_tools.py
checks that a wrong cached file and a wrong download are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mjthatch
mjthatch merged commit c646337 into main Oct 23, 2026
13 checks passed
@mjthatch
mjthatch deleted the research/security-fixes branch October 23, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants