From d2c2b84908eeb0f28fff9b9cc04bbfe03bdbff00 Mon Sep 17 00:00:00 2001 From: David Ernst Date: Sat, 27 Dec 2025 03:10:02 -0800 Subject: [PATCH] /malware-check: Decrypt vote on 2nd-device w/ randomizers --- .../[election_id]/admin/decrypt-column.ts | 2 +- .../election/[election_id]/encrypted-vote.ts | 21 +++ pages/api/malware-check.ts | 110 ++++----------- src/crypto/decrypt.ts | 23 ++- src/crypto/encrypt.ts | 2 +- src/crypto/tests/shuffle.test.ts | 4 +- src/crypto/tests/threshold-keygen.test.ts | 4 +- src/malware-check/MalwareCheckPage.tsx | 131 +++++++++--------- src/protocol/VoteContext.tsx | 2 +- src/vote/submitted/MalwareCheck.tsx | 18 +-- src/vote/submitted/encodePlaintext.ts | 94 ------------- src/vote/vote-state.ts | 2 +- 12 files changed, 145 insertions(+), 268 deletions(-) create mode 100644 pages/api/election/[election_id]/encrypted-vote.ts delete mode 100644 src/vote/submitted/encodePlaintext.ts diff --git a/pages/api/election/[election_id]/admin/decrypt-column.ts b/pages/api/election/[election_id]/admin/decrypt-column.ts index caf839aa..d1725047 100644 --- a/pages/api/election/[election_id]/admin/decrypt-column.ts +++ b/pages/api/election/[election_id]/admin/decrypt-column.ts @@ -1,7 +1,7 @@ import { mapValues } from 'lodash-es' import { NextApiRequest, NextApiResponse } from 'next' import { pointToString, RP } from 'src/crypto/curve' -import decrypt from 'src/crypto/decrypt' +import { decrypt } from 'src/crypto/decrypt' import { CipherStrings } from 'src/crypto/stringify-shuffle' export default async (req: NextApiRequest, res: NextApiResponse) => { diff --git a/pages/api/election/[election_id]/encrypted-vote.ts b/pages/api/election/[election_id]/encrypted-vote.ts new file mode 100644 index 00000000..36a56d0f --- /dev/null +++ b/pages/api/election/[election_id]/encrypted-vote.ts @@ -0,0 +1,21 @@ +import { firebase } from 'api/_services' +import { NextApiRequest, NextApiResponse } from 'next' +import { CipherStrings } from 'src/crypto/stringify-shuffle' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { auth_token, election_id } = req.query + + if (typeof election_id !== 'string') return res.status(400).json({ error: 'Missing election_id' }) + if (typeof auth_token !== 'string') return res.status(400).json({ error: 'Missing auth_token' }) + + const electionDoc = firebase.firestore().collection('elections').doc(election_id) + + // Fetch stored vote from database + const votes = await electionDoc.collection('votes').where('auth', '==', auth_token).get() + if (votes.empty) return res.status(404).json({ error: 'Vote not found' }) + + const storedVote = votes.docs[0].data() + const storedEncryptedVote = storedVote.encrypted_vote as Record + + return res.status(200).json({ encrypted_vote: storedEncryptedVote }) +} diff --git a/pages/api/malware-check.ts b/pages/api/malware-check.ts index c4d358ee..8655df0c 100644 --- a/pages/api/malware-check.ts +++ b/pages/api/malware-check.ts @@ -1,14 +1,14 @@ import { firestore } from 'firebase-admin' import { NextApiRequest, NextApiResponse } from 'next' -import { CipherStrings } from 'src/crypto/stringify-shuffle' // import { firebase, pushover } from './_services' import { firebase } from './_services' +import { generateEmailLoginCode } from './admin-login' export default async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }) - const { auth_token, confirmed, election_id, issue_description, recalculated_encrypted_vote } = req.body + const { auth_token, confirmed, decrypted, election_id, issue_description } = req.body if (typeof election_id !== 'string') return res.status(400).json({ error: 'Missing election_id' }) if (typeof auth_token !== 'string') return res.status(400).json({ error: 'Missing auth_token' }) @@ -19,51 +19,41 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { if (confirmed !== undefined) { const checkDoc = electionDoc.collection('malware-checks').doc(auth_token) const check = await checkDoc.get() - if (!check.exists) return res.status(404).json({ error: 'No malware check found' }) await checkDoc.update({ - confirmed, - confirmed_at: firestore.FieldValue.serverTimestamp(), - issue_description: issue_description || null, + confirmations: firestore.FieldValue.arrayUnion({ + confirmed, + confirmed_at: new Date(), + issue_description: issue_description || null, + otp: req.body.otp || null, + }), }) return res.status(200).json({ success: true }) } // Otherwise, this is the initial check request - if (!recalculated_encrypted_vote) return res.status(400).json({ error: 'Missing recalculated_encrypted_vote' }) - // Fetch stored vote from database - const votes = await electionDoc.collection('votes').where('auth', '==', auth_token).get() - if (votes.empty) return res.status(404).json({ error: 'Vote not found', match: false }) - - const storedVote = votes.docs[0].data() - const storedEncryptedVote = storedVote.encrypted_vote as Record - - // Compare recalculated encrypted vote with stored encrypted vote - const match = compareEncryptedVotes(storedEncryptedVote, recalculated_encrypted_vote) - - // Get device info - const userAgent = req.headers['user-agent'] || 'Unknown' - const deviceInfo = parseUserAgent(userAgent) + // Generate a 6-digit OTP to match the device against later. + const otp = generateEmailLoginCode() // Store device info in malware-checks subcollection - const checkData = { - auth: auth_token, - confirmed: null as boolean | null, - created_at: firestore.FieldValue.serverTimestamp(), - device_info: { - device_type: deviceInfo, - timestamp: firestore.FieldValue.serverTimestamp(), - user_agent: userAgent, - }, - issue_description: null as null | string, - match, - recalculated_vote: recalculated_encrypted_vote, - } - - await electionDoc.collection('malware-checks').doc(auth_token).set(checkData) + await electionDoc + .collection('malware-checks') + .doc(auth_token) + .set( + { + auth: auth_token, + decrypted: firestore.FieldValue.arrayUnion({ + created_at: new Date(), + decrypted, + device_info: req.headers['user-agent'] || null, + otp, + }), + }, + { merge: true }, + ) // Send Pushover notification to admin on mismatch // if (!match) @@ -72,53 +62,5 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // `election: ${election_id}\nauth: ${auth_token}\ndevice: ${deviceInfo}\nVoter's recalculated vote does not match the stored vote.`, // ) - return res.status(200).json({ - device_info: deviceInfo, - match, - }) -} - -function compareEncryptedVotes( - stored: Record, - recalculated: Record, -): boolean { - // Check if all keys match - const storedKeys = Object.keys(stored).sort() - const recalculatedKeys = Object.keys(recalculated).sort() - - if (storedKeys.length !== recalculatedKeys.length) return false - - // Compare each cipher - for (const key of storedKeys) { - if (!recalculated[key]) return false - - const storedCipher = stored[key] - const recalculatedCipher = recalculated[key] - - // Compare encrypted and lock values - if (storedCipher.encrypted !== recalculatedCipher.encrypted) return false - if (storedCipher.lock !== recalculatedCipher.lock) return false - } - - return true -} - -function parseUserAgent(userAgent: string): string { - // Simple user agent parsing for device type - const ua = userAgent.toLowerCase() - - if (ua.includes('iphone')) return 'iPhone' - if (ua.includes('ipad')) return 'iPad' - if (ua.includes('android')) return 'Android' - if (ua.includes('windows')) return 'Windows' - if (ua.includes('mac')) return 'Mac' - if (ua.includes('linux')) return 'Linux' - - // Extract browser info - if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari' - if (ua.includes('chrome')) return 'Chrome' - if (ua.includes('firefox')) return 'Firefox' - if (ua.includes('edge')) return 'Edge' - - return 'Unknown Device' + return res.status(200).json({ otp }) } diff --git a/src/crypto/decrypt.ts b/src/crypto/decrypt.ts index aa432f59..82d5e8da 100644 --- a/src/crypto/decrypt.ts +++ b/src/crypto/decrypt.ts @@ -1,7 +1,7 @@ import { RP } from './curve' -import { Cipher } from './shuffle' +import { Cipher, Public_Key } from './shuffle' -export default function decrypt(secret_key: bigint, cipher: Cipher): RP { +export function decrypt(secret_key: bigint, cipher: Cipher): RP { const { encrypted, lock } = cipher const shared_secret = lock.multiply(secret_key) @@ -9,3 +9,22 @@ export default function decrypt(secret_key: bigint, cipher: Cipher): RP { return message } + +/** + * Decrypt a cipher using the randomizer and public key. + * This allows decryption without the secret key, useful for malware checks. + * + * Encryption: encrypted = message + (public_key * randomizer) + * Decryption: message = encrypted - (public_key * randomizer) + */ +export function decryptWithRandomizer(public_key: Public_Key, randomizer: bigint, cipher: Cipher): RP { + const { encrypted } = cipher + + // Calculate shared_secret = public_key * randomizer (same as in encryption) + const shared_secret = public_key.multiply(randomizer) + + // Decrypt: message = encrypted - shared_secret + const message = encrypted.subtract(shared_secret) + + return message +} diff --git a/src/crypto/encrypt.ts b/src/crypto/encrypt.ts index 7deb32c1..56409a1d 100644 --- a/src/crypto/encrypt.ts +++ b/src/crypto/encrypt.ts @@ -1,7 +1,7 @@ import { G, RP } from './curve' import { Cipher, Public_Key } from './shuffle' -export default function encrypt(public_key: Public_Key, randomizer: bigint, message: RP): Cipher { +export function encrypt(public_key: Public_Key, randomizer: bigint, message: RP): Cipher { // Calculate our encrypted message const shared_secret = public_key.multiply(randomizer) const encrypted = message.add(shared_secret) diff --git a/src/crypto/tests/shuffle.test.ts b/src/crypto/tests/shuffle.test.ts index 8fb60e59..0313be94 100644 --- a/src/crypto/tests/shuffle.test.ts +++ b/src/crypto/tests/shuffle.test.ts @@ -3,8 +3,8 @@ import { expect, test } from 'bun:test' import { isEqual, range } from 'lodash' import { pointToString, random_bigint, stringToPoint } from '../curve' -import decrypt from '../decrypt' -import encrypt from '../encrypt' +import { decrypt } from '../decrypt' +import { encrypt } from '../encrypt' import { generate_key_pair } from '../generate-key-pair' import { rename_to_c1_and_2, shuffleWithProof } from '../shuffle' import { verify_shuffle_proof } from '../shuffle-proof' diff --git a/src/crypto/tests/threshold-keygen.test.ts b/src/crypto/tests/threshold-keygen.test.ts index 62d62a1f..a7d3aec0 100644 --- a/src/crypto/tests/threshold-keygen.test.ts +++ b/src/crypto/tests/threshold-keygen.test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'bun:test' import { mapValues, noop } from 'lodash' import { CURVE, G, random_bigint, RP, stringToPoint } from '../curve' -import decrypt from '../decrypt' -import encrypt from '../encrypt' +import { decrypt } from '../decrypt' +import { encrypt } from '../encrypt' import { combine_partials, compute_keyshare, diff --git a/src/malware-check/MalwareCheckPage.tsx b/src/malware-check/MalwareCheckPage.tsx index 8bd99d50..5c040674 100644 --- a/src/malware-check/MalwareCheckPage.tsx +++ b/src/malware-check/MalwareCheckPage.tsx @@ -1,8 +1,8 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { api } from 'src/api-helper' -import { RP, stringToPoint } from 'src/crypto/curve' -import encrypt from 'src/crypto/encrypt' +import { pointToString, RP } from 'src/crypto/curve' +import { decryptWithRandomizer } from 'src/crypto/decrypt' import { CipherStrings } from 'src/crypto/stringify-shuffle' import { Head } from 'src/Head' import { unTruncateSelection } from 'src/status/un-truncate-selection' @@ -10,18 +10,15 @@ import { useElectionInfo } from 'src/status/use-election-info' import { TailwindPreflight } from 'src/TailwindPreflight' import { generateColumnNames } from 'src/vote/generateColumnNames' import { base64URLToBigint } from 'src/vote/submitted/base64url' -import { decodePlaintext } from 'src/vote/submitted/encodePlaintext' type CheckResult = { - device_info?: string error?: string - match: boolean + otp?: string } type VoteData = { + decryptedPlaintext: Record // questionId: plaintext randomizers: Record // questionId -> randomizer (base64url) - selections: Record // questionId -> plaintext - verificationNumber: string } export function MalwareCheckPage() { @@ -44,108 +41,102 @@ function PageContent() { const [showIssueForm, setShowIssueForm] = useState(false) const { ballot_design, threshold_public_key } = useElectionInfo() - // Extract vote data from URL hash on page load + // Extract randomizers from URL hash on page load useEffect(() => { if (typeof window === 'undefined' || !election_id || !auth_token) return const hash = window.location.hash.slice(1) // Remove # - if (!hash) return setCheckResult({ error: 'Missing vote data in URL', match: false }) + if (!hash) return setCheckResult({ error: 'Missing vote data in URL' }) try { - // Parse compact format directly (no base64url decoding needed): tracking|questionId|plaintext|randomizer|questionId|plaintext|randomizer|... + // Parse compact format: questionId|randomizer|questionId|randomizer|... const parts = hash.split('|') - if (parts.length < 1 || (parts.length - 1) % 3 !== 0) throw new Error('Invalid data format') + if (parts.length < 2 || parts.length % 2 !== 0) throw new Error('Invalid data format') - // Decode verification number from base64url - const verificationNumberBigInt = base64URLToBigint(parts[0]) - // Convert back to decimal string format, with dashes every 4 digits - const verificationNumberString = verificationNumberBigInt.toString().padStart(12, '0') - const verificationNumber = `${verificationNumberString.slice(0, 4)}-${verificationNumberString.slice( - 4, - 8, - )}-${verificationNumberString.slice(8, 12)}` - const selections: Record = {} const randomizers: Record = {} - // Process in groups of 3: questionId, encodedPlaintext, randomizer - // Note: We'll decode plaintext later when we have ballot_design - for (let i = 1; i < parts.length; i += 3) { + // Process in pairs: questionId, randomizer + for (let i = 0; i < parts.length; i += 2) { const questionId = parts[i] - const encodedPlaintext = parts[i + 1] - const randomizer = parts[i + 2] - selections[questionId] = encodedPlaintext + const randomizer = parts[i + 1] randomizers[questionId] = randomizer } - const data: VoteData = { - randomizers, - selections, - verificationNumber, - } + const data: VoteData = { decryptedPlaintext: {}, randomizers } setVoteData(data) // Move private data into document memory and clear URL window.history.replaceState(null, '', window.location.pathname) } catch (_error) { - setCheckResult({ error: 'Failed to parse vote data', match: false }) + setCheckResult({ error: 'Failed to parse vote data' }) console.error('Failed to parse vote data', _error) } }, [election_id, auth_token]) - // Recalculate encrypted vote and send to server + // Fetch encrypted vote, try to decrypt with randomizers, and send success/fail to server useEffect(() => { if (!voteData || !threshold_public_key || checkResult) return ;(async () => { - if (!ballot_design) return setCheckResult({ error: 'Missing ballot design', match: false }) - try { - if (!threshold_public_key) return setCheckResult({ error: 'Missing election public key', match: false }) + if (!threshold_public_key) return setCheckResult({ error: 'Missing election public key' }) const public_key = RP.fromHex(threshold_public_key) - const recalculated: Record = {} + const { randomizers } = voteData - const { randomizers, selections, verificationNumber } = voteData + if (!randomizers || Object.keys(randomizers).length === 0) + return setCheckResult({ error: 'Invalid vote data format' }) - if (!verificationNumber || !selections || !randomizers) - return setCheckResult({ error: 'Invalid vote data format', match: false }) + // Fetch encrypted vote from server + const encryptedVoteResponse = await api(`/election/${election_id}/encrypted-vote?auth_token=${auth_token}`) + if (!encryptedVoteResponse.ok) return setCheckResult({ error: 'Failed to fetch encrypted vote' }) - // Recalculate encryption for each selection - for (const questionId of Object.keys(selections)) { - // Decode plaintext from compact format (i{index}, b, w:{text}, or as-is) - const encodedPlaintext = selections[questionId] - const plaintext = decodePlaintext(encodedPlaintext, questionId, ballot_design) + const { encrypted_vote } = (await encryptedVoteResponse.json()) as { + encrypted_vote: Record + } + + // Decrypt each cipher using the randomizer + const decryptedPlaintext: Record = {} + + for (const questionId of Object.keys(randomizers)) { + const cipher = encrypted_vote[questionId] + if (!cipher) return setCheckResult({ error: `Missing encrypted vote for question: ${questionId}` }) // Convert base64url encoded randomizer back to bigint const randomizerBigInt = base64URLToBigint(randomizers[questionId]) - // Encode: stringToPoint(`${tracking}:${plaintext}`) - const encoded = stringToPoint(`${verificationNumber}:${plaintext}`) + // Decrypt using randomizer and public key + const decryptedPoint = decryptWithRandomizer(public_key, randomizerBigInt, { + encrypted: RP.fromHex(cipher.encrypted), + lock: RP.fromHex(cipher.lock), + }) - // Encrypt: encrypt(public_key, randomizer, encoded) - const cipher = encrypt(public_key, randomizerBigInt, encoded) + // Extract plaintext from point (format: "verification_number:plaintext") + const decodedString = pointToString(decryptedPoint) + const colonIndex = decodedString.indexOf(':') + if (colonIndex === -1) return setCheckResult({ error: `Invalid decoded format for question: ${questionId}` }) - // Convert to CipherStrings format - recalculated[questionId] = { - encrypted: String(cipher.encrypted), - lock: String(cipher.lock), - } + const plaintext = decodedString.slice(colonIndex + 1) + decryptedPlaintext[questionId] = plaintext } - // Send to API endpoint + // Update voteData with decrypted plaintext + setVoteData({ ...voteData, decryptedPlaintext }) + + // Let server know we were able to decrypt the vote const response = await api('/malware-check', { auth_token, + decrypted: true, election_id, - recalculated_encrypted_vote: recalculated, }) const result: CheckResult = await response.json() setCheckResult(result) } catch (error) { - setCheckResult({ error: 'Failed to recalculate or verify vote', match: false }) - console.error('Failed to recalculate or verify vote', error) + setCheckResult({ error: 'Failed to decrypt or verify vote' }) + console.error('Failed to decrypt or verify vote', error) } })() - }, [voteData, ballot_design, threshold_public_key, election_id, auth_token, checkResult]) + }, [voteData, threshold_public_key, election_id, auth_token, checkResult]) const columns = useMemo(() => generateColumnNames({ ballot_design }).columns, [ballot_design]) @@ -153,7 +144,7 @@ function PageContent() { if (!confirmed) return setShowIssueForm(true) try { - const response = await api('/api/malware-check', { + const response = await api('/malware-check', { auth_token, confirmed: true, election_id, @@ -168,7 +159,7 @@ function PageContent() { const handleSubmitIssue = async () => { try { - const response = await api('/api/malware-check', { + const response = await api('/malware-check', { auth_token, confirmed: false, election_id, @@ -192,6 +183,14 @@ function PageContent() { ) + if (!voteData.decryptedPlaintext || Object.keys(voteData.decryptedPlaintext).length === 0) { + return ( +
+

Decrypting vote...

+
+ ) + } + if (checkResult?.error) return (
@@ -215,7 +214,7 @@ function PageContent() {
) - if (!checkResult.match) + if (!checkResult.otp) return (

⚠️ Vote Mismatch Detected

@@ -244,11 +243,9 @@ function PageContent() { {columns.map((columnId) => { - const encodedPlaintext = voteData.selections?.[columnId] - if (!encodedPlaintext) return null + const plaintext = voteData.decryptedPlaintext?.[columnId] + if (!plaintext) return null - // Decode plaintext from compact format - const plaintext = decodePlaintext(encodedPlaintext, columnId, ballot_design || []) const displayValue = unTruncateSelection(plaintext, ballot_design || [], columnId) // Find question title from ballot design @@ -305,8 +302,6 @@ function PageContent() {
)} - {checkResult.device_info &&

Device: {checkResult.device_info}

} -

Note: You may be prompted to confirm via SMS to ensure that a live person is performing this 2nd device check, rather than malware from the original device. If you were given an Anti-Malware Code, please diff --git a/src/protocol/VoteContext.tsx b/src/protocol/VoteContext.tsx index d611478a..73e6e45d 100644 --- a/src/protocol/VoteContext.tsx +++ b/src/protocol/VoteContext.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useMemo, useReducer } from 'react' import { random_bigint, RP, stringToPoint } from 'src/crypto/curve' import { generateTrackingNum } from 'src/vote/tracking-num' -import encrypt from '../crypto/encrypt' +import { encrypt } from '../crypto/encrypt' import { public_key, voters } from './election-parameters' const rand = () => RP.BASE.multiplyUnsafe(random_bigint()).toHex() diff --git a/src/vote/submitted/MalwareCheck.tsx b/src/vote/submitted/MalwareCheck.tsx index ce74d40b..c918015c 100644 --- a/src/vote/submitted/MalwareCheck.tsx +++ b/src/vote/submitted/MalwareCheck.tsx @@ -3,7 +3,6 @@ import { useMemo, useState } from 'react' import { State } from '../vote-state' import { bigintToBase64URL } from './base64url' -import { encodePlaintext } from './encodePlaintext' import { MalwareCheckQRCode } from './MalwareCheckQRCode' type CheckStatus = { @@ -56,27 +55,22 @@ export const MalwareCheck = ({ // }, [isOpen, election_id, auth]) const qrUrl = useMemo(() => { - if (!state.tracking || !state.plaintext || !state.randomizer || !state.ballot_design) return '' + if (!state.randomizer) return '' - // Build compact format: tracking|questionId|plaintext|randomizer|questionId|plaintext|randomizer|... - // Using | as delimiter since it's unlikely to appear in plaintext or question IDs + // Build compact format: questionId|randomizer|questionId|randomizer|... + // Using | as delimiter since it's unlikely to appear in question IDs // All characters are URL-safe, so no need for base64url encoding - // Verification number encoded as base64url for compactness - const trackingNum = BigInt(state.tracking.replace(/-/g, '')) - const parts: string[] = [bigintToBase64URL(trackingNum)] + const parts: string[] = [] - for (const questionId of Object.keys(state.plaintext)) { + for (const questionId of Object.keys(state.randomizer)) { parts.push(questionId) - // Encode plaintext compactly (index for options, w: prefix for write-ins, b for BLANK) - const encodedPlaintext = encodePlaintext(state.plaintext[questionId], questionId, state.ballot_design) - parts.push(encodedPlaintext) parts.push(bigintToBase64URL(BigInt(state.randomizer[questionId]))) } // Use compact format directly (no base64url encoding needed - all chars are URL-safe) const encodedData = parts.join('|') - // Build URL: siv.org/malware-check/$election_id/$auth_token/#url_encoded_vote_data + // Build URL: siv.org/malware-check/$election_id/$auth_token/#randomizers const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'https:' : 'http:' const host = typeof window !== 'undefined' ? window.location.host.replace('localhost', LOCALHOST_IP) : 'siv.org' return `${protocol}//${host}/malware-check/${election_id}/${auth}#${encodedData}` diff --git a/src/vote/submitted/encodePlaintext.ts b/src/vote/submitted/encodePlaintext.ts deleted file mode 100644 index a8bacb59..00000000 --- a/src/vote/submitted/encodePlaintext.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { multi_vote_regex } from '../../status/tally-votes' -import { Item } from '../storeElectionInfo' - -/** Decode encoded plaintext back to original selection */ -export function decodePlaintext(encoded: string, questionId: string, ballotDesign: Item[]): string { - if (encoded === 'b') return 'BLANK' - - // If it's a write-in, remove "w:" prefix - if (encoded.startsWith('w:')) return encoded.slice(2) - - if (!ballotDesign) return 'MissingBallotDesign' - - // If it's an indexed option, consult ballot design - if (encoded.startsWith('i')) { - const index = Number.parseInt(encoded.slice(1), 10) - if (isNaN(index)) return encoded // Invalid format, return as-is - - // Find the question - const multiSuffix = questionId.match(multi_vote_regex) - let baseQuestionId = - multiSuffix && !ballotDesign.find((item) => item.id === questionId) - ? questionId.slice(0, -multiSuffix[0].length) - : questionId - - // For score/budget, the column format is "questionId_optionName", extract base questionId - let question = ballotDesign.find((item) => item.id === baseQuestionId) - if (!question) { - // Try to find by matching prefix (for score/budget voting: "vote_Chocolate" -> "vote") - question = ballotDesign.find((item) => { - if (!item.id) return false - return baseQuestionId.startsWith(item.id + '_') - }) - if (question) baseQuestionId = question.id || baseQuestionId - } - - if (!question || index < 0 || index >= question.options.length) return encoded - - const option = question.options[index] - - // Return the value if available, otherwise the name - return option.value || option.name - } - - // Otherwise, it's a score or special value, keep as-is - return encoded -} - -/** - * Encode plaintext selection compactly: - * - If matches an option: encode as "i{index}" (0-based) - * - If "BLANK": encode as "b" - * - Otherwise: encode as "w:{plaintext}" for write-ins, or keep as-is for scores/special values - */ -export function encodePlaintext(plaintext: string, questionId: string, ballotDesign: Item[]): string { - if (plaintext === 'BLANK') return 'b' - - if (!ballotDesign) return `w:${plaintext}` - - // Find the question in ballot design - // Handle multi-vote format (e.g., "president_1" -> "president") - // Handle score/budget format (e.g., "vote_Chocolate" -> "vote") - const multiSuffix = questionId.match(multi_vote_regex) - let baseQuestionId = - multiSuffix && !ballotDesign.find((item) => item.id === questionId) - ? questionId.slice(0, -multiSuffix[0].length) - : questionId - - // For score/budget, the column format is "questionId_optionName", extract base questionId - let question = ballotDesign.find((item) => item.id === baseQuestionId) - if (!question && (baseQuestionId.includes('_') || baseQuestionId.includes('-'))) { - // Try to find by matching prefix (for score/budget voting) - question = ballotDesign.find((item) => { - if (!item.id) return false - return baseQuestionId.startsWith(item.id + '_') || baseQuestionId.startsWith(item.id + '-') - }) - if (question) baseQuestionId = question.id || baseQuestionId - } - - if (!question) return `w:${plaintext}` - - // For score/budget voting, numeric strings are valid and should be kept as-is - if ((question.type === 'score' || question.type === 'budget') && /^\d+$/.test(plaintext)) return plaintext - - // Check if plaintext matches any option (check both value and name) - // Use original order (not shuffled) - const optionIndex = question.options.findIndex((option) => { - // Check if plaintext matches the option value or name - return (option.value || option.name) === plaintext - }) - if (optionIndex >= 0) return `i${optionIndex}` - - // Otherwise, treat as write-in - return `w:${plaintext}` -} diff --git a/src/vote/vote-state.ts b/src/vote/vote-state.ts index 4a9b27df..fa8b472f 100644 --- a/src/vote/vote-state.ts +++ b/src/vote/vote-state.ts @@ -2,7 +2,7 @@ import { mapValues, merge } from 'lodash-es' import { random_bigint, RP, stringToPoint } from 'src/crypto/curve' import { CipherStrings } from 'src/crypto/stringify-shuffle' -import encrypt from '../crypto/encrypt' +import { encrypt } from '../crypto/encrypt' import { Item } from './storeElectionInfo' import { generateTrackingNum } from './tracking-num' import { useLocalStorageReducer } from './useLocalStorage'