Skip to main content

confium_crypto_vss/
pedersen_range.rs

1//! Pedersen range proof — prove that a Pedersen commitment opens to a
2//! value in [0, 2^bits) without revealing it.
3//!
4//! This is the real sigma-protocol construction the advisory process
5//! specified, replacing the gated hash-based sketch: the prover
6//! decomposes the value into bits, commits to each bit, and produces
7//! a Cramer-Damgård-Schoenmakers OR-proof per bit that the bit
8//! commitment opens to 0 or to 1. `verify` takes the value commitment
9//! `C` as its statement and checks both the per-bit OR-proofs and the
10//! aggregation `Σ 2^i·C_i == C`.
11//!
12//! Per-bit proof shape (branch k ∈ {0, 1}, statement `C_i - k·G` has
13//! a pure H-opening):
14//!
15//! ```text
16//! announcement  A_k = u_k·H
17//! challenge     e_k
18//! response      z_k = u_k + e_k·ρ_k
19//! check         z_k·H == A_k + e_k·(C_i - k·G)
20//! ```
21//!
22//! The real branch is proven honestly; the other is simulated with a
23//! random challenge, and the two challenges sum to the per-bit
24//! Fiat-Shamir challenge `H("confium-range-v1" | C | i | C_i | A_0 |
25//! A_1)`, binding the whole transcript. Unaudited crate: see the lib
26//! docs — this construction follows the textbook composition but has
27//! had no external review.
28
29use p256::elliptic_curve::Field as _;
30use p256::elliptic_curve::PrimeField;
31use p256::elliptic_curve::sec1::FromSec1Point;
32use p256::elliptic_curve::sec1::ToSec1Point;
33use p256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar};
34use sha2::{Digest, Sha256};
35
36/// Generator pair for Pedersen commitments: `C = v·G + r·H`.
37///
38/// `H` is derived by try-and-increment hashing of a fixed label, so
39/// nobody knows the discrete log of `H` with respect to `G`.
40#[derive(Debug, Clone, Copy)]
41pub struct PedersenGens {
42    /// Standard base generator.
43    pub g: AffinePoint,
44    /// Nothing-up-my-sleeve second generator.
45    pub h: AffinePoint,
46}
47
48impl PedersenGens {
49    /// The standard generators: `G` plus `H = hash_to_curve(label)`.
50    pub fn standard() -> Self {
51        Self {
52            g: ProjectivePoint::GENERATOR.to_affine(),
53            h: hash_to_curve(b"confium-pedersen-h-v1"),
54        }
55    }
56}
57
58/// A Pedersen commitment together with its opening (prover-side).
59#[derive(Debug, Clone)]
60pub struct PedersenCommitment {
61    /// The commitment point `C = v·G + r·H`.
62    pub c: AffinePoint,
63    /// The committed value.
64    pub value: Scalar,
65    /// The blinding factor.
66    pub blind: Scalar,
67}
68
69/// Commit `value` with a fresh random blinding.
70pub fn commit(gens: &PedersenGens, value: Scalar) -> PedersenCommitment {
71    let blind = random_scalar();
72    let c =
73        (ProjectivePoint::from(gens.g) * value + ProjectivePoint::from(gens.h) * blind).to_affine();
74    PedersenCommitment { c, value, blind }
75}
76
77/// A non-interactive range proof for a Pedersen commitment.
78#[derive(Debug, Clone)]
79pub struct PedersenRangeProof {
80    /// Bit width of the proven range.
81    pub bits: u32,
82    /// Per-bit commitments `C_i` (bit 0 first).
83    pub bit_commitments: Vec<AffinePoint>,
84    /// Per-bit OR-proof announcements `[A_0, A_1]` (flattened).
85    pub announcements: Vec<AffinePoint>,
86    /// Per-bit challenge halves `e_0` (the other is derived).
87    pub challenges: Vec<Scalar>,
88    /// Per-bit responses `[z_0, z_1]` (flattened).
89    pub responses: Vec<Scalar>,
90}
91
92/// Prove that `com` opens to a value in `[0, 2^bits)`.
93///
94/// Returns `None` when the value does not fit the bit width.
95pub fn prove(
96    gens: &PedersenGens,
97    com: &PedersenCommitment,
98    bits: u32,
99) -> Option<PedersenRangeProof> {
100    let bits = bits.checked_sub(1)?; // need bits >= 1
101    let bits = bits + 1;
102    if !fits(com.value, bits) {
103        return None;
104    }
105
106    // Split the blinding so that Σ 2^i · r_i == r exactly.
107    let mut r_is: Vec<Scalar> = (0..bits).map(|_| random_scalar()).collect();
108    let mut weighted: Scalar = Scalar::ZERO;
109    for (i, r) in r_is.iter().enumerate().take(bits as usize) {
110        weighted += pow2(i as u32) * *r;
111    }
112    let delta = com.blind - weighted;
113    // Fold the correction into the top bit's blinding (2^(bits-1) is
114    // invertible mod q).
115    let top = (bits - 1) as usize;
116    let inv = invert(pow2(top as u32));
117    r_is[top] += delta * inv;
118
119    let g = ProjectivePoint::from(gens.g);
120    let h = ProjectivePoint::from(gens.h);
121
122    let mut bit_commitments = Vec::with_capacity(bits as usize);
123    let mut announcements = Vec::with_capacity(bits as usize * 2);
124    let mut challenges = Vec::with_capacity(bits as usize);
125    let mut responses = Vec::with_capacity(bits as usize * 2);
126
127    for i in 0..bits {
128        let b = bit_at(com.value, i);
129        let c_i =
130            (g * (if b { Scalar::ONE } else { Scalar::ZERO }) + h * r_is[i as usize]).to_affine();
131        bit_commitments.push(c_i);
132
133        // Real branch j: announcement A_j = u·H, response z_j = u + e_j·ρ.
134        // Simulated branch k: random e_k, z_k; A_k = z_k·H − e_k·(C_i − k·G).
135        let u = random_scalar();
136        let real_announce = (h * u).to_affine();
137
138        // Simulated branch: random (e_sim, z_sim), announcement set by
139        // the verification equation A = z·H − e·(C_i − k·G).
140        let (e_sim, z_sim) = (random_scalar(), random_scalar());
141        let sim_branch: u32 = if b { 0 } else { 1 };
142        let sim_statement = if sim_branch == 0 {
143            ProjectivePoint::from(c_i)
144        } else {
145            ProjectivePoint::from(c_i) - g
146        };
147        let sim_announce = (h * z_sim - sim_statement * e_sim).to_affine();
148
149        // Branch storage order is fixed (0, 1) regardless of which is
150        // real; the Fiat-Shamir challenge must be computed over the
151        // STORED order so the verifier recomputes the same value.
152        let (a0, a1) = if !b {
153            (real_announce, sim_announce)
154        } else {
155            (sim_announce, real_announce)
156        };
157        let fs = bit_challenge(com.c, i, &c_i, &a0, &a1, bits);
158        let e_real = fs - e_sim;
159        let z_real = u + e_real * r_is[i as usize];
160
161        announcements.push(a0);
162        announcements.push(a1);
163        if !b {
164            challenges.push(e_real);
165            responses.push(z_real);
166            responses.push(z_sim);
167        } else {
168            challenges.push(e_sim);
169            responses.push(z_sim);
170            responses.push(z_real);
171        }
172    }
173
174    Some(PedersenRangeProof {
175        bits,
176        bit_commitments,
177        announcements,
178        challenges,
179        responses,
180    })
181}
182
183/// Verify a range proof against the value commitment `c`.
184///
185/// Binds the full statement: checks the aggregation
186/// `Σ 2^i·C_i == c` and every per-bit OR-proof (announcements,
187/// responses, and the Fiat-Shamir challenge sum).
188pub fn verify(gens: &PedersenGens, c: &AffinePoint, proof: &PedersenRangeProof) -> bool {
189    let n = proof.bits as usize;
190    if proof.bit_commitments.len() != n
191        || proof.announcements.len() != n * 2
192        || proof.challenges.len() != n
193        || proof.responses.len() != n * 2
194    {
195        return false;
196    }
197
198    // Aggregation: Σ 2^i · C_i == c.
199    let g = ProjectivePoint::from(gens.g);
200    let h = ProjectivePoint::from(gens.h);
201    let mut aggregate = ProjectivePoint::IDENTITY;
202    for (i, c_i) in proof.bit_commitments.iter().enumerate() {
203        aggregate += ProjectivePoint::from(*c_i) * pow2(i as u32);
204    }
205    if aggregate.to_affine() != *c {
206        return false;
207    }
208
209    // Per-bit OR-proofs.
210    for i in 0..n {
211        let c_i = proof.bit_commitments[i];
212        let a0 = proof.announcements[i * 2];
213        let a1 = proof.announcements[i * 2 + 1];
214        let e0 = proof.challenges[i];
215        let z0 = proof.responses[i * 2];
216        let z1 = proof.responses[i * 2 + 1];
217
218        let fs = bit_challenge(*c, i as u32, &c_i, &a0, &a1, proof.bits);
219        let e1 = fs - e0;
220
221        // Branch 0: z_0·H == A_0 + e_0·C_i
222        let lhs0 = h * z0;
223        let rhs0 = ProjectivePoint::from(a0) + ProjectivePoint::from(c_i) * e0;
224        if lhs0 != rhs0 {
225            return false;
226        }
227        // Branch 1: z_1·H == A_1 + e_1·(C_i − G)
228        let lhs1 = h * z1;
229        let rhs1 = ProjectivePoint::from(a1) + (ProjectivePoint::from(c_i) - g) * e1;
230        if lhs1 != rhs1 {
231            return false;
232        }
233    }
234    true
235}
236
237// ---- helpers -----------------------------------------------------------
238
239fn bit_challenge(
240    c: AffinePoint,
241    i: u32,
242    c_i: &AffinePoint,
243    a0: &AffinePoint,
244    a1: &AffinePoint,
245    bits: u32,
246) -> Scalar {
247    let mut hasher = Sha256::new();
248    hasher.update(b"confium-range-v1");
249    hasher.update(bits.to_be_bytes());
250    hasher.update(c.to_sec1_point(true).as_bytes());
251    hasher.update(i.to_be_bytes());
252    hasher.update(c_i.to_sec1_point(true).as_bytes());
253    hasher.update(a0.to_sec1_point(true).as_bytes());
254    hasher.update(a1.to_sec1_point(true).as_bytes());
255    let bytes: [u8; 32] = hasher.finalize().into();
256    // Rejection sampling with re-hash: never a constant fallback.
257    let mut bytes = bytes;
258    loop {
259        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(bytes))) {
260            return s;
261        }
262        let mut h = Sha256::new();
263        h.update(b"confium-scalar-reduce-v1");
264        h.update(bytes);
265        bytes = h.finalize().into();
266    }
267}
268
269fn fits(v: Scalar, bits: u32) -> bool {
270    // v < 2^bits ⇔ the scalar's big-endian encoding has zeros in the
271    // top (256 − bits) bits.
272    let repr = v.to_repr();
273    repr.as_slice()[..(256 - bits) as usize / 8]
274        .iter()
275        .all(|&b| b == 0)
276        && (((256 - bits) % 8) == 0
277            || repr.as_slice()[(256 - bits) as usize / 8] < (1u8 << ((256 - bits) % 8)))
278}
279
280fn bit_at(v: Scalar, i: u32) -> bool {
281    let repr = v.to_repr();
282    let byte = repr.as_slice()[31 - (i / 8) as usize];
283    (byte >> (i % 8)) & 1 == 1
284}
285
286fn pow2(i: u32) -> Scalar {
287    // 2^i via repeated squaring — always exact, no byte fiddling.
288    let two = Scalar::from(2u64);
289    let mut acc = Scalar::ONE;
290    let mut base = two;
291    let mut e = i;
292    while e > 0 {
293        if e & 1 == 1 {
294            acc *= base;
295        }
296        base = base * base;
297        e >>= 1;
298    }
299    acc
300}
301
302fn random_scalar() -> Scalar {
303    use getrandom::SysRng;
304    use p256::elliptic_curve::rand_core::UnwrapErr;
305    Scalar::random(&mut UnwrapErr(SysRng))
306}
307
308fn invert(s: Scalar) -> Scalar {
309    Option::<Scalar>::from(s.invert()).unwrap_or(Scalar::ONE)
310}
311
312/// Try-and-increment hash-to-curve for the second generator.
313fn hash_to_curve(label: &[u8]) -> AffinePoint {
314    let mut counter: u32 = 0;
315    loop {
316        let mut hasher = Sha256::new();
317        hasher.update(b"confium-hash-to-curve-v1");
318        hasher.update(label);
319        hasher.update(counter.to_be_bytes());
320        let bytes: [u8; 32] = hasher.finalize().into();
321        // Candidate x-coordinate: DER-decode via SEC1 point recovery.
322        let mut sec1 = [0u8; 33];
323        sec1[0] = 0x02 | (bytes[31] & 1);
324        sec1[1..33].copy_from_slice(&bytes);
325        let enc = p256::elliptic_curve::sec1::Sec1Point::<p256::NistP256>::from_bytes(&sec1);
326        if let Ok(enc) = enc {
327            let p_opt = Option::<AffinePoint>::from(AffinePoint::from_sec1_point(&enc));
328            if let Some(p) = p_opt {
329                // Reject the identity and the standard generator.
330                if p != AffinePoint::IDENTITY && p != ProjectivePoint::GENERATOR.to_affine() {
331                    return p;
332                }
333            }
334        }
335        counter += 1;
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn gens() -> PedersenGens {
344        PedersenGens::standard()
345    }
346
347    fn scalar(v: u64) -> Scalar {
348        Scalar::from(v)
349    }
350
351    #[test]
352    fn round_trip_zero() {
353        let gens = gens();
354        let com = commit(&gens, scalar(0));
355        let proof = prove(&gens, &com, 64).unwrap();
356        assert!(verify(&gens, &com.c, &proof));
357    }
358
359    #[test]
360    fn round_trip_max() {
361        let gens = gens();
362        let com = commit(&gens, scalar(u64::MAX));
363        let proof = prove(&gens, &com, 64).unwrap();
364        assert!(verify(&gens, &com.c, &proof));
365    }
366
367    #[test]
368    fn round_trip_mid() {
369        let gens = gens();
370        let com = commit(&gens, scalar(4242));
371        let proof = prove(&gens, &com, 64).unwrap();
372        assert!(verify(&gens, &com.c, &proof));
373    }
374
375    #[test]
376    fn round_trip_top_bit() {
377        let gens = gens();
378        let com = commit(&gens, scalar(1 << 63));
379        let proof = prove(&gens, &com, 64).unwrap();
380        assert!(verify(&gens, &com.c, &proof));
381    }
382
383    #[test]
384    fn rejects_proof_for_a_different_commitment() {
385        let gens = gens();
386        let com = commit(&gens, scalar(42));
387        let other = commit(&gens, scalar(43));
388        let proof = prove(&gens, &com, 64).unwrap();
389        // Valid proof, wrong statement.
390        assert!(!verify(&gens, &other.c, &proof));
391    }
392
393    #[test]
394    fn rejects_tampered_bit_commitment() {
395        let gens = gens();
396        let com = commit(&gens, scalar(300));
397        let mut proof = prove(&gens, &com, 64).unwrap();
398        // Flipping any bit commitment breaks the aggregation equation
399        // (and the per-bit Fiat-Shamir binding).
400        proof.bit_commitments[0] = (ProjectivePoint::from(proof.bit_commitments[0])
401            + ProjectivePoint::from(gens.g))
402        .to_affine();
403        assert!(!verify(&gens, &com.c, &proof));
404    }
405
406    #[test]
407    fn rejects_tampered_response() {
408        let gens = gens();
409        let com = commit(&gens, scalar(7));
410        let mut proof = prove(&gens, &com, 64).unwrap();
411        proof.responses[0] += Scalar::ONE;
412        assert!(!verify(&gens, &com.c, &proof));
413    }
414
415    #[test]
416    fn rejects_tampered_announcement() {
417        let gens = gens();
418        let com = commit(&gens, scalar(7));
419        let mut proof = prove(&gens, &com, 64).unwrap();
420        // The announcement is bound by Fiat-Shamir; substituting a
421        // different point must fail the challenge recomputation.
422        proof.announcements[1] = (ProjectivePoint::from(proof.announcements[1])
423            + ProjectivePoint::from(gens.h))
424        .to_affine();
425        assert!(!verify(&gens, &com.c, &proof));
426    }
427
428    #[test]
429    fn rejects_cross_bit_proof_splice() {
430        let gens = gens();
431        let com = commit(&gens, scalar(0b1011));
432        let mut proof = prove(&gens, &com, 64).unwrap();
433        // Swapping two bits' sub-transcripts: each bit's challenge
434        // binds its own commitment index, so the splice must fail.
435        proof.bit_commitments.swap(0, 1);
436        proof.announcements.swap(0, 2);
437        proof.announcements.swap(1, 3);
438        proof.challenges.swap(0, 1);
439        proof.responses.swap(0, 2);
440        proof.responses.swap(1, 3);
441        assert!(!verify(&gens, &com.c, &proof));
442    }
443
444    #[test]
445    fn rejects_out_of_range_value_at_prove_time() {
446        let gens = gens();
447        // 2^64 does not fit a 64-bit range.
448        let com = PedersenCommitment {
449            c: (ProjectivePoint::from(gens.g) * pow2(64)
450                + ProjectivePoint::from(gens.h) * scalar(5))
451            .to_affine(),
452            value: pow2(64),
453            blind: scalar(5),
454        };
455        assert!(prove(&gens, &com, 64).is_none());
456    }
457
458    #[test]
459    fn rejects_malformed_shape() {
460        let gens = gens();
461        let com = commit(&gens, scalar(1));
462        let mut proof = prove(&gens, &com, 64).unwrap();
463        proof.responses.pop();
464        assert!(!verify(&gens, &com.c, &proof));
465    }
466
467    #[test]
468    fn second_generator_is_not_the_identity_or_g() {
469        let gens = gens();
470        assert_ne!(gens.h, AffinePoint::IDENTITY);
471        assert_ne!(gens.h, gens.g);
472    }
473
474    #[test]
475    fn commitment_hides_equal_values() {
476        let gens = gens();
477        let a = commit(&gens, scalar(9));
478        let b = commit(&gens, scalar(9));
479        // Same value, different (random) blinding → different points.
480        assert_ne!(a.c, b.c);
481    }
482}