Reading the code¶
Four modules, each matching a layer of the math. Read them in this order and the earlier pages should snap into code.
src/sigma_rangeproof/
group.py the group, the two generators, parameter checks
pedersen.py the commitment
transcript.py Fiat-Shamir hashing
rangeproof.py the OR proof and the range proof on top of it
group.py¶
This holds the arithmetic world everything lives in. The prime _P_HEX is the
2048-bit safe prime from RFC 3526, group 14, a constant that has been published
and reused for decades. From it the module derives q = (p - 1) // 2, the order
of the subgroup we work in, and picks the two generators.
The first generator is g = 4. Four is \(2^2\), so it is a square, which puts it
in the quadratic-residue subgroup; in a safe-prime group that subgroup has prime
order, so any non-identity square generates all of it. The second generator comes
from _hash_to_subgroup, which hashes a fixed label and squares the result into
the subgroup. Squaring guarantees membership; hashing guarantees nobody knows its
discrete log relative to g. That is the nothing-up-my-sleeve construction the
binding property depends on.
Params.validate is a guard against a mistyped constant. The cheap half always
runs: it confirms q = (p-1)/2 and that both generators actually sit in the
order-q subgroup, which is two exponentiations. The expensive half, a
Miller-Rabin primality test on both p and q, is behind a flag because it is
many exponentiations and only worth paying once. The test suite calls it with the
flag on; import does not. An earlier version ran it on every import and turned a
sub-second import into a 30-second one, which is a small lesson in not doing
heavy work at module load.
rand_scalar draws a blinding factor or a nonce uniformly from \([0, q)\) using
the secrets module, the standard library's cryptographic random source. Plain
random would be a real bug here; nonces and blindings have to be unpredictable.
pedersen.py¶
Two functions, both one line of math. commit(value, blinding) returns
(pow(g, value, p) * pow(h, blinding, p)) % p
which is \(g^v h^r \bmod p\), and the blinding it used, drawing one at random if
you did not supply it. open_commit recomputes the commitment from a claimed
value and blinding and checks it matches. Both value and blinding must lie in
\([0, q)\); rather than silently reducing them mod q, commit raises, so a
caller that confuses the integer threshold window with the modular exponent gets
an error instead of a commitment to a different number than it meant.
transcript.py¶
This is Fiat-Shamir in mechanical form. A Transcript wraps a SHA-256 state and
exposes append_int, append_bytes, and challenge. Every value gets absorbed
with its length and a label, so two different sequences of values can never hash
to the same state by accident. Integers are written as fixed-width big-endian
bytes for the same reason: no ambiguity at the boundaries.
The transcript is seeded at construction with a domain string and the group
parameters, so a proof made under one group cannot be replayed under another.
challenge reads a scalar in \([0, q)\) out of the current state. Because both
the prover and the verifier append the same values in the same order, they pull
identical challenges without exchanging a message. This object is the difference
between the interactive protocols on the Sigma page and the
single-shot proofs the library actually produces.
rangeproof.py¶
The top module, where the OR proof and the range proof live.
_prove_bit is the OR proof
for "this commitment holds a 0 or a 1." It computes the two targets \(Y_0 = C_i\)
and \(Y_1 = C_i / g\), simulates the branch that does not match the real bit by
choosing that branch's challenge and response first and backing out its
commitment, runs an honest Schnorr commitment on the real branch, pulls the
overall challenge from the transcript, and splits it so the two branch challenges
sum to it. The output is the six numbers a0, a1, e0, e1, z0, z1.
_verify_bit is the mirror. It recomputes the targets, pulls the same challenge,
checks that e0 + e1 equals it, and checks the Schnorr equation
\(h^{z_b} = a_b Y_b^{e_b}\) on both branches.
prove_ge(value, blinding, threshold, bits) assembles the whole thing. It
computes \(w = \text{value} - \text{threshold}\) and refuses, with a ValueError,
if \(w\) is negative or does not fit in bits bits; there is no valid proof
outside the range and the function will not fabricate one. It chooses the bit
blindings so they recombine to the original blinding, using the
inv_top = pow(pow(2, bits-1, q), -1, q) line to solve for the last one. It
commits to each bit, seeds the transcript with the statement and all the bit
commitments, and runs _prove_bit for each bit in order.
verify_ge(commitment, threshold, proof) does the two checks from the
range-proof page: it forms \(C' = C \cdot g^{-T}\), confirms
that \(\prod_i C_i^{2^i}\) reconstructs \(C'\), then verifies every bit proof
against a transcript seeded exactly as the prover seeded it. Both checks have to
pass.
RangeProof is a small dataclass with to_dict and from_dict that turn the
integers into hex strings and back, so a proof serialises straight to JSON for
sending over a wire or storing in a database.
Where to start poking¶
If you want to convince yourself it works rather than take the math on faith, the
quickest probe is in verify_ge: change the threshold you pass to verification
and watch the product identity fail, because \(C'\) moved. The
notebook
does this and a few other deliberate failures, which is usually a faster way to
trust a proof system than reading the soundness argument twice.