swisspost-evoting-go-poc/pkg/mixnet/zero_argument.go
saymrwulf ec4be74e17 Due-diligence hardening + Rust transport-security layer
Correctness/security review of the whole PoC, with fixes and regression tests.

Cryptographic soundness:
- mixnet: enforce the multi-exponentiation c_{B_m}=commit(0;0) check that was
  stubbed out with an empty if — without it a malicious mixer can prove a
  non-permutation shuffle.
- zkp: derive all four Fiat-Shamir challenges via RecursiveHashToZq instead of
  a biased `hash mod q` (which also capped the challenge space at 256 bits for
  production-sized groups).

Verification honesty:
- protocol: VerifyTally now actually calls zkp.VerifySchnorrProof and returns
  the true aggregate result instead of an unconditional true.
- protocol: persist the padded mix input (event.MixInput) so the verifier checks
  shuffle 0 against the same padding the tally used (fixes false INVALID for N<2).

Other correctness:
- kdf: length-prefix BuildKDFInfo parts so the info encoding is injective.
- math: GqElementFromSquareRoot accepts the valid root q (off-by-one that could
  panic in HashAndSquare); RandomGqElement samples the full canonical range.
- cmd: validate demo --voters/--options instead of panicking on degenerate values.
- protocol: use crypto/rand in the demo driver (drop the last math/rand import).

Transport security (new): pkg/transportsec exposes Ed25519 signatures and X25519
ECDH — implemented in Rust (rust/transportsec: ed25519-dalek, x25519-dalek),
linked into Go via cgo. No RSA. Cross-language conformance test proves the Rust
Ed25519 signatures interoperate with Go's crypto/ed25519. Makefile builds the
Rust static lib before the Go binary.

Tests: added unit/round-trip/tamper coverage for math, hash, elgamal, zkp,
mixnet, kdf, returncodes, protocol (end-to-end), and the Rust FFI bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:42:34 +02:00

252 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mixnet
import (
"math/big"
"github.com/user/evote/pkg/elgamal"
"github.com/user/evote/pkg/hash"
emath "github.com/user/evote/pkg/math"
)
// ZeroArgument is a proof that Σ_i a_i ★ b_{i-1} = 0 for committed matrices A, B.
type ZeroArgument struct {
CA0 emath.GqElement // Commitment to a_0 (prepended column)
CBm emath.GqElement // Commitment to b_m (appended column)
CD *emath.GqVector // Commitments to diagonal d vector (size 2m+1)
APrime *emath.ZqVector // Aggregated a' vector
BPrime *emath.ZqVector // Aggregated b' vector
RPrime emath.ZqElement // Aggregated randomness for A
SPrime emath.ZqElement // Aggregated randomness for B
TPrime emath.ZqElement // Aggregated randomness for D
}
// GenZeroArgument generates a ZeroArgument proof.
func GenZeroArgument(
cA *emath.GqVector, // Commitments to A columns (size m)
cB *emath.GqVector, // Commitments to B columns (size m)
A *emath.ZqMatrix, // n×m matrix
B *emath.ZqMatrix, // n×m matrix
r *emath.ZqVector, // Randomness for A (size m)
s *emath.ZqVector, // Randomness for B (size m)
y emath.ZqElement, // Star map parameter
pk elgamal.PublicKey, // Public key (needed for Fiat-Shamir hash)
ck CommitmentKey,
group *emath.GqGroup,
) ZeroArgument {
zqGroup := emath.ZqGroupFromGqGroup(group)
n := A.NumRows()
m := A.NumCols()
// 1. Prepend random a_0 to A, append random b_m to B
a0 := emath.RandomZqVector(n, zqGroup)
r0 := emath.RandomZqElement(zqGroup)
cA0 := ck.Commit(a0, r0)
bm := emath.RandomZqVector(n, zqGroup)
sm := emath.RandomZqElement(zqGroup)
cBm := ck.Commit(bm, sm)
// A' = [a_0 | A] (m+1 columns, n rows)
aPrimeCols := make([]*emath.ZqVector, m+1)
aPrimeCols[0] = a0
for j := 0; j < m; j++ {
aPrimeCols[j+1] = A.GetColumn(j)
}
// B' = [B | b_m] (m+1 columns, n rows)
bPrimeCols := make([]*emath.ZqVector, m+1)
for j := 0; j < m; j++ {
bPrimeCols[j] = B.GetColumn(j)
}
bPrimeCols[m] = bm
// r' = [r_0 | r] and s' = [s | s_m]
rPrepended := r.Prepend(r0)
sAppended := s.Append(sm)
// 2. Compute d vector (diagonal star map products)
// Java formula: d[k] = Σ StarMap(A'[i], B'[j]) where j = (m - k) + i
// Bounds: i = max(0, k-m) to m, break when j > m
dSize := 2*m + 1
dVec := make([]emath.ZqElement, dSize)
zero, _ := emath.NewZqElement(big.NewInt(0), zqGroup)
for k := 0; k < dSize; k++ {
dVec[k] = zero
for i := max(0, k-m); i <= m; i++ {
j := (m - k) + i
if j > m {
break
}
if j >= 0 {
sm := StarMap(aPrimeCols[i], bPrimeCols[j], y)
dVec[k] = dVec[k].Add(sm)
}
}
}
// 3. Generate randomness for d (Java: t[m+1] = 0)
tVec := make([]emath.ZqElement, dSize)
for k := 0; k < dSize; k++ {
if k == m+1 {
tVec[k] = zero
} else {
tVec[k] = emath.RandomZqElement(zqGroup)
}
}
// 4. Compute commitments to d
cdElems := make([]emath.GqElement, dSize)
for k := 0; k < dSize; k++ {
cdElems[k] = ck.H.Exponentiate(tVec[k]).Multiply(ck.G.Get(0).Exponentiate(dVec[k]))
}
cD := emath.GqVectorOf(cdElems...)
// 5. Fiat-Shamir challenge x
// Java hash order: (p, q, pk, ck, c_A_0, c_B_m, c_d, c_B, c_A)
x := zeroArgumentChallenge(group, pk, &ck, cA0, cBm, cD, cB, cA)
// 6. Compute x^i powers
xPowers := computeXPowers(x, 2*m+1, zqGroup)
// 7. Compute proof elements
aPrimeVec := emath.ZqVectorOfZeros(n, zqGroup)
for i := 0; i <= m; i++ {
scaled := aPrimeCols[i].ScalarMultiply(xPowers[i])
aPrimeVec = aPrimeVec.Add(scaled)
}
bPrimeVec := emath.ZqVectorOfZeros(n, zqGroup)
for i := 0; i <= m; i++ {
scaled := bPrimeCols[i].ScalarMultiply(xPowers[m-i])
bPrimeVec = bPrimeVec.Add(scaled)
}
rPrimeVal := zero
for i := 0; i <= m; i++ {
rPrimeVal = rPrimeVal.Add(xPowers[i].Multiply(rPrepended.Get(i)))
}
sPrimeVal := zero
for i := 0; i <= m; i++ {
sPrimeVal = sPrimeVal.Add(xPowers[m-i].Multiply(sAppended.Get(i)))
}
tPrimeVal := zero
for k := 0; k < dSize; k++ {
tPrimeVal = tPrimeVal.Add(xPowers[k].Multiply(tVec[k]))
}
return ZeroArgument{
CA0: cA0,
CBm: cBm,
CD: cD,
APrime: aPrimeVec,
BPrime: bPrimeVec,
RPrime: rPrimeVal,
SPrime: sPrimeVal,
TPrime: tPrimeVal,
}
}
// VerifyZeroArgument verifies a ZeroArgument proof.
func VerifyZeroArgument(
arg ZeroArgument,
cA *emath.GqVector,
cB *emath.GqVector,
y emath.ZqElement,
pk elgamal.PublicKey,
ck CommitmentKey,
group *emath.GqGroup,
) bool {
zqGroup := emath.ZqGroupFromGqGroup(group)
m := cA.Size()
// 1. Reconstruct x
x := zeroArgumentChallenge(group, pk, &ck, arg.CA0, arg.CBm, arg.CD, cB, cA)
xPowers := computeXPowers(x, 2*m+1, zqGroup)
// 2. Check c_D[m+1] commits to 0 (Java: c_d.get(m+1) == 1)
if !arg.CD.Get(m + 1).IsIdentity() {
return false
}
// 3. Check Π(c_A[:,i]^{x^(i+1)}) * c_A_0^{x^0} = commit(a', r')
lhs1 := arg.CA0.Exponentiate(xPowers[0])
for i := 0; i < m; i++ {
lhs1 = lhs1.Multiply(cA.Get(i).Exponentiate(xPowers[i+1]))
}
rhs1 := ck.Commit(arg.APrime, arg.RPrime)
if !lhs1.Equals(rhs1) {
return false
}
// 4. Check Π(c_B[:,i]^{x^(m-i)}) * c_B_m^{x^0} = commit(b', s')
lhs2 := arg.CBm.Exponentiate(xPowers[0])
for i := 0; i < m; i++ {
lhs2 = lhs2.Multiply(cB.Get(i).Exponentiate(xPowers[m-i]))
}
rhs2 := ck.Commit(arg.BPrime, arg.SPrime)
if !lhs2.Equals(rhs2) {
return false
}
// 5. Check Π(c_D[k]^{x^k}) = commit(starMap(a', b', y), t')
lhs3 := group.Identity()
for k := 0; k < arg.CD.Size(); k++ {
lhs3 = lhs3.Multiply(arg.CD.Get(k).Exponentiate(xPowers[k]))
}
starMapVal := StarMap(arg.APrime, arg.BPrime, y)
rhs3 := ck.H.Exponentiate(arg.TPrime).Multiply(ck.G.Get(0).Exponentiate(starMapVal))
return lhs3.Equals(rhs3)
}
// zeroArgumentChallenge computes the Fiat-Shamir challenge for ZeroArgument.
// Java hash order: (p, q, pk, ck, c_A_0, c_B_m, c_d, c_B, c_A)
func zeroArgumentChallenge(group *emath.GqGroup, pk elgamal.PublicKey, ck *CommitmentKey, cA0, cBm emath.GqElement, cD, cB, cA *emath.GqVector) emath.ZqElement {
zqGroup := emath.ZqGroupFromGqGroup(group)
q := group.Q()
hashBytes := hash.RecursiveHash(
hash.HashableBigInt{Value: group.P()},
hash.HashableBigInt{Value: group.Q()},
pkToHashable(pk),
ckToHashable(ck),
hash.HashableBigInt{Value: cA0.Value()},
hash.HashableBigInt{Value: cBm.Value()},
gqVectorToHashable(cD),
gqVectorToHashable(cB),
gqVectorToHashable(cA),
)
eVal := new(big.Int).SetBytes(hashBytes)
eVal.Mod(eVal, q)
e, _ := emath.NewZqElement(eVal, zqGroup)
return e
}
func computeXPowers(x emath.ZqElement, count int, group *emath.ZqGroup) []emath.ZqElement {
powers := make([]emath.ZqElement, count)
one, _ := emath.NewZqElement(big.NewInt(1), group)
powers[0] = one
if count > 1 {
powers[1] = x
for i := 2; i < count; i++ {
powers[i] = powers[i-1].Multiply(x)
}
}
return powers
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}