Skip to content

circuits: Constrain sender-encryption blinders to be nonzero and distinct - #331

Merged
HDauven merged 1 commit into
masterfrom
phoenix/distinct-sender-blinder
Oct 14, 2026
Merged

HDauven merged 1 commit into
masterfrom
phoenix/distinct-sender-blinder

Conversation

@HDauven

@HDauven HDauven commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Constrains each output note's two sender-encryption blinders to be nonzero and to differ, closing the degenerate-blinder cases of #330:

  • A zero blinder encrypts its sender key as (identity, key), publishing the key in the clear.
  • Two equal blinders of a note give both encryptions the same c1; their c2 then differ by the sender-fixed A - 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_data still holds, so there is no SRS-power or proving-time impact. Only the verifier-data values rotate; the four current .vd assets are regenerated against the 2^17 + 9 extended 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; fmt and clippy clean.

Fixes #330

@HDauven
HDauven force-pushed the phoenix/distinct-sender-blinder branch from b85a78f to 4d9b509 Compare October 14, 2026 08:52
@HDauven
HDauven requested a balanced review from Copilot October 14, 2026 08:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

@HDauven
HDauven marked this pull request as ready for review October 14, 2026 09:06
@HDauven
HDauven requested review from moCello and xevisalle October 14, 2026 09:06

@moCello moCello left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread circuits/src/sender_enc.rs Outdated
Comment on lines +110 to +118
// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread circuits/src/sender_enc.rs Outdated
Comment on lines +182 to +184
fn assert_nonzero(composer: &mut Composer, w: Witness) {
let inverse = composer[w].invert().unwrap_or(BlsScalar::zero());
let inverse = composer.append_witness(inverse);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread circuits/src/sender_enc.rs Outdated
Comment on lines +172 to +178
/// 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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread circuits/tests/sender_blinder.rs Outdated
//! can fail.

#![cfg(feature = "plonk")]
#![allow(non_snake_case)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@HDauven
HDauven force-pushed the phoenix/distinct-sender-blinder branch 2 times, most recently from 4d9b509 to 344687e Compare October 14, 2026 14:15
@HDauven
HDauven merged commit 7d1427b into master Oct 14, 2026
5 checks passed
@HDauven
HDauven deleted the phoenix/distinct-sender-blinder branch October 14, 2026 14:44
@xevisalle

Copy link
Copy Markdown
Member

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.

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.

Circuit does not constrain sender-encryption blinders to be nonzero

4 participants