Repository navigation
circuits: Constrain sender-encryption blinders to be nonzero and distinct - #331
Conversation
b85a78f to
4d9b509
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Cryptographic circuit constraints and verifier-data rotation warrant final human validation despite no identified defects.
0 open findings
What changed in this PR
Adds privacy-preserving constraints against degenerate sender-encryption blinders and rotates the corresponding verifier data.
Changes:
- Constrains blinders to be nonzero and pairwise distinct per output note.
- Adds regression tests for zero and equal blinders.
- Updates verifier assets and changelog entries.
| File | Description |
|---|---|
circuits/src/sender_enc.rs |
Adds blinder constraints. |
circuits/tests/sender_blinder.rs |
Tests degenerate blinders. |
circuits/tests/verifier_data.rs |
References rotated verifier data. |
circuits/CHANGELOG.md |
Records both security fixes. |
circuits/tests/assets/d2d0578d0167adadf3025e5d8cc6f41e73a68d47e9ad8aa8fcddc7ee8beaab9e.vd |
Removes superseded 1-input data. |
circuits/tests/assets/757ba7194e191bc2818b2a5fb0d32314a22ea4984b6d945168c28116d7e216e4.vd |
Removes superseded 2-input data. |
circuits/tests/assets/dca73e6a72671f16a452992fcba5ad8c26b752497a7b616a302ed7b671a2cefd.vd |
Removes superseded 3-input data. |
circuits/tests/assets/5f7106b189bb0aaba1ddeadcfdcad8650c3fac70f3d31cf20f9912ac5d196fd8.vd |
Removes superseded 4-input data. |
circuits/tests/assets/146c11bda9aa438df9ec6595e2da2b7a0254af8b85e982a31d6ad4fde4c05191.vd |
Adds updated 1-input data. |
circuits/tests/assets/f4986dd13dea61573b9b7289f4a1c6a08811b110996a5bfd5917d2b9654d9274.vd |
Adds updated 2-input data. |
circuits/tests/assets/cc93c810a196c17618881f0b80f1284891782da29354d8092a5536c29ae7f0d0.vd |
Adds updated 3-input data. |
circuits/tests/assets/85c6488e9013710d723daab7a2c356d84aa4d5b134ff39f8407c5eedb3586b1f.vd |
Adds updated 4-input data. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
moCello
left a comment
There was a problem hiding this comment.
APPROVE — The new gates are sound: each inverse witness exists only for a nonzero value, and component_mul_generator keeps each blinder canonical. The tests show that the circuit rejects zero and equal blinders on both output notes, and none of the five NOTE findings blocks the merge.
- #330 asks only for the nonzero constraint, in the 2.0 circuit revisit. This change also rejects equal blinders and lands ahead of the release that #327 prepares. The scope is a maintainer judgment call. If both rules stay under #330, extend the issue to describe the equal-blinder case that the changelog links to it.
| // The two component blinders must be nonzero and must differ. A zero | ||
| // blinder encrypts its key to `(identity, key)`, leaving the sender key in | ||
| // the clear. Equal blinders give both encryptions the same `c1`, and their | ||
| // `c2` then differ by `A - B`, a value fixed by the sender public key, so a | ||
| // passive observer can fingerprint and link the sender. The note-key gadget | ||
| // excludes the identity for the same reason. | ||
| assert_nonzero(composer, blinder.0); | ||
| assert_nonzero(composer, blinder.1); | ||
| assert_distinct(composer, blinder.0, blinder.1); |
There was a problem hiding this comment.
NOTE: The comment explains why zero and equal blinders are unsafe, but it omits the fact that these checks rely on: component_mul_generator pins each blinder to [0, r).
Context: The checks test BLS-field witnesses, but the scalar multiplications reduce the blinder modulo the JubJub subgroup order r. A witness equal to r passes assert_nonzero and still acts as a zero blinder. A witness a + r, for a small a, passes assert_distinct against a and still acts as a. component_mul_point decomposes 252 bits and accepts both, because r < 2^252. In dusk-plonk 0.24.0, component_mul_generator calls assert_canonical_jubjub_scalar, which excludes both. In jubjub-elgamal 0.6.0, encrypt_unchecked calls that gadget on the same witness to build c1.
Impact: The dependency has no test in phoenix: honest witness generation stops a noncanonical blinder with JubJubScalarMalformed before proving. The fixed-base gadget gained its canonicality constraint only in the recent dusk-plonk hardening. If a later change builds c1 another way, a prover with its own witness generator again proves (identity, key), and sender_blinder.rs still passes.
Severity-rationale: The circuit is sound as it stands. The gap is a cross-crate invariant that nothing in this crate records.
Fix: Replace the six-line comment with the rule and the fact it relies on, for example: "Blinders must be nonzero and distinct (#330). encrypt_unchecked keeps each blinder a canonical JubJub scalar, so checking the witnesses checks the scalars." #330 then carries why each case is unsafe.
| fn assert_nonzero(composer: &mut Composer, w: Witness) { | ||
| let inverse = composer[w].invert().unwrap_or(BlsScalar::zero()); | ||
| let inverse = composer.append_witness(inverse); |
There was a problem hiding this comment.
NOTE: assert_nonzero now inverts secret blinders with BlsScalar::invert, which returns early on 0 and ±1. Use invert_ct, as dusk-bls12_381 documents for secret scalars.
Context: In dusk-bls12_381 0.16.0, Scalar::invert returns early for zero, one and minus one. Its docs say: "This operation is variable time with respect to the input. Use [Scalar::invert_ct] when the scalar is secret." Before this change the helper saw only the public x of a note key, and now it also sees the blinders and their difference. The same call in assert_distinct in circuits/src/circuit_impl.rs, from #315, runs on secret note positions. There it shows whether two inputs sit next to each other in the tree.
Severity-rationale: The new call leaks nothing today: c1 already shows publicly whether a blinder, or the difference of two, is 0 or ±1. dusk-plonk also documents that proving is not constant-time. The point stands on the rule in AGENTS.md against new branches on blinding factors, and the fix costs one word.
Fix: Call invert_ct() in assert_nonzero. CtOption::unwrap_or takes the same default, and the circuit does not change. The circuit_impl.rs call predates this change. Make the position check call this helper, as the finding on assert_distinct in this file asks. Then the one fix covers both calls. Without that change, open an issue for the position check.
| /// Constrains two witnesses to differ, in two gates: it derives their | ||
| /// difference and proves it nonzero. | ||
| fn assert_distinct(composer: &mut Composer, a: Witness, b: Witness) { | ||
| let difference = composer | ||
| .gate_add(Constraint::new().left(1).a(a).right(-BlsScalar::one()).b(b)); | ||
| assert_nonzero(composer, difference); | ||
| } |
There was a problem hiding this comment.
NOTE: This assert_distinct repeats the gates of the assert_distinct that circuits/src/circuit_impl.rs already has for input positions. Keep one definition.
Context: The position check from #315 builds each pair from the same gate_add difference, the same inverse witness and the same multiplication gate, written inline. The two gadgets now share a name, a purpose and a gate sequence, but no code. A fix to one, such as a constant-time inversion, misses the other. circuit_impl.rs already shares append_subgroup_point with this module as a pub(crate) helper.
Fix: Move assert_nonzero and the pairwise assert_distinct to circuit_impl.rs as pub(crate) helpers. Import them here as append_subgroup_point is imported. Rename the position check, for example to assert_pairwise_distinct, and make it call assert_distinct for each pair. Each pair keeps its gates and witness order, so the verifier data stays as regenerated, and the verifier_data tests show it.
| // excludes the identity for the same reason. | ||
| assert_nonzero(composer, blinder.0); | ||
| assert_nonzero(composer, blinder.1); | ||
| assert_distinct(composer, blinder.0, blinder.1); |
There was a problem hiding this comment.
NOTE: Core's Sender::encrypt still accepts two equal blinders, so core builds a note that this circuit now refuses to prove.
Context: #330 states that honest transactions cannot reach the zero case, because Sender::encrypt in core/src/note.rs panics on a zero blinder. That premise does not cover the new rule. Neither Sender::encrypt nor Note::new rejects equal blinders or documents them in # Panics. Transaction::new in rusk/core/src/transfer/phoenix.rs draws the two blinders of each output independently, so production transactions do not reach it. A caller that derives both blinders from one value gets only CircuitUnsatisfied from the prover, with no sign of the cause.
Fix: Document in Sender::encrypt and Note::new that the two blinders must differ, as the circuit requires. Put the change in a separate core: commit. Rejecting equal blinders as Sender::encrypt rejects a zero blinder is the stricter option, but it adds a panic path to two public constructors. If core is to stay as it is in this change, open an issue for it instead.
| //! can fail. | ||
|
|
||
| #![cfg(feature = "plonk")] | ||
| #![allow(non_snake_case)] |
There was a problem hiding this comment.
NOTE: The non_snake_case suppression has no comment, and nothing in this test binary needs it.
Context: The file binds no uppercase name. A and B occur only in docs and in the method calls sender_pk.A() and sender_pk.B(), which the lint does not check. Every function and binding in common/mod.rs is snake case. The attribute came over from identity_keys.rs, which needs it for its bindings R, A and B. Here it only hides the warning for a later non-snake-case name.
Fix: Delete the attribute.
…inct A zero blinder encrypts its sender key to (identity, key); two equal blinders of an output note give both encryptions the same c1, and their c2 then differ by the sender-fixed A - B. Either leaks the sender to a passive observer. Constrain each blinder nonzero and the two of a note distinct, with one inverse-witness gate each, and regenerate the verifier data. #330
4d9b509 to
344687e
Compare
|
Why is this an issue? If the sender uses the official client there's no need for such a check. This is adding more complexity to the circuit. In the end, if the sender wants to leak their key, they can do it anyways. |
Constrains each output note's two sender-encryption blinders to be nonzero and to differ, closing the degenerate-blinder cases of #330:
(identity, key), publishing the key in the clear.c1; theirc2then differ by the sender-fixedA - B, letting a passive observer fingerprint and link the sender.Each case is excluded by one inverse-witness gate per blinder and per pair (+8 gates per circuit, flat across 1–4 inputs). No domain change: the verifier-data byte sizes are unchanged and
crs_extension_keeps_verifier_datastill holds, so there is no SRS-power or proving-time impact. Only the verifier-data values rotate; the four current.vdassets are regenerated against the2^17 + 9extended CRS, and their key-ids reproduce rusk-recovery's naming.The verifier data it rotates is not yet deployed (the chain runs the 0.8 keys), so this lands as a self-contained fix against the current circuit ahead of the 0.36 core release (#327), which will rebase on top and touches no
circuits/src.Verified with
--release:sender_blinder,identity_keys,verifier_data(all four, including the 3- and 4-input circuits against the extended CRS),transaction,distinct_inputs,torsion,serialization;fmtandclippyclean.Fixes #330