Skip to content

Commit 07f17c5

Browse files
stackdumpclaude
andcommitted
Phase B / B5.8c: closePoll v3 branch — client-side aggregate + decrypt
window.closePoll now branches on poll.voteSchemaVersion === 3 and runs the homomorphic-tally close flow entirely in the browser: 1. Load sk_creator from localStorage; on miss, prompt the user to upload their backup file (parseSkBackupText validates pk = G·sk so a tampered file fails before any expensive work). 2. Cross-check the recovered sk against the poll's persisted pkCreator — guards against using the wrong backup. 3. GET /api/polls/{id}/votes (B5.7d) for the per-voter ciphertexts. 4. Aggregate per-bin via Pedersen pointAdd, then build the tallyDecrypt_8 witness — buildTallyDecryptWitness decrypts each bin internally bounded by the actual voter count. 5. WASM-prove tallyDecrypt_8 in the worker. 6. Sign the canonical aggregate-tally payload (mirrors polls_v3_aggregate.go's format byte-for-byte). 7. POST {creator, signature, tallies, decryptProofBytes} to /aggregate. After a successful close, sk_creator is cleared from localStorage — the tally is public anyway, but lingering keys are an avoidable footgun. aggregateSigPayload duplicates the server's payload string construction; any drift in either side immediately breaks signature verification, which surfaces as a 403 in the E2E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9599771 commit 07f17c5

1 file changed

Lines changed: 196 additions & 3 deletions

File tree

public/poll.js

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22

33
import { mimcHash } from './mimc.js';
44
import { MerkleTree } from './merkle.js';
5-
import { buildVoteCastWitness, buildVoteCastHomomorphicWitness, HOMOMORPHIC_CHOICES } from './witness-builder.js';
5+
import { buildVoteCastWitness, buildVoteCastHomomorphicWitness, buildTallyDecryptWitness, HOMOMORPHIC_CHOICES } from './witness-builder.js';
66
import { prove as workerProve, loadKeys, initProver } from './prover.js';
7-
import { SUBGROUP_ORDER as PEDERSEN_SUBGROUP_ORDER } from './pedersen.js';
7+
import {
8+
SUBGROUP_ORDER as PEDERSEN_SUBGROUP_ORDER,
9+
decodePointHex,
10+
encodePointHex,
11+
pointAdd,
12+
ZERO as PEDERSEN_ZERO,
13+
} from './pedersen.js';
814
import {
915
generateSkCreator,
1016
derivePkCreator,
1117
saveSkCreator,
18+
loadSkCreator,
19+
clearSkCreator,
1220
downloadSkBackup,
21+
parseSkBackupText,
1322
} from './sk-creator-store.js';
1423

1524
// Current poll context
@@ -812,8 +821,26 @@ window.closePoll = async function() {
812821

813822
const btn = document.getElementById('btn-close');
814823
btn.disabled = true;
815-
btn.textContent = 'Signing...';
816824

825+
// v3 (homomorphic-tally) close is a fundamentally different flow:
826+
// aggregate ciphertexts client-side, decrypt under sk_creator,
827+
// generate a Groth16 decrypt-proof, sign a canonical aggregate
828+
// payload, POST to /aggregate. Branch early so the v1/v2 code
829+
// path stays untouched.
830+
const schemaVersion = (currentPollData && currentPollData.voteSchemaVersion) || 1;
831+
if (schemaVersion === 3) {
832+
try {
833+
await closePollV3(btn);
834+
} catch (err) {
835+
showMsg('Close failed: ' + walletError(err, 'closePollV3'), 'error');
836+
} finally {
837+
btn.disabled = false;
838+
btn.textContent = 'Close & Publish Tallies';
839+
}
840+
return;
841+
}
842+
843+
btn.textContent = 'Signing...';
817844
try {
818845
const accounts = await provider.request({ method: 'eth_requestAccounts' });
819846
const creator = accounts[0];
@@ -842,6 +869,172 @@ window.closePoll = async function() {
842869
}
843870
};
844871

872+
// closePollV3 runs the homomorphic-tally close path. Caller is
873+
// window.closePoll (above) which handles button state and outer
874+
// error display.
875+
//
876+
// Steps:
877+
// 1. Recover sk_creator from localStorage, or prompt for the
878+
// backup file.
879+
// 2. GET /api/polls/{id}/votes → list of per-voter ciphertexts.
880+
// 3. Aggregate per-bin in the browser (server will repeat the
881+
// same aggregation but having a local copy means we can
882+
// decrypt under sk_creator without round-tripping).
883+
// 4. Build the tallyDecrypt_8 witness (decrypts in the process)
884+
// and WASM-prove.
885+
// 5. Sign the canonical aggregate-tally payload with the wallet.
886+
// 6. POST {creator, signature, tallies, decryptProofBytes} to
887+
// /api/polls/{id}/aggregate.
888+
async function closePollV3(btn) {
889+
btn.textContent = 'Loading creator key...';
890+
let sk = loadSkCreator(currentPollId);
891+
if (sk === null) {
892+
sk = await promptForSkBackup();
893+
if (sk === null) {
894+
// User cancelled; show a short hint and bail.
895+
throw new Error('creator key not found in this browser; restore from backup file to close');
896+
}
897+
// Re-save so next time the close button doesn't re-prompt.
898+
try { saveSkCreator(currentPollId, sk); } catch { /* localStorage may be unavailable */ }
899+
}
900+
901+
// Server expects a pkCreator that matches G·sk; cross-check
902+
// before doing any expensive work.
903+
const computedPk = derivePkCreator(sk);
904+
if (currentPollData && currentPollData.pkCreator
905+
&& currentPollData.pkCreator.toLowerCase() !== computedPk.toLowerCase()) {
906+
throw new Error(
907+
'creator key does not match this poll (pk mismatch). Did you upload the wrong backup?',
908+
);
909+
}
910+
911+
btn.textContent = 'Fetching votes...';
912+
const votesResp = await fetch(`/api/polls/${currentPollId}/votes`);
913+
if (!votesResp.ok) throw new Error('failed to fetch votes: ' + (await votesResp.text()));
914+
const votesData = await votesResp.json();
915+
const votes = votesData.votes || [];
916+
if (votes.length === 0) {
917+
throw new Error('no votes have been cast — nothing to aggregate');
918+
}
919+
920+
btn.textContent = `Aggregating ${votes.length} ciphertexts...`;
921+
const aggregatesHex = aggregateCiphertextsHex(votes);
922+
923+
btn.textContent = 'Decrypting tallies...';
924+
const witnessResult = buildTallyDecryptWitness({
925+
skCreator: sk,
926+
aggregatesHex,
927+
// tallies omitted ⇒ buildTallyDecryptWitness decrypts each bin.
928+
maxTally: votes.length,
929+
});
930+
const tallies = witnessResult.tallies; // [int; K]
931+
932+
btn.textContent = 'Generating decrypt proof...';
933+
await initProver();
934+
const proofData = await workerProve(witnessResult.circuit, witnessResult.witness);
935+
if (!proofData || !proofData.proof) {
936+
throw new Error('WASM prover returned no proof bytes');
937+
}
938+
939+
btn.textContent = 'Sign to publish tallies...';
940+
const provider = getWalletProvider();
941+
const accounts = await provider.request({ method: 'eth_requestAccounts' });
942+
const creator = accounts[0];
943+
const sigMsg = aggregateSigPayload(currentPollId, tallies);
944+
const signature = await provider.request({
945+
method: 'personal_sign',
946+
params: [sigMsg, creator],
947+
});
948+
949+
btn.textContent = 'Publishing tallies...';
950+
const resp = await fetch(`/api/polls/${currentPollId}/aggregate`, {
951+
method: 'POST',
952+
headers: { 'Content-Type': 'application/json' },
953+
body: JSON.stringify({
954+
creator,
955+
signature,
956+
tallies,
957+
decryptProofBytes: uint8ToBase64(proofData.proof),
958+
}),
959+
});
960+
if (!resp.ok) throw new Error(await resp.text());
961+
962+
// sk_creator is no longer needed — clear from localStorage so a
963+
// compromised browser session post-close can't decrypt anything
964+
// (though the plaintext tally is now public anyway, a hygienic
965+
// clear is the right default).
966+
try { clearSkCreator(currentPollId); } catch { /* */ }
967+
968+
showMsg(
969+
'Poll closed and tallies published. The decrypt proof is verifiable on this device — no one trusts the server.',
970+
'success',
971+
);
972+
loadPoll(currentPollId);
973+
}
974+
975+
// aggregateCiphertextsHex sums per-voter ciphertexts component-wise
976+
// across bins, returning [{A: hex, B: hex}; K]. Mirrors the server's
977+
// homomorphic-aggregate step (which it re-runs at close time too —
978+
// having a local copy lets us decrypt without an extra round trip).
979+
function aggregateCiphertextsHex(votes) {
980+
const aggA = new Array(HOMOMORPHIC_CHOICES).fill(PEDERSEN_ZERO);
981+
const aggB = new Array(HOMOMORPHIC_CHOICES).fill(PEDERSEN_ZERO);
982+
for (const v of votes) {
983+
if (!v.ciphertexts || v.ciphertexts.length !== HOMOMORPHIC_CHOICES) {
984+
throw new Error(`vote with malformed ciphertext list (got ${v.ciphertexts ? v.ciphertexts.length : 0}, want ${HOMOMORPHIC_CHOICES})`);
985+
}
986+
for (let j = 0; j < HOMOMORPHIC_CHOICES; j++) {
987+
const a = decodePointHex(v.ciphertexts[j].A);
988+
const b = decodePointHex(v.ciphertexts[j].B);
989+
aggA[j] = pointAdd(aggA[j], a);
990+
aggB[j] = pointAdd(aggB[j], b);
991+
}
992+
}
993+
const out = [];
994+
for (let j = 0; j < HOMOMORPHIC_CHOICES; j++) {
995+
out.push({
996+
A: encodePointHex(aggA[j]),
997+
B: encodePointHex(aggB[j]),
998+
});
999+
}
1000+
return out;
1001+
}
1002+
1003+
// aggregateSigPayload mirrors internal/server/polls_v3_aggregate.go's
1004+
// canonical close-message format: bitwrap-aggregate-tally:{pollID}:{tallies}
1005+
// with strict comma-joined decimal tallies and no spaces. Drift here
1006+
// breaks signature verification on the server.
1007+
function aggregateSigPayload(pollId, tallies) {
1008+
return 'bitwrap-aggregate-tally:' + pollId + ':' + tallies.map(String).join(',');
1009+
}
1010+
1011+
// promptForSkBackup opens a file-picker, parses the uploaded backup,
1012+
// and returns the BigInt sk. Resolves to null if the user cancels.
1013+
function promptForSkBackup() {
1014+
return new Promise((resolve, reject) => {
1015+
const input = document.createElement('input');
1016+
input.type = 'file';
1017+
input.accept = 'application/json,.json';
1018+
input.onchange = async () => {
1019+
const file = input.files && input.files[0];
1020+
if (!file) return resolve(null);
1021+
try {
1022+
const text = await file.text();
1023+
const parsed = parseSkBackupText(text);
1024+
if (parsed.pollId !== currentPollId) {
1025+
return reject(new Error(
1026+
`backup is for poll ${parsed.pollId.slice(0, 12)}…, not this poll`,
1027+
));
1028+
}
1029+
resolve(parsed.sk);
1030+
} catch (e) {
1031+
reject(e);
1032+
}
1033+
};
1034+
input.click();
1035+
});
1036+
}
1037+
8451038
// ============ Reveal Vote ============
8461039

8471040
function findRevealData(pollId) {

0 commit comments

Comments
 (0)