Fix check_deployment disagreement due to non-deterministic signer - #3346
Fix check_deployment disagreement due to non-deterministic signer#3346Antonio95 wants to merge 19 commits into
check_deployment disagreement due to non-deterministic signer#3346Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses a consensus-risky nondeterminism in deployment checking by making the “dummy” signer used during CheckDeployment circuit synthesis deterministic (derived from a seeded RNG), preventing validator disagreements when call.dynamic targets depend on self.signer.
Changes:
- Make the burner private key (and thus
self.signer) deterministic during deployment verification by sampling it from the deployment-ID-seeded RNG. - Update
resolve_dynamic_targetdocumentation to reflect current behavior inSynthesize/CheckDeploymentmodes. - Add a V19 regression test covering dynamic target resolution involving closures, and update several related comments for clarity.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| synthesizer/process/src/stack/deploy.rs | Samples burner private key from a deployment-ID-seeded RNG to make CheckDeployment deterministic. |
| synthesizer/process/src/stack/call/dynamic.rs | Updates documentation for resolve_dynamic_target return behavior (esp. closure cases). |
| synthesizer/src/vm/tests/test_v19/closure_dynamic_targets.rs | Adds regression test for deterministic verify_deployment behavior with closure-vs-function dynamic targets. |
| synthesizer/src/vm/tests/test_v19/mod.rs | Registers the new V19 test module. |
| synthesizer/src/vm/tests/test_v18/mod.rs | Comment wording/line-wrapping cleanup. |
| synthesizer/process/src/stack/helpers/synthesize.rs | Comment clarifying is_root = true semantics during synthesis. |
| synthesizer/process/benches/check_deployment.rs | Comment clarifying is_root = true semantics in benchmark setup. |
| synthesizer/process/src/tests/test_credits.rs | Comment clarifying is_root is set (not sampled). |
| circuit/program/src/request/verify.rs | Comment clarifying is_root = true semantics in circuit-side request verification tests. |
| // Deploys a program whose function points the target of a call.dynamic instruction to a closure or a | ||
| // function depending on the last bit of self.signer. The test checks several runs of test- |
There was a problem hiding this comment.
the code comment trails off, so some edit here seems desirable
| @@ -160,7 +160,7 @@ impl<N: Network> Stack<N> { | |||
| deployment.program().functions().values().zip_eq(deployment.function_verifying_keys()) | |||
| { | |||
| // Initialize a burner private key. | |||
| let burner_private_key = PrivateKey::new(rng)?; | |||
| let burner_private_key = PrivateKey::new(&mut seeded_rng)?; | |||
There was a problem hiding this comment.
imo this is a pretty decent suggestion -- we used an approach like this in another project with similar constraints around determinism where we still wanted to be able to make changes easily
There was a problem hiding this comment.
@cbeck88 What do you think about the following? At the two (relevant) places where we just replaced rng by seeded_rng (signer sampling and get.record.dynamic resolution), we pass a cloned copy of seeded_rng instead?
There was a problem hiding this comment.
i think instead of cloning seeded_rng, could call SeedableRng::from_seed(seeded_rng.next_u64())? or something like this
only because, using an rng to seed another rng still results in pseudo-independent draws, but cloning the rng doesn't do that, so it could conceivably cause an obscure problem. it just seems a little more defensive
I'd like to keep the I recall at some point there was a similar false-negative (incorrectly rejecting) case for standard deployments as well. Does Fix 2 address that as well? |
@raychu86 I'm not sure which false negatives you have in mind. We've fixed a couple of unrelated valid-program rejections lately (e.g. programs which cast |
|
@Antonio95 I may be misremembering, but a while back we had a similar non-deterministic outcome for deployment verification. However I don't recall if it was consistent between validators. I just checked, and it should be fine with the deterministic RNG, so non-issue. |
@raychu86 the seriousness of the problem is that it can cause a chain split, as mentioned by antonio:
The attacker need not have control of a validator node in quorum to cause this chain split, which is why it's a serious vulnerability This vulnerability was found by a security researcher and we paid it out |
Builds on the burner-key fix (seeding `self.signer` for `CheckDeployment`) by closing the remaining ambient-RNG sources reachable during deployment verification, so that verification is a pure function of the deployment. Most importantly, `get.record.dynamic` sampled its dummy entry value from `rand::rng()` on the not-present branch, which is reached during `CheckDeployment` synthesis (a sampled dynamic record carries no data). That value is stored to a register a program can feed into a `call.dynamic` target (closure vs. non-closure), so the same deployment could verify on some validators and abort on others — an independent fork vector not closed by seeding the burner key, since the exploiting program never reads `self.signer`. It is now drawn from a ChaChaRng seeded by the record root and entry identifier, both deterministic across validators verifying the same deployment. Additionally, in `verify_deployment`, the `Request::sign` nonce and the record-translation sampling are now drawn from the deployment-ID-seeded RNG rather than the ambient `rng`. These are defense-in-depth: once the burner key is seeded neither currently changes a verification outcome, but this removes any dependence of verification on the ambient RNG. The `rng` argument is now unused and renamed `_rng` (the signature is retained for API compatibility). Out of scope: the Synthesize-mode (key-generation) burner in `synthesize.rs` runs during `deploy()`/proving, never during validator verification, so it is not a fork source and is left unchanged. Adds `test_v19/get_record_dynamic_targets.rs`, mirroring `closure_dynamic_targets` but driving the `call.dynamic` target from a `get.record.dynamic` read; it fails without the fix and asserts repeated verifications agree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
raychu86
left a comment
There was a problem hiding this comment.
Code looks straightforward. Some merge conflicts
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Antonio Mejías Gil <anmegi.95@gmail.com>
…rification-hardening Harden deployment verification: seed remaining ambient RNG sources (follow-on to #3346)
| { | ||
| // Initialize a burner private key. | ||
| let burner_private_key = PrivateKey::new(rng)?; | ||
| let burner_private_key = PrivateKey::new(&mut seeded_rng)?; |
There was a problem hiding this comment.
Medium: Ungated consensus verification change
A deployer can submit a pre-V19 program whose call.dynamic target depends on self.signer or a sampled dynamic-record value. Using the deployment-seeded witness for all consensus versions can make an upgraded validator reject a deployment previously accepted with the legacy ambient-RNG witness, causing disagreement during historical replay. Gate the deterministic behavior on its activation consensus version and retain the legacy path for earlier versions; the deterministic sampling added in get_record_dynamic.rs needs the same gate.
There was a problem hiding this comment.
From the PR description: "This does not need to be version guarded, since in principle it is switching from "machine-dependent random, sometimes correct, sometimes not" to "deterministic, correct"."
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
PR overviewThis pull request makes signer and dynamic-record sampling deterministic during deployment checks to prevent inconsistent One security issue remains open: the deterministic verification behavior is not gated by consensus version, so a crafted pre-V19 deployment can be interpreted differently by upgraded validators during historical replay. This can cause consensus disagreement until the legacy behavior is retained for versions before activation. Open issues (1)
Fixed/addressed: 0 · PR risk: 7/10 |
This PR fixes a problematic interaction between an issue in
resolve_dynamic_targetand non-deterministic sampling of asignerin someCallStackmodes.Issue
The function
resolve_dynamic_target, called insynthesizer/process/src/stack/call/dynamic.rs::{execute, evaluate}, returns aResult<Option<ResolvedTarget>>. In the twoCallStackmodes it refers to as "dummy", i.e.SynthesizeandCheckDeployment, it returnsOk<None>in some cases andErrin others (specifically,Erris returned when the target can be resolved but it points to a closure). The correct thing to do would be to returnOk<None>in all cases for those two modes, since in those no dynamic calls are actually followed through and executed (one is synthesising a single function's circuit, and closures cannot be called viacall.dynamic). This is indeed the wayresolve_dynamic_targetwas documented - but not implemented. The documentation has been updated to match actual behaviour.As can be seen in
execute, the target returned byresolve_dynamic_targetis never used in the dummy modes. However,executealways applies?to theResult<<Option<ResolvedTarget>>>, which meansexecutefails (in a controlled manner) when the register values as they exist during synthesis happen to cause the target to be an existing closure. In particular,verify_deploymentrejects it.So far this would mainly be a usability issue, where technically valid programs could not be deployed. However, one element is not sampled deterministically when starting circuit
SynthesizeandCheckDeploymentmodes: the signer, which is sampled from the OS'srnginstead of the deterministicseeded_rngused for function inputs, etc. This makes easy to construct simple programs wherevm.deployandvm.check_deploymentsucceed or fail randomly depending on the OS's unpredictablerng, leading to a disagreement between validators: simply have the target of acall.dynamicinstruction depend on the signer (for instance, it's last bit), being an existing closure in some cases and a non-existent target (or a function) in others. Cf. the test https://github.com/ProvableHQ/snarkVM/blob/fix/dynamic_target_resolution_closure/synthesizer/src/vm/tests/test_v19/closure_dynamic_targets.rs#L21.Fix
Two options present themselves:
seeded_rnginstead ofrng. This means if one validator rejects the deployment, all do. This does not need to be version guarded, since in principle it is switching from "machine-dependent random, sometimes correct, sometimes not" to "deterministic, correct".resolve_dynamic_targetto always returnOk<None>inSynthesizeandCheckDeploymentmodes, as mentioned above. This was done in this PR (ccfe407) but has since been reverted.Point 2 would more robust and future-proof, as it also guards against other sources of randomness we may have missed (none have been found after an AI-aided search) or which may be introduced in the future. However, the call to
resolve_dynamic_targetlives in an internal part of the stack we do not want to be consensus-version-dependent. For that reason, we implement point 1 only (which is a positive change in itself). As a result, pathological Aleo programs such as the one proposed may either be rejected by all validators, or accepted by all, i.e. the risk of forking is avoided. The fact that such a program (which technically has valid Aleo instructions) could be rejected at random is a minor usability issue given its pathological semantics (a dynamic call can never execute pointing to a closure, so a program definition which allows that in some register state is, at best, questionable).A way to implement point 2 and circumvent the
ConsensusVersionthreading issue would be to add a boolean flag toCallStack::SynthesizeandCallStack::CheckDepoymentto mark which version ofresolve_dynamic_targetto use (fixed one in the since reverted ccfe407). @raychu86 let me know if you prefer this option (to the current state, i.e. not implementing point 2).Tests
One small test has been added which tests the narrow type of problematic programs:
test_v19::test_conditional_dynamic_call_target_deployment. Details therein.