Skip to content

Commit c9e26d5

Browse files
authored
fix: crisp security issues [skip-line-limit] (#1821)
1 parent 9ac36a6 commit c9e26d5

124 files changed

Lines changed: 7290 additions & 1183 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ jobs:
281281
- name: Run ZK Prover Integration Tests (with network downloads)
282282
run: 'cargo test -p e3-zk-prover --features integration-tests --test integration_tests -- --nocapture'
283283

284+
# Guards the RISC Zero guest artifact. The on-chain imageId is immutable, so a guest change
285+
# that leaves ImageID.sol untouched ships a verifier that no longer matches this tree.
284286
build_e3_support_risc0:
285287
needs: [detect_changes]
286288
if: needs.detect_changes.outputs.docker_support == 'true'
@@ -658,19 +660,51 @@ jobs:
658660
crisp_unit:
659661
needs: [detect_changes, build_crisp_sdk]
660662
if: needs.detect_changes.outputs.crisp == 'true'
661-
timeout-minutes: 30
663+
# Per leg, because they are not remotely the same size. Anything that generates a ballot proves
664+
# five Noir circuits, which a two-vCPU runner takes minutes to do; everything else is seconds.
665+
timeout-minutes: ${{ matrix.timeout }}
662666
runs-on: 'ubuntu-latest'
663667
strategy:
664668
matrix:
665669
include:
666670
- test-suite: test:circuits
667671
command: pnpm test:circuits
672+
timeout: 15
668673
- test-suite: test:sdk
669674
command: pnpm test:sdk
670-
- test-suite: test:contracts
671-
command: pnpm test:contracts
675+
timeout: 45
676+
needs-sdk: true
677+
# The contract suite is split by proving cost, not by subject. Run whole it took 27m54s
678+
# against a 30m cap and started dying to ordinary runner variance, and it grows by minutes
679+
# with every ballot a new test needs. Split, the critical path is the largest single leg
680+
# rather than the sum, and the checks that prove nothing report in seconds instead of
681+
# queueing behind twenty minutes of proving.
682+
#
683+
# Balanced by ballots, which is what the time is: input-tree 7, ballots 5, unit 0.
684+
# `pnpm test:contracts` still runs all of it in one go for local use, and
685+
# `crisp_contract_leg_coverage` fails if a test file ever belongs to no leg.
686+
#
687+
# Every contract leg needs both capabilities: `hardhat.config.ts` imports
688+
# `@interfold/contracts/tasks/*`, which only resolves once `evm:build` has emitted `dist/`,
689+
# and each leg contains at least one test that imports `@crisp-e3/sdk`.
690+
- test-suite: test:contracts:input-tree
691+
command: pnpm test:contracts:input-tree
692+
timeout: 45
693+
needs-evm: true
694+
needs-sdk: true
695+
- test-suite: test:contracts:ballots
696+
command: pnpm test:contracts:ballots
697+
timeout: 45
698+
needs-evm: true
699+
needs-sdk: true
700+
- test-suite: test:contracts:unit
701+
command: pnpm test:contracts:unit
702+
timeout: 15
703+
needs-evm: true
704+
needs-sdk: true
672705
- test-suite: cargo test
673706
command: cargo test
707+
timeout: 20
674708
fail-fast: false
675709
steps:
676710
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -724,13 +758,16 @@ jobs:
724758
working-directory: .
725759
run: pnpm install --frozen-lockfile
726760

761+
# Gated on a declared capability rather than on the leg's name. Matching the name made the
762+
# label load-bearing: splitting `test:contracts` into three renamed legs silently skipped this
763+
# step, and the suite failed on a missing `@interfold/contracts/dist` two steps later.
727764
- name: Compile Interfold contracts
728-
if: matrix.test-suite == 'test:contracts'
765+
if: matrix.needs-evm
729766
working-directory: .
730767
run: pnpm evm:build
731768

732769
- name: Download Crisp SDK artifacts
733-
if: matrix.test-suite == 'test:sdk' || matrix.test-suite == 'test:contracts'
770+
if: matrix.needs-sdk
734771
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
735772
with:
736773
name: crisp-sdk-artifacts
@@ -941,6 +978,36 @@ jobs:
941978
- name: Build circuits
942979
run: pnpm build:circuits
943980

981+
# The generated verifier contracts must match the freshly built verification keys. A drift
982+
# means the deployed verifier accepts a different circuit from the one in this tree.
983+
#
984+
# `--no-compile` is load-bearing, not an optimisation. Without it the checker deletes every
985+
# nargo target directory first — its guard against comparing stale artifacts — and recompiles
986+
# only what it needs, which is the `.json` and the evm `.vk`. That silently discards the
987+
# `.vk_recursive` files `build:circuits` had just written, and the upload below then ships an
988+
# artifact `zk_prover_e2e` cannot use. Nothing here fails; the damage surfaces two jobs later.
989+
# The step above has just built these circuits, so there is nothing stale to guard against.
990+
- name: Check generated verifiers are in sync
991+
run: pnpm check:verifiers --no-compile
992+
993+
# Asserted before the upload, so a step that consumes or clears a build output fails in the
994+
# job that did it rather than in the job that needed it. Same list `zk_prover_e2e` checks.
995+
- name: Verify recursive aggregation artifacts survived
996+
run: |
997+
for artifact in \
998+
circuits/bin/recursive_aggregation/dkg_aggregator/target/dkg_aggregator.json \
999+
circuits/bin/recursive_aggregation/dkg_aggregator/target/dkg_aggregator.vk_recursive \
1000+
circuits/bin/recursive_aggregation/decryption_aggregator/target/decryption_aggregator.json \
1001+
circuits/bin/recursive_aggregation/decryption_aggregator/target/decryption_aggregator.vk_recursive
1002+
do
1003+
test -f "$artifact" || {
1004+
echo "Missing before upload: $artifact"
1005+
echo "A step between 'Build circuits' and here removed it."
1006+
exit 1
1007+
}
1008+
echo " ok $artifact"
1009+
done
1010+
9441011
- name: Upload compiled circuit artifacts
9451012
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
9461013
with:

.github/workflows/releases.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,16 +450,45 @@ jobs:
450450
uses: actions/checkout@v6
451451
with:
452452
fetch-depth: 0
453+
# This job holds `contents: write` and runs `pnpm install`, which executes lifecycle
454+
# scripts from every dependency. Persisting the token in .git/config would leave it on
455+
# disk for all of them. The one step that needs it takes it explicitly instead.
456+
persist-credentials: false
453457

454458
- name: Download all binary artifacts
455459
uses: actions/download-artifact@v4
456460
with:
457461
path: dist/
458462

463+
# Pinned to immutable SHAs: this job has `contents: write` and force-pushes `stable`, so a
464+
# mutable tag could introduce unreviewed code into the release.
465+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
466+
with:
467+
node-version: '22'
468+
469+
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
470+
471+
- name: Install dependencies
472+
run: pnpm install --frozen-lockfile
473+
474+
# A RISC Zero proof names a guest image; it does not name the source that produced it. This
475+
# record is what lets a third party tie the released source to the deployed imageId.
476+
- name: Generate compute-provider provenance manifest
477+
run: |
478+
pnpm provenance:manifest --out compute-provider-provenance.json
479+
# `complete: false` means a field could not be resolved. Publishing it would put a
480+
# partial record beside the release as though it were a verification.
481+
if ! node -e "process.exit(require('./compute-provider-provenance.json').complete ? 0 : 1)"; then
482+
echo "provenance manifest is incomplete; refusing to publish it" >&2
483+
node -e "console.error(require('./compute-provider-provenance.json').unresolved.join('\n'))"
484+
exit 1
485+
fi
486+
459487
- name: Prepare release assets
460488
run: |
461489
mkdir -p release-assets
462490
find dist/ -name "*.tar.gz" -exec cp {} release-assets/ \;
491+
cp compute-provider-provenance.json release-assets/
463492
# `interfold config check` fetches this asset from the latest release,
464493
# so a release without it leaves operators unable to detect a redeploy.
465494
if [ ! -f deployments/manifest.json ]; then
@@ -609,7 +638,15 @@ jobs:
609638

610639
- name: Update stable tag (for stable releases only)
611640
if: needs.validate-and-prepare.outputs.is_prerelease == 'false'
641+
env:
642+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
612643
run: |
613644
git tag -f stable
645+
# Through a credential helper, so the token never reaches a process argument list. Every
646+
# process on a runner shares one user, so anything left running by `pnpm install` can read
647+
# /proc/*/cmdline; it cannot read another process's environment as easily. The config
648+
# stores the helper only — the token is read from the environment when git invokes it.
649+
git config --local credential.helper '!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f'
614650
git push origin stable --force
651+
git config --local --unset credential.helper
615652
echo "✅ Updated 'stable' tag to point to v${{ needs.validate-and-prepare.outputs.version }}"

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ __pycache__/
1818

1919
# circuit benchmarks
2020
circuits/benchmarks/results_*/raw/
21+
22+
# Generated by crates/support/methods/build.rs. Holds a machine-local guest ELF path,
23+
# so it is never committed.
24+
crates/support/tests/Elf.sol

.husky/pre-push

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ pnpm check:license
66
pnpm check:committee
77
pnpm check:docs
88
pnpm check:invariants
9+
pnpm check:image-id
10+
pnpm check:verifiers

Cargo.lock

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/CRATES_ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -573,9 +573,9 @@ semantic replay domain across deferred, in-flight, and completed submissions. Re
573573
failures release their key. Successful or known-benign terminal results retain it.
574574

575575
Every node reads the proof-type policy after a fault quorum. A disabled policy produces a durable,
576-
E3-scoped `CommitteeMemberExcluded` fact instead of a transaction that must revert. This fact is
577-
not an on-chain expulsion: it changes only the current E3's collectors and aggregator selection.
578-
The canonical N-member roster remains unchanged for proof binding, rewards, and registry state.
576+
E3-scoped `CommitteeMemberExcluded` fact instead of a transaction that must revert. This fact is not
577+
an on-chain expulsion: it changes only the current E3's collectors and aggregator selection. The
578+
canonical N-member roster remains unchanged for proof binding, rewards, and registry state.
579579

580580
The gate is deliberately described as in-memory: there is no durable external-effect outbox or
581581
persisted transaction intent. A crash after snapshot advancement but before receipt classification

agent/INVARIANTS.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,85 @@ skip-proof feature containment (`pnpm check:invariants`, baselines in
356356
binds the chain, Interfold address, E3 ID, scheme ID, BFV parameter hash, committee public key,
357357
output hash, and SAFE commitment. The E3 program verifies application rules separately and cannot
358358
create a decryption duty by itself. — `flow-trace/04`; INDEX Z-15
359+
- **The compute path carries no external audit.** The 2026-08-17 Zenith audit covered six Solidity
360+
files and no Rust. `crates/compute-provider`, the RISC Zero guest, `crates/zk-helpers`, and
361+
`Risc0BfvCiphertextVerifier.sol` were outside both the audit and its mitigation review, so a
362+
`Resolved` `Z-` row is this repository's remediation rather than a re-reviewed one. Treat changes
363+
in these areas as unaudited by default. — `flow-trace/00`;
364+
`packages/interfold-contracts/audits/README.md`
365+
- **A Secure Process derives its input root; it never receives it.** `ComputeInput` holds only
366+
`fhe_inputs`, and `ComputeInput::process` derives the leaves from the ciphertexts it processed.
367+
The protocol verifier takes the input root from the proof envelope and does not constrain it, so
368+
the E3 program's comparison against its own on-chain root is the only check — and that comparison
369+
is worthless if the guest can be handed leaves that disagree with the ciphertexts it consumed.
370+
Publication is unpermissioned and one-shot, with no dispute path, so any party could otherwise
371+
publish a tally over ciphertexts that were never submitted. `MerkleTreeBuilder::with_leaf_hashes`
372+
is `#[cfg(test)]` to keep it out of that path. — `flow-trace/04`
373+
- **Every E3 program must compare the proof's input root against its own root.**
374+
`Risc0BfvCiphertextVerifier` takes no `inputRoot` argument and constrains none. A program that
375+
skips the comparison accepts a result computed over any input set. — `flow-trace/04`
376+
- **A Secure Process derives its leaves; it never receives them, and never drops one.**
377+
`MerkleTreeBuilder::compute_leaf_hashes` builds every leaf from the ciphertexts it was given and
378+
pushes one per published input, whatever the E3 program's policy decides about computing over it.
379+
Both rules are applied by `e3-compute-provider` rather than delegated: a received root can
380+
disagree with the data it claims to describe, and a missing leaf changes the root and makes the
381+
result unpublishable. — `flow-trace/04`
382+
- **The leaf layout and input selection are the E3 program's, not the crate's.** They are supplied
383+
as an `InputPolicy`, because a leaf must match whatever that program builds on chain and no two
384+
programs need agree, and because "what does a second input for the same participant mean?" has no
385+
universal answer. `InputPolicy::default` is the historical behaviour — leaf is the ciphertext's
386+
own commitment, every input counts — which matches the starter template. Every E3 program exports
387+
`policy()` beside `fhe_processor`. — `flow-trace/04`
388+
- **CRISP binds bytes, commitment, slot and parent into its leaf, and selects the end of each slot's
389+
chain.** `CRISPProgram.inputLeaf` is
390+
`sha256(sha256(bytes) || commitment || slot || parentIndexPlusOne) mod SNARK_SCALAR_FIELD` and
391+
`e3_user_program::policy` rebuilds it byte for byte; a divergence makes every root mismatch and
392+
nothing else would catch it, so both sides pin the same vector (`program/tests/input_leaf.rs`,
393+
`tests/input-leaf.test.ts`) and `onchain_root_agreement.rs` asserts Rust reproduces a root a real
394+
contract produced. The tree is append-only because the mask path checks no signature, so anyone
395+
can write to any census member's slot and update-in-place would let a third party erase a counted
396+
vote. — `flow-trace/04`
397+
- **A slot's head must be openable by anyone, so selection follows a parent chain rather than a
398+
mutable pointer.** `chain_head_per_slot` takes an entry only when its bytes reproduce its
399+
commitment _and_ the entry it names is that slot's current head. `CRISPProgram` cannot check the
400+
first — the commitment is a Poseidon sponge over CRT limbs and the circuit never sees the
401+
serialization — so with one mutable head per slot, anyone could publish a valid proof beside
402+
unusable bytes and leave a head only they can open. A slot nobody can mask is a slot where every
403+
later input is provably its owner voting again, which is a coercion receipt. Because an unusable
404+
entry is never the head, it is never a valid parent, and the next honest input names the same
405+
parent it did.
406+
407+
The rule takes the **first** usable entry to extend a parent, so a later sibling is dropped and an
408+
input can be front-run into not counting. Keep it that way: a stale parent cannot be told apart
409+
from a sibling built a moment earlier, because only the circuit knows whether an entry replaces
410+
the slot or adds to it. Preferring the later sibling would let a mask on a superseded ciphertext
411+
restore it over a vote — a silent tally corruption, against a dropped re-vote the voter can see
412+
and retry. — `flow-trace/04`
413+
414+
- **CRISP's three ballot operations prove one relation and publish one shape.** Voting, updating,
415+
and masking all prove `published = addend + ballot`, with the addend selected by the private
416+
`is_mask_vote` and derived as `keep_previous = is_mask_vote & !is_first_vote`. The circuit returns
417+
`sum_ct_commitment` on every path, the SDK has one code path, and `CrispSDK.prepareBallot` makes
418+
the same server request either way. Branching any of these apart — a different published
419+
ciphertext, a different commitment for the digest, a different request — makes the three
420+
distinguishable on chain, which is what masks exist to prevent. Deriving the selector rather than
421+
witnessing it is what stops a voter counting their old ballot twice and a masker erasing a vote. —
422+
`flow-trace/04`
423+
- **CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The
424+
witness generator reverses the message over the full degree, so the payload sits at
425+
`k1[D - MAX_MSG_NON_ZERO_COEFFS ..]` with the options back to front;
426+
`crisp_lib::utils::ballot_layout` derives that offset and both checkers use it. Coefficients
427+
inside an option segment must be binary, everything outside the ballot region must be zero, and a
428+
mask's plaintext must be zero everywhere. Indexing as if the polynomial were the message width
429+
makes both checks read only padding: every vote passes any balance bound, and a mask — which needs
430+
no signature and may be written to any eligible slot — can carry an arbitrary payload into someone
431+
else's ballot. Tests must build `k1` at the compiled degree, not at `MAX_MSG_NON_ZERO_COEFFS`. —
432+
`flow-trace/04`
433+
- **The SAFE ciphertext commitment requires exactly two components.** It covers `c[0]` and `c[1]`
434+
only, matching the Noir circuit, so `bfv_ciphertext_to_greco` rejects any other component count. A
435+
padded ciphertext would otherwise share a commitment with its two-component prefix while threshold
436+
decryption rejects it, failing the round as a `DecryptionTimeout` billed to the ciphernodes. —
437+
`flow-trace/04`
359438
- **Client PK commitment binding (C-01):** serialized PK event bytes are an untrusted transport
360439
hint; indexers store the decoded key only when its recomputed commitment equals the on-chain
361440
(C5-proven) value. Proof-backed committee publication never accepts key bytes. Public-key
@@ -438,7 +517,20 @@ skip-proof feature containment (`pnpm check:invariants`, baselines in
438517

439518
- Committee four-file sync (above) — `scripts/check-committee.sh`, pre-push + CI.
440519
- **Never hand-edit generated files:** parity matrices, `utils.ts` H/T values, verifier contracts
441-
(`generate-verifiers.ts` output), `.active-preset.json`.
520+
(`generate-verifiers.ts` output), `.active-preset.json`, `crates/support/contracts/ImageID.sol`,
521+
`crates/support/tests/Elf.sol`.
522+
- **Generated verifiers must match the built VKs**`pnpm check:verifiers`, pre-push + CI
523+
(`build_circuits`). A drift means the deployed verifier accepts a different circuit from the tree.
524+
- **`Elf.sol` is never committed.** `crates/support/methods/build.rs` writes it with a machine-local
525+
guest ELF path, so it is generated per checkout and `.gitignore`d.
526+
- **A release publishes a complete provenance manifest**`pnpm provenance:manifest`. It ties
527+
source commit, lockfile digests, pinned revisions, RISC Zero version, builder image tag **and
528+
digest** (the builder tag is mutable and `RISC0_DOCKER_CONTAINER_TAG` overrides it), guest ELF
529+
SHA-256, image ID, and the deployed verifier to one record. The generator reports
530+
`complete: false` with the unresolved fields rather than emitting a partial record that reads as
531+
verified. The ELF SHA-256 is **not** the image ID: SHA-256 checks binary integrity, the image ID
532+
is computed from the loaded memory image. Procedure:
533+
`docs/pages/verifying-the-compute-provider.mdx`.
442534
- Upgradeable-contract storage baselines are committed and CI-gated (missing baselines, compiler
443535
drift, layout incompatibility, bad gap consumption all fail); baseline creation is an explicit
444536
maintainer command. — INDEX concern #27

0 commit comments

Comments
 (0)