This is a research proof-of-concept, not a production library. This document states precisely what side-channel hardening is and is not in place, so no reader mistakes the mitigations for a constant-time guarantee. No em-dashes; references as Title: Link.
- The library is NOT constant-time, and does not claim to be. It is built on
rug(GMP), whose big-integer and binary-quadratic-form arithmetic is not constant-time: multiplication, reduction, inversion, and primality testing all have data-dependent timing. A blanket constant-time claim would be false, so we do not make one. - What is hardened: the secret-exponent exponentiations, the classic side-channel target. On the RSA path these use GMP's side-channel-resistant modular exponentiation (
mpz_powm_secviarug::Integer::secure_pow_mod), which keeps the same cache-access pattern for same-sized arguments. On the class-group path they use a fixed-width Montgomery ladder (classgroup::pow_ct) that performs the same sequence of group operations regardless of the exponent bits, removing the square-and-multiply Hamming-weight leak and (via the fixed width) the exponent-magnitude leak. - This reduces specific, well-known leaks; it does not defend against a resourced side-channel adversary. Such an adversary is out of scope, consistent with the threat model in
THREAT_MODEL.md.
The secret values worth protecting are the per-puzzle randomness (knowing it lets one strip the time-lock mask early and read the message before the deadline), the RSA trapdoor phi (the demo retains it, but in a non-trapdoor deployment the analogous secret-exponent operations still occur), and the zero-knowledge proof's masking randomness k (leaking it can leak the witness).
- RSA secret-exponent modexps use
secure_pow_mod. Applied insetup(h = g^e,ederived fromphi),gen_with_randomness(u = g^randh^(rN),rthe secret randomness),solve_with_trapdoor(u^e), andprove_bit(the real-branch commitmentsg^k,H^k). The RSA modulus andN^2are odd, assecure_pow_modrequires, and the exponents are positive. Note: the proof maskkis sampled UNIFORMLY and its top bit is deliberately NOT forced. Forcing it would push the real-branch responsez = k + c*rinto the top half of its range while the simulated branch's response is uniform, which would reveal which branch is real and hence leak the ballot. Sok's exact bit-length can leak throughsecure_pow_mod's size dependence, butk's size is anyway approximately public (the responsez, which isk + c*r, is part of the proof), so this is not a meaningful additional leak. - Class-group secret-exponent powers use a Montgomery ladder.
classgroup::cl_lockandcg_lockexponentiate the secret randomnessr(and the secret messagesincl_lock) withpow_ctover a fixed bit-width (rhas its top bit forced so the width is constant;rcarries no zero-knowledge constraint here, so forcing its width is safe, unlike the proof mask above). Each ladder step performs exactly one composition and one squaring in the same order, so the operation count and sequence do not depend on the exponent. The public-exponent path keeps the fasterpow(square-and-multiply), and its doc comment states that it leaks the exponent's Hamming weight and is for public exponents only.
The negative space is the honest part.
- The underlying arithmetic is not constant-time.
secure_pow_modandpow_ctonly control the exponent-dependent structure of the computation. The individual multiplications, the binary-quadratic-form composition, squaring, and reduction, and the modular inversions are GMP-backed and have data-dependent timing. A cache or timing attack on those primitives is not defended against. - Modulus and discriminant generation are not constant-time. Safe-prime and prime generation, primality testing, and rejection sampling have timing that depends on the values sampled. A side-channel during key generation is out of scope.
- The slow solve is intentionally timed and uses a public exponent.
PSolveperformsTsequential squarings on2^T, a public exponent; its running time is the whole point of the puzzle and leaks nothing secret. - The solve-time discrete log leaks the recovered value, which is then revealed anyway. The class-group easy-DL and the RSA extraction operate on the value being opened; any timing dependence on it is moot because the solve outputs it.
- No memory zeroization of secrets. GMP manages its own allocations and may reallocate, so reliably scrubbing secret limbs (
r,phi,k) from memory is not achievable through the high-level API, and we do not pretend to. Secrets may persist in memory after use. - The ballot-validity PROVER is not constant-time. The OR-proof runs one real and one simulated sigma-protocol branch, and these are inherently different computations. The simulated branch inverts
v_{1-s}(one of the two branch targets, selected by the secret bits) and the proof components are stored at array indices that depend ons. The expensive modular exponentiations run in a fixed order (simulate, then real) independent ofs, so the modexp timing does not leak the bit, but the branch-target inversion and the storage order are residual, secret-dependent operations. A fine-grained timing or cache adversary measuring the prover could learn the bit. The verifier is fully public. Making the prover constant-time (oblivious branch selection) is out of scope for this PoC. - No protection against fault attacks, power analysis, or a malicious host.
- The RSA demo mode is insecure by construction. The generator keeps the factorization (the trapdoor), so it can open any puzzle instantly regardless of side channels; hardening the RSA path is defense-in-depth for a hypothetical trapdoor-free deployment, not a fix for the demo's intentional insecurity.
This is not a timing issue but belongs in the honest accounting. Both homomorphic paths now have a per-ballot validity proof for the 0/1 (vote) case:
- RSA path:
gen_ballot/verify_bit. - CL class-group path:
cl_gen_ballot/cl_verify_bit. This is the CL analog (a CDS OR of two cross-base Chaum-Pedersen statements proving the puzzle is a well-formed CL ciphertext encrypting a bit). An aggregator verifies each proof and excludes any ballot that fails, so a malicious voter cannot corrupt the tally. Theclvotedemo shows a forged "encode 7" ballot being rejected.
The CL proof rests on more than the RSA one: responses are integers in a group of unknown order (knowledge soundness under a strong-root-style assumption), and class groups can contain low-order elements that would break soundness. cl_setup therefore puts the generator in the odd-order squares subgroup (no 2-torsion, by genus theory), and the residual small-order risk is the low-order assumption. These are real, additional assumptions and are stated in MATH.md Section 10.
Bounded bids (not just 0/1) are also covered now, on both paths, by a bit-decomposition range proof: a power-of-two [0, 2^L) (gen_in_range_proved / verify_range, cl_*) and a tight [0, B) for arbitrary B (gen_in_range_tight / verify_range_tight, and cl_gen_in_range_tight / cl_verify_range_tight). The tight version proves s and B-1-s both in [0, 2^L) and ties them with a Chaum-Pedersen proof that their puzzles' product encodes the public B-1, so only s in [0, B) is accepted. The auction demo uses a tight bound B = 1000 and rejects an over-cap bidder.
One residual mitigation regardless of proofs: if a malformed CL puzzle slips in (for example in a deployment that skips proof verification), cl_solve returns Err(ClError::MalformedPuzzle) rather than panicking or silently returning a wrong aggregate. Two layers give this: cl_solve first structurally validates the puzzle's forms (Form::is_valid: positive-definite, a > 0, exact discriminant) and rejects a degenerate form (for instance a zero or negative leading coefficient) BEFORE the squaring chain, which is necessary because the binary-quadratic-form arithmetic divides by the leading coefficient and would otherwise abort the process; then, for a structurally valid puzzle that carries a residual G-component, the easy-DL detects that the recovered element is not in F. The class-group verifiers (cl_verify_bit, cl_verify_range, cl_verify_range_tight) and the Wesolowski poe_verify apply the same structural validation to every untrusted form (the puzzle's own u/v, the proof commitments, and each range-proof bit-puzzle, screened before any composition) and return false rather than panicking. So the worst case is a detectable failure to open or a rejected proof, not a crash or a silently-wrong tally. The one homomorphic entry point with no error channel is cl_eval_sum (it returns a ClPuzzle); callers must gate inputs through a verifier first, which now reliably rejects malformed forms, and the clvote demo does exactly this.
- GNU MP manual, Integer Exponentiation (
mpz_powm_sec): https://gmplib.org/manual/Integer-Exponentiation - rug
Integer::secure_pow_mod: https://docs.rs/rug/latest/rug/struct.Integer.html#method.secure_pow_mod - Montgomery, Speeding the Pollard and Elliptic Curve Methods of Factorization (the ladder): https://www.ams.org/journals/mcom/1987-48-177/S0025-5718-1987-0866113-7/
- Threat model and presets:
THREAT_MODEL.md