|
2 | 2 |
|
3 | 3 | import { mimcHash } from './mimc.js'; |
4 | 4 | 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'; |
6 | 6 | 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'; |
8 | 14 | import { |
9 | 15 | generateSkCreator, |
10 | 16 | derivePkCreator, |
11 | 17 | saveSkCreator, |
| 18 | + loadSkCreator, |
| 19 | + clearSkCreator, |
12 | 20 | downloadSkBackup, |
| 21 | + parseSkBackupText, |
13 | 22 | } from './sk-creator-store.js'; |
14 | 23 |
|
15 | 24 | // Current poll context |
@@ -812,8 +821,26 @@ window.closePoll = async function() { |
812 | 821 |
|
813 | 822 | const btn = document.getElementById('btn-close'); |
814 | 823 | btn.disabled = true; |
815 | | - btn.textContent = 'Signing...'; |
816 | 824 |
|
| 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...'; |
817 | 844 | try { |
818 | 845 | const accounts = await provider.request({ method: 'eth_requestAccounts' }); |
819 | 846 | const creator = accounts[0]; |
@@ -842,6 +869,172 @@ window.closePoll = async function() { |
842 | 869 | } |
843 | 870 | }; |
844 | 871 |
|
| 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 | + |
845 | 1038 | // ============ Reveal Vote ============ |
846 | 1039 |
|
847 | 1040 | function findRevealData(pollId) { |
|
0 commit comments