Skip to content

Commit 7b58d04

Browse files
stackdumpclaude
andcommitted
Seal poll results while voting is open
Hide tallies, nullifiers, and commitments from the results endpoint while a poll is active. Only vote count is exposed. Prevents observers from diffing the tally after each vote to correlate timing and de-anonymize voters. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56e366c commit 7b58d04

3 files changed

Lines changed: 44 additions & 12 deletions

File tree

internal/server/polls.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -488,15 +488,27 @@ func (s *Server) handlePollResults(w http.ResponseWriter, r *http.Request) {
488488
}
489489

490490
result := map[string]interface{}{
491-
"pollId": pollID,
492-
"title": poll.Title,
493-
"choices": poll.Choices,
494-
"voteCount": len(votes),
495-
"nullifiers": nullifiers,
496-
"commitments": commitments,
497-
"status": poll.Status,
491+
"pollId": pollID,
492+
"title": poll.Title,
493+
"choices": poll.Choices,
494+
"status": poll.Status,
498495
}
499496

497+
// While active, only expose vote count — no tallies, nullifiers, or
498+
// commitments. Revealing per-vote data while voting is open lets an
499+
// observer diff the tally after each vote and de-anonymize voters.
500+
if poll.Status == "active" {
501+
result["voteCount"] = len(votes)
502+
w.Header().Set("Content-Type", "application/json")
503+
json.NewEncoder(w).Encode(result)
504+
return
505+
}
506+
507+
// Poll is closed — full results are safe to expose.
508+
result["voteCount"] = len(votes)
509+
result["nullifiers"] = nullifiers
510+
result["commitments"] = commitments
511+
500512
// Derive tallies from the Petri net event log (event sourcing)
501513
events, _ := s.store.ReadEvents(pollID)
502514
if len(events) > 0 {

public/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ <h3>Prove</h3>
9999
</svg>
100100
</div>
101101
<h3>Tally</h3>
102-
<p>Results aggregate on-chain in real time. The final tally is publicly verifiable &mdash; anyone can audit the proofs without accessing individual ballots.</p>
102+
<p>Results stay sealed until the poll closes. Once closed, the final tally is publicly verifiable &mdash; anyone can audit the proofs without accessing individual ballots.</p>
103103
</div>
104104
</div>
105105
</section>

public/poll.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,12 +456,33 @@ async function loadResults(pollId) {
456456

457457
const choices = data.choices || [];
458458
const voteCount = data.voteCount || 0;
459+
const barsDiv = document.getElementById('results-bars');
460+
461+
if (data.status === 'active') {
462+
// Poll is still open — tallies are hidden to prevent vote correlation
463+
barsDiv.innerHTML = choices.map(c => `
464+
<div class="result-bar">
465+
<div class="result-label">
466+
<span>${esc(c)}</span>
467+
<span style="color:var(--text-muted);">sealed</span>
468+
</div>
469+
<div class="result-track">
470+
<div class="result-fill" style="width:0%"></div>
471+
</div>
472+
</div>
473+
`).join('');
474+
document.getElementById('results-total').textContent =
475+
`${voteCount} vote${voteCount !== 1 ? 's' : ''} cast \u00b7 results sealed until poll closes`;
476+
document.getElementById('results-nullifiers').textContent =
477+
'Nullifiers hidden while poll is active.';
478+
return;
479+
}
480+
481+
// Poll is closed — show full results
459482
const tallies = data.tallies || null;
460483
const talliedCount = data.talliedCount || 0;
461-
const barsDiv = document.getElementById('results-bars');
462484

463485
if (tallies && talliedCount > 0) {
464-
// Show real tallies from revealed votes
465486
const maxVotes = Math.max(...tallies, 1);
466487
barsDiv.innerHTML = choices.map((c, i) => {
467488
const count = tallies[i] || 0;
@@ -479,12 +500,11 @@ async function loadResults(pollId) {
479500
`;
480501
}).join('');
481502
} else {
482-
// No reveals yet — show placeholders
483503
barsDiv.innerHTML = choices.map(c => `
484504
<div class="result-bar">
485505
<div class="result-label">
486506
<span>${esc(c)}</span>
487-
<span style="color:var(--text-muted);">hidden</span>
507+
<span style="color:var(--text-muted);">no tallied votes</span>
488508
</div>
489509
<div class="result-track">
490510
<div class="result-fill" style="width:0%"></div>

0 commit comments

Comments
 (0)