Skip to content

Commit 7c553ef

Browse files
stackdumpclaude
andcommitted
Phase B / B5.3: v3 vote submission server-side
POST /api/polls/{id}/vote branches on poll.VoteSchemaVersion. v3 votes carry K=8 ElGamal ciphertexts under the poll's PkCreator and a Groth16 proof against voteCastHomomorphic_8; the server rebuilds the public witness from authoritative state (poll fields + request nullifier + request ciphertexts) and verifies the proof against that, so a misbehaving client can't bind the proof to a different nullifier or ciphertext set than it claims. Defensive: voteCommitment, witness.voterSecret, witness.voteChoice are all rejected for v3 polls so client misconfigurations fail at submit time rather than as silent privacy leaks. Adds VerifyVoteCastHomomorphicProofBytes to prover, extends store.VoteRecord with a Ciphertexts field, and seven server tests cover happy path (real prove + verify), tampered proof, double- spend, and four shape-rejection cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e64ce12 commit 7c553ef

5 files changed

Lines changed: 688 additions & 2 deletions

File tree

internal/server/polls.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,21 @@ type createPollRequest struct {
4444
// castVoteRequest is the request body for POST /api/polls/{id}/vote.
4545
type castVoteRequest struct {
4646
Nullifier string `json:"nullifier"`
47-
VoteCommitment string `json:"voteCommitment"` // mimcHash(voterSecret, voteChoice) — blinded
47+
VoteCommitment string `json:"voteCommitment,omitempty"` // v1/v2 only; absent on v3
4848
Proof string `json:"proof"`
4949
Witness map[string]string `json:"witness,omitempty"` // full witness for server-side verification
5050
PublicInputs []string `json:"publicInputs,omitempty"` // proof public inputs for validation
5151

5252
// Client-side proof bytes (privacy-preserving path — server never sees private inputs)
5353
ProofBytes string `json:"proofBytes,omitempty"` // base64 gnark proof
5454
PublicWitnessBytes string `json:"publicWitnessBytes,omitempty"` // base64 gnark public witness
55+
56+
// v3 (homomorphic) ballots carry K ElGamal ciphertexts under the
57+
// poll's PkCreator. Server reconstructs the public witness from
58+
// these + poll fields rather than trusting the client's
59+
// PublicWitnessBytes — keeps the verifier's truth boundary at the
60+
// authoritative state, not the request body.
61+
Ciphertexts []store.HomomorphicCiphertext `json:"ciphertexts,omitempty"`
5562
}
5663

5764
// revealVoteRequest is the request body for POST /api/polls/{id}/reveal.
@@ -337,6 +344,21 @@ func (s *Server) handleCastVote(w http.ResponseWriter, r *http.Request) {
337344
http.Error(w, "nullifier required", http.StatusBadRequest)
338345
return
339346
}
347+
348+
// v3 / homomorphic-tally branch. Distinct shape from v1/v2: no
349+
// voteCommitment (the choice is hidden inside ciphertexts that
350+
// are never decrypted individually). voterSecret-bearing fields
351+
// like Witness["voterSecret"] are likewise rejected — server must
352+
// never see anything that could deanonymize a ballot.
353+
if poll.VoteSchemaVersion == 3 {
354+
if err := s.handleCastVoteV3(w, r, poll, &req); err != nil {
355+
// handleCastVoteV3 writes its own response on error.
356+
_ = err
357+
return
358+
}
359+
return
360+
}
361+
340362
if req.VoteCommitment == "" {
341363
http.Error(w, "voteCommitment required (blinded vote hash)", http.StatusBadRequest)
342364
return

internal/server/polls_v3.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package server
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/hex"
6+
"fmt"
7+
"log"
8+
"math/big"
9+
"net/http"
10+
"strings"
11+
"time"
12+
13+
tedwards "github.com/consensys/gnark-crypto/ecc/bn254/twistededwards"
14+
15+
"github.com/stackdump/bitwrap-io/internal/store"
16+
"github.com/stackdump/bitwrap-io/prover"
17+
)
18+
19+
// handleCastVoteV3 processes a vote against a v3 (homomorphic-tally)
20+
// poll. Returns a non-nil error iff a response has already been
21+
// written; the caller should bail out without writing again.
22+
//
23+
// Defensive contract: this function must not store or log anything
24+
// that could be used to recover an individual voter's choice. The
25+
// only inputs persisted are (nullifier, ciphertexts, proof). The
26+
// proof's private witness (one-hot V[K], randomness R[K], voter
27+
// secret) is never sent over the wire.
28+
func (s *Server) handleCastVoteV3(w http.ResponseWriter, r *http.Request, poll *store.Poll, req *castVoteRequest) error {
29+
// Reject leakage fields. A v3 client that sends voteCommitment is
30+
// confused — fail loudly so the bug surfaces at submit time
31+
// instead of as a silently-stored choice trail later.
32+
if req.VoteCommitment != "" {
33+
http.Error(w, "voteCommitment is not valid on v3 polls (use ciphertexts)", http.StatusBadRequest)
34+
return fmt.Errorf("voteCommitment present")
35+
}
36+
if _, ok := req.Witness["voterSecret"]; ok {
37+
http.Error(w, "voterSecret must not be sent for v3 polls", http.StatusBadRequest)
38+
return fmt.Errorf("voterSecret in witness")
39+
}
40+
if _, ok := req.Witness["voteChoice"]; ok {
41+
http.Error(w, "voteChoice must not be sent for v3 polls", http.StatusBadRequest)
42+
return fmt.Errorf("voteChoice in witness")
43+
}
44+
45+
if len(req.Ciphertexts) != prover.VoteCastHomomorphicChoices {
46+
http.Error(w, fmt.Sprintf("expected %d ciphertexts, got %d",
47+
prover.VoteCastHomomorphicChoices, len(req.Ciphertexts)), http.StatusBadRequest)
48+
return fmt.Errorf("ciphertext count mismatch")
49+
}
50+
if req.ProofBytes == "" {
51+
http.Error(w, "proofBytes required for v3 vote", http.StatusBadRequest)
52+
return fmt.Errorf("missing proofBytes")
53+
}
54+
55+
// Decode ciphertext points and check on-curve / in-subgroup. Without
56+
// the subgroup check, a voter with a small-subgroup-crafted point
57+
// could leak server-internal state during aggregation; the prover
58+
// would still accept it because the curve has cofactor 8.
59+
cts := make([]prover.Ciphertext, prover.VoteCastHomomorphicChoices)
60+
for j := 0; j < prover.VoteCastHomomorphicChoices; j++ {
61+
a, err := decodeAndCheckPoint(req.Ciphertexts[j].A)
62+
if err != nil {
63+
http.Error(w, fmt.Sprintf("ciphertext[%d].A: %v", j, err), http.StatusBadRequest)
64+
return err
65+
}
66+
b, err := decodeAndCheckPoint(req.Ciphertexts[j].B)
67+
if err != nil {
68+
http.Error(w, fmt.Sprintf("ciphertext[%d].B: %v", j, err), http.StatusBadRequest)
69+
return err
70+
}
71+
cts[j] = prover.Ciphertext{A: a, B: b}
72+
}
73+
74+
// Poll's PkCreator must already be valid (validated at create time)
75+
// but defensively re-decode here in case storage was tampered with.
76+
pkBytes, err := hex.DecodeString(poll.PkCreator)
77+
if err != nil || len(pkBytes) != 32 {
78+
http.Error(w, "stored pkCreator is malformed", http.StatusInternalServerError)
79+
return fmt.Errorf("bad stored pkCreator")
80+
}
81+
pk, err := prover.DecodePoint(pkBytes)
82+
if err != nil {
83+
http.Error(w, "stored pkCreator failed to decode", http.StatusInternalServerError)
84+
return err
85+
}
86+
87+
proofBytes, err := base64.StdEncoding.DecodeString(req.ProofBytes)
88+
if err != nil {
89+
http.Error(w, "invalid proofBytes encoding", http.StatusBadRequest)
90+
return err
91+
}
92+
93+
if s.proverSvc != nil {
94+
// pollId is hex-decoded into a 128-bit big int, matching the v2 path.
95+
pollIDInt := new(big.Int).SetBytes([]byte(poll.ID))
96+
registryRoot, err := parseHexToBig(poll.RegistryRoot)
97+
if err != nil {
98+
http.Error(w, fmt.Sprintf("registry root parse: %v", err), http.StatusInternalServerError)
99+
return err
100+
}
101+
nullifierInt, err := parseHexToBig(req.Nullifier)
102+
if err != nil {
103+
http.Error(w, fmt.Sprintf("nullifier parse: %v", err), http.StatusBadRequest)
104+
return err
105+
}
106+
107+
inputs := prover.VoteCastHomomorphicProofInputs{
108+
PollID: pollIDInt,
109+
RegistryRoot: registryRoot,
110+
Nullifier: nullifierInt,
111+
MaxChoices: len(poll.Choices),
112+
PkCreator: pk,
113+
Ciphertexts: cts,
114+
}
115+
if err := prover.VerifyVoteCastHomomorphicProofBytes(s.proverSvc.Prover(), proofBytes, inputs); err != nil {
116+
log.Printf("v3 proof verification failed: %v", err)
117+
http.Error(w, fmt.Sprintf("ZK proof verification failed: %v", err), http.StatusForbidden)
118+
return err
119+
}
120+
}
121+
122+
vote := &store.VoteRecord{
123+
Nullifier: req.Nullifier,
124+
Proof: "client-side:" + req.ProofBytes[:min(32, len(req.ProofBytes))] + "...",
125+
Timestamp: time.Now().UTC(),
126+
Ciphertexts: req.Ciphertexts,
127+
}
128+
if err := s.store.SaveVote(poll.ID, vote); err != nil {
129+
if strings.Contains(err.Error(), "nullifier already used") {
130+
http.Error(w, "Vote already cast (nullifier used)", http.StatusConflict)
131+
return err
132+
}
133+
log.Printf("Failed to save v3 vote: %v", err)
134+
http.Error(w, "Failed to record vote", http.StatusInternalServerError)
135+
return err
136+
}
137+
138+
// Append castVote event with nullifier only — choice and weight
139+
// are deliberately not bound (the server doesn't know them, by
140+
// design). The Petri runtime's "one vote per registration"
141+
// invariant is satisfied by the nullifier alone.
142+
_ = s.store.AppendEvent(poll.ID, store.PollEvent{
143+
Action: "castVote",
144+
Bindings: map[string]string{"nullifier": req.Nullifier},
145+
})
146+
147+
w.Header().Set("Content-Type", "application/json")
148+
w.WriteHeader(http.StatusOK)
149+
_, _ = w.Write([]byte(`{"status":"accepted"}`))
150+
return nil
151+
}
152+
153+
// decodeAndCheckPoint parses a 32-byte compressed BabyJubJub hex point
154+
// and verifies it lies in the prime-order subgroup.
155+
func decodeAndCheckPoint(hx string) (tedwards.PointAffine, error) {
156+
var p tedwards.PointAffine
157+
buf, err := hex.DecodeString(hx)
158+
if err != nil {
159+
return p, fmt.Errorf("hex decode: %w", err)
160+
}
161+
p, err = prover.DecodePoint(buf)
162+
if err != nil {
163+
return p, err
164+
}
165+
if !prover.IsInPrimeSubgroup(&p) {
166+
return p, fmt.Errorf("not in prime-order subgroup")
167+
}
168+
return p, nil
169+
}
170+
171+
// parseHexToBig accepts hex with or without 0x prefix and parses it as
172+
// a big.Int. Decimal input also works (existing fixtures store
173+
// registry roots as decimal-stringified field elements).
174+
func parseHexToBig(s string) (*big.Int, error) {
175+
s = strings.TrimPrefix(s, "0x")
176+
n, ok := new(big.Int).SetString(s, 0)
177+
if ok {
178+
return n, nil
179+
}
180+
// Try decimal explicitly (SetString with base 0 needs a 0x/0o/0b prefix).
181+
n, ok = new(big.Int).SetString(s, 10)
182+
if !ok {
183+
// Last resort: try as bare hex.
184+
n, ok = new(big.Int).SetString(s, 16)
185+
if !ok {
186+
return nil, fmt.Errorf("could not parse %q", s)
187+
}
188+
}
189+
return n, nil
190+
}

0 commit comments

Comments
 (0)