Skip to content

Security: aryaethn/htlp

Security

SECURITY.md

Security and side-channel hardening notes

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.

TL;DR

  • 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_sec via rug::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.

What is hardened

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 in setup (h = g^e, e derived from phi), gen_with_randomness (u = g^r and h^(rN), r the secret randomness), solve_with_trapdoor (u^e), and prove_bit (the real-branch commitments g^k, H^k). The RSA modulus and N^2 are odd, as secure_pow_mod requires, and the exponents are positive. Note: the proof mask k is sampled UNIFORMLY and its top bit is deliberately NOT forced. Forcing it would push the real-branch response z = k + c*r into 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. So k's exact bit-length can leak through secure_pow_mod's size dependence, but k's size is anyway approximately public (the response z, which is k + 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_lock and cg_lock exponentiate the secret randomness r (and the secret message s in cl_lock) with pow_ct over a fixed bit-width (r has its top bit forced so the width is constant; r carries 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 faster pow (square-and-multiply), and its doc comment states that it leaks the exponent's Hamming weight and is for public exponents only.

What is NOT hardened (out of scope)

The negative space is the honest part.

  • The underlying arithmetic is not constant-time. secure_pow_mod and pow_ct only 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. PSolve performs T sequential squarings on 2^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 bit s) and the proof components are stored at array indices that depend on s. The expensive modular exponentiations run in a fixed order (simulate, then real) independent of s, 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.

Input validity (a trust-model gap, not a side channel)

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. The clvote demo 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.

References

There aren't any published security advisories