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/malware-check/confirm.ts b/pages/api/malware-check/confirm.ts new file mode 100644 index 00000000..486c4e34 --- /dev/null +++ b/pages/api/malware-check/confirm.ts @@ -0,0 +1,53 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { firebase, pushover } from '../_services' +import { malwareCheckErrorGenerator } from './download' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { auth_token, confirmed, election_id, issue_description, otp } = 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' }) + if (typeof confirmed !== 'boolean') return res.status(400).json({ error: 'Missing confirmed' }) + if (typeof otp !== 'string') return res.status(400).json({ error: 'Missing otp' }) + + const electionDoc = firebase.firestore().collection('elections').doc(election_id) + const checkDoc = electionDoc.collection('malware-checks').doc(auth_token) + + const check = await checkDoc.get() + const malwareCheckError = malwareCheckErrorGenerator('confirm', election_id, auth_token, otp, res) + if (!check.exists) return malwareCheckError('No malware check found') + + const data = check.data() + if (!data || !data.checks || !Array.isArray(data.checks)) return malwareCheckError('Invalid check data') + + // Find matching check entry by OTP + const checkEntry = data.checks.find((entry: { otp?: string }) => entry.otp === otp) + if (!checkEntry) return malwareCheckError('Invalid OTP', 401) + + // Update the specific check entry - add confirmation + const updatedChecks = data.checks.map((entry: { confirmations?: unknown[]; otp?: string }) => { + if (entry.otp === otp) { + const confirmations = Array.isArray(entry.confirmations) ? [...entry.confirmations] : [] + confirmations.push({ + confirmed, + confirmed_at: new Date(), + issue_description: issue_description || null, + otp, + }) + return { ...entry, confirmations } + } + + return entry + }) + + await checkDoc.update({ checks: updatedChecks }) + + if (!confirmed) + await pushover( + 'Malware check: Reported issue', + `${election_id}: ${auth_token} (OTP: ${otp})\n${issue_description ? `Issue: ${issue_description}` : ''}`, + ) + + return res.status(200).json({ success: true }) +} diff --git a/pages/api/malware-check/decrypt-success.ts b/pages/api/malware-check/decrypt-success.ts new file mode 100644 index 00000000..a59616ad --- /dev/null +++ b/pages/api/malware-check/decrypt-success.ts @@ -0,0 +1,35 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { firebase } from '../_services' +import { malwareCheckErrorGenerator } from './download' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { auth_token, election_id, otp } = 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' }) + if (typeof otp !== 'string') return res.status(400).json({ error: 'Missing otp' }) + + const electionDoc = firebase.firestore().collection('elections').doc(election_id) + const checkDoc = electionDoc.collection('malware-checks').doc(auth_token) + + const malwareCheckError = malwareCheckErrorGenerator('decrypt-success', election_id, auth_token, otp, res) + const check = await checkDoc.get() + if (!check.exists) return malwareCheckError('No malware check found') + + const data = check.data() + if (!data || !data.checks || !Array.isArray(data.checks)) return malwareCheckError('Invalid check data') + + // Find matching check entry by OTP + const checkEntry = data.checks.find((entry: { otp?: string }) => entry.otp === otp) + if (!checkEntry) return malwareCheckError('Invalid OTP', 401) + + // Update the specific check entry + const updatedChecks = data.checks.map((entry: { otp?: string }) => { + return entry.otp === otp ? { ...entry, decrypted_at: new Date() } : entry + }) + + await checkDoc.update({ checks: updatedChecks }) + + return res.status(200).json({ success: true }) +} diff --git a/pages/api/malware-check/download.ts b/pages/api/malware-check/download.ts new file mode 100644 index 00000000..925f613b --- /dev/null +++ b/pages/api/malware-check/download.ts @@ -0,0 +1,71 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { firebase, pushover } from '../_services' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { already_seen_device, auth_token, election_id, otp } = 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' }) + if (typeof otp !== 'string') return res.status(400).json({ error: 'Missing otp' }) + + const electionDoc = firebase.firestore().collection('elections').doc(election_id) + const checkDoc = electionDoc.collection('malware-checks').doc(auth_token) + + const check = await checkDoc.get() + + const malwareCheckError = malwareCheckErrorGenerator('download', election_id, auth_token, otp, res) + if (!check.exists) return malwareCheckError('No malware check found') + + const data = check.data() + if (!data || !data.checks || !Array.isArray(data.checks)) return malwareCheckError('Invalid check data') + + // Find matching check entry by OTP + const checkEntry = data.checks.find((entry: { otp?: string }) => entry.otp === otp) + if (!checkEntry) return malwareCheckError('Invalid OTP', 401) + + // Check if already downloaded + if (checkEntry.downloaded_at) { + await pushover('Malware check: Already downloaded', `${election_id}: ${auth_token}\nOTP: ${otp}`) + return res.status(410).json({ error: 'Already downloaded. Restart from original device' }) + } + + // Fetch encrypted 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() + + // Update the specific check entry + const updatedChecks = data.checks.map((entry: { already_seen_device?: null | string; otp?: string }) => { + if (entry.otp === otp) + return { + ...entry, + already_seen_device: already_seen_device ?? null, + downloaded_at: new Date(), + downloaded_by_ip: req.headers['x-real-ip'] || null, + downloaded_by_user_agent: req.headers['user-agent'] || null, + } + + return entry + }) + + await checkDoc.update({ checks: updatedChecks }) + + return res.status(200).json({ + encrypted_randomizers: checkEntry.encrypted_randomizers, + encrypted_vote: storedVote.encrypted_vote, + }) +} + +export function malwareCheckErrorGenerator( + type: 'confirm' | 'decrypt-success' | 'download', + election_id: string, + auth_token: string, + otp: string, + res: NextApiResponse, +) { + return async function (error: string, errorCode = 404) { + await pushover(`Malware check, ${type}: ${error}`, `${election_id}: ${auth_token}\nOTP: ${otp}`) + return res.status(errorCode).json({ error }) + } +} diff --git a/pages/api/malware-check/init.ts b/pages/api/malware-check/init.ts new file mode 100644 index 00000000..814188e1 --- /dev/null +++ b/pages/api/malware-check/init.ts @@ -0,0 +1,29 @@ +import { firestore } from 'firebase-admin' +import { NextApiRequest, NextApiResponse } from 'next' + +import { firebase } from '../_services' +import { generateEmailLoginCode } from '../admin-login' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { auth_token, election_id, encrypted_randomizers } = 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' }) + if (typeof encrypted_randomizers !== 'string') return res.status(400).json({ error: 'Missing encrypted_randomizers' }) + + const electionDoc = firebase.firestore().collection('elections').doc(election_id) + + // Generate 6-digit OTP + const otp = generateEmailLoginCode() + + // Create new check entry + const checkEntry = { created_at: new Date(), encrypted_randomizers, otp } + + // Add new check entry to checks array + await electionDoc + .collection('malware-checks') + .doc(auth_token) + .set({ auth_token, checks: firestore.FieldValue.arrayUnion(checkEntry) }, { merge: true }) + + return res.status(200).json({ otp }) +} diff --git a/pages/api/malware-check/malware-check-status.ts b/pages/api/malware-check/malware-check-status.ts new file mode 100644 index 00000000..bdca9d06 --- /dev/null +++ b/pages/api/malware-check/malware-check-status.ts @@ -0,0 +1,39 @@ +import { firebase } from 'api/_services' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + // Disabling endpoint for now, because we're not using it. + // It also could benefit from tighter auth, so not anyone can check statuses (and device user-agents) for other voters. + // And its understanding of the malware-check docs data structure is outdated. + const disabled = true + if (disabled) return res.status(400).json({ disabled }) + + const { auth, election_id } = req.query + + if (typeof election_id !== 'string') return res.status(400).json({ error: 'Missing election_id' }) + if (typeof auth !== 'string') return res.status(400).json({ error: 'Missing auth' }) + + const electionDoc = firebase.firestore().collection('elections').doc(election_id) + + // Fetch malware checks for this auth token + const checks = await electionDoc.collection('malware-checks').where('auth', '==', auth).get() + + const checkStatuses = checks.docs.map((doc) => { + const data = doc.data() + return { + confirmed: data.confirmed, + created_at: data.created_at?._seconds ? new Date(data.created_at._seconds * 1000).toISOString() : null, + device_info: data.device_info?.device_type || 'Unknown', + match: data.match, + user_agent: data.device_info?.user_agent || 'Unknown', + } + }) + + // Count confirmed checks + const confirmedCount = checkStatuses.filter((c) => c.confirmed === true).length + + return res.status(200).json({ + checks: checkStatuses, + verified_count: confirmedCount, + }) +} diff --git a/pages/malware-check/[election_id]/[auth_token]/[otp].tsx b/pages/malware-check/[election_id]/[auth_token]/[otp].tsx new file mode 100644 index 00000000..a24b26a8 --- /dev/null +++ b/pages/malware-check/[election_id]/[auth_token]/[otp].tsx @@ -0,0 +1 @@ +export { MalwareCheckPage as default } from 'src/malware-check/MalwareCheckPage' diff --git a/src/admin/Voters/useVoterInvites.ts b/src/admin/Voters/useVoterInvites.ts index 4a0c55d2..97f32d11 100644 --- a/src/admin/Voters/useVoterInvites.ts +++ b/src/admin/Voters/useVoterInvites.ts @@ -5,5 +5,9 @@ import { useData } from 'src/pusher-helper' export function useVoterInvites(): VoterInvites { const election_id = useRouter().query.election_id as string | undefined - return useData(`election/${election_id}/admin/find-voter-invites`, [`invite-voter-${election_id}`, 'delivery']) || {} + const { data } = useData(`election/${election_id}/admin/find-voter-invites`, [ + `invite-voter-${election_id}`, + 'delivery', + ]) + return data || {} } 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/symmetric-encrypt.ts b/src/crypto/symmetric-encrypt.ts new file mode 100644 index 00000000..cf780802 --- /dev/null +++ b/src/crypto/symmetric-encrypt.ts @@ -0,0 +1,80 @@ +import { Crypto } from '@peculiar/webcrypto' + +const crypto = new Crypto() + +const AesAlgorithm = { + counter: new Uint8Array(16), // null ok bc key always unique + length: 64, + name: 'AES-CTR', +} + +/** Decrypt a base64url-encoded ciphertext using AES-CTR with the given key. + Returns the decrypted string */ +export async function decryptSymmetric(key: CryptoKey, ciphertext: string): Promise { + // Convert base64url to base64 + let base64 = ciphertext.replace(/-/g, '+').replace(/_/g, '/') + const padding = base64.length % 4 + if (padding) { + base64 += '='.repeat(4 - padding) + } + + // Decode base64 to bytes + const cipherBytes = + typeof window !== 'undefined' + ? new Uint8Array( + atob(base64) + .split('') + .map((c) => c.charCodeAt(0)), + ) + : new Uint8Array(Buffer.from(base64, 'base64')) + + const decrypted = await crypto.subtle.decrypt(AesAlgorithm, key, cipherBytes) + return new TextDecoder().decode(decrypted) +} + +/** Encrypt a string using AES-CTR with the given key. + Returns base64url-encoded ciphertext */ +export async function encryptSymmetric(key: CryptoKey, data: string): Promise { + const encoded = new TextEncoder().encode(data) + const ciphertext = await crypto.subtle.encrypt(AesAlgorithm, key, encoded) + + const bytes = new Uint8Array(ciphertext) + const base64 = + typeof window !== 'undefined' ? btoa(String.fromCharCode(...bytes)) : Buffer.from(bytes).toString('base64') + + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +/** Export a CryptoKey to base64url string for storage in URL hash */ +export async function exportKeyToBase64URL(key: CryptoKey): Promise { + const keyData = await crypto.subtle.exportKey('raw', key) + const bytes = new Uint8Array(keyData) + const base64 = + typeof window !== 'undefined' ? btoa(String.fromCharCode(...bytes)) : Buffer.from(bytes).toString('base64') + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +/** Generate a 128-bit symmetric encryption key */ +export async function generateSymmetricKey(): Promise { + return crypto.subtle.generateKey({ length: 128, name: 'AES-CTR' }, true, ['encrypt', 'decrypt']) +} + +/** Import a base64url string back to a CryptoKey */ +export async function importKeyFromBase64URL(base64url: string): Promise { + // Convert base64url to base64 + let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const padding = base64.length % 4 + if (padding) base64 += '='.repeat(4 - padding) + + // Decode base64 to bytes + const bytes = + typeof window !== 'undefined' + ? new Uint8Array( + atob(base64) + .split('') + .map((c) => c.charCodeAt(0)), + ) + : new Uint8Array(Buffer.from(base64, 'base64')) + + return crypto.subtle.importKey('raw', bytes, { length: 128, name: 'AES-CTR' }, false, ['encrypt', 'decrypt']) +} 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/InitMalwareCheck.tsx b/src/malware-check/InitMalwareCheck.tsx new file mode 100644 index 00000000..76936a63 --- /dev/null +++ b/src/malware-check/InitMalwareCheck.tsx @@ -0,0 +1,184 @@ +import { useEffect, useMemo, useState } from 'react' +import { Spinner } from 'src/admin/Spinner' +import { api } from 'src/api-helper' +import { encryptSymmetric, exportKeyToBase64URL, generateSymmetricKey } from 'src/crypto/symmetric-encrypt' + +import { bigintToBase64URL } from '../vote/submitted/base64url' +import { State } from '../vote/vote-state' +import { MalwareCheckQRCode } from './MalwareCheckQRCode' + +type CheckStatus = { + confirmed: boolean | null + created_at: null | string + device_info: string + match: boolean + user_agent: string +} + +type StatusResponse = { + checks: CheckStatus[] + verified_count: number +} + +const LOCALHOST_IP = '192.168.4.124' + +export const MalwareCheck = ({ + auth, + election_id, + state, +}: { + auth: string + election_id: string + state: State & { submitted_at?: Date } +}) => { + const [isOpen, setIsOpen] = useState(false) + const [checkStatus] = useState(null) + const [keyBase64URL, setKeyBase64URL] = useState(null) + const [otp, setOtp] = useState(null) + const [qrError, setQrError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + // // Poll for malware check status + // useEffect(() => { + // if (!isOpen || !election_id || !auth) return + + // const fetchStatus = async () => { + // try { + // const response = await fetch(`/api/election/${election_id}/malware-check-status?auth=${auth}`) + // if (response.ok) { + // const data: StatusResponse = await response.json() + // setCheckStatus(data) + // } + // } catch { + // // Silently fail + // } + // } + + // fetchStatus() + // const interval = setInterval(fetchStatus, 5000) // Poll every 5 seconds + + // return () => clearInterval(interval) + // }, [isOpen, election_id, auth]) + + // Generate symmetric key, encrypt payload, and get OTP from server + useEffect(() => { + if (!isOpen) return + + if (!state.randomizer || otp || isLoading) return + ;(async () => { + setIsLoading(true) + setQrError(null) + + try { + // Generate symmetric key if not already generated + const key = await generateSymmetricKey() + // Export key to base64url for URL hash + const exported = await exportKeyToBase64URL(key) + setKeyBase64URL(exported) + + // Convert randomizers to base64url format + const parts: string[] = [] + for (const questionId of Object.keys(state.randomizer)) { + parts.push(questionId) + parts.push(bigintToBase64URL(BigInt(state.randomizer[questionId]))) + } + + // Create compact payload: pipe-delimited format + const payload = parts.join('|') + + // Encrypt payload using symmetric key + const encrypted_randomizers = await encryptSymmetric(key, payload) + + // Send encrypted randomizers to server + const response = await api('/malware-check/init', { + auth_token: auth, + election_id, + encrypted_randomizers, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error || 'Failed to initialize malware check') + } + + const result = await response.json() + if (result.otp) { + setOtp(result.otp) + } else { + throw new Error('No OTP received from server') + } + } catch (error) { + console.error('Failed to initialize malware check', error) + setQrError(error instanceof Error ? error.message : 'Failed to initialize malware check') + } finally { + setIsLoading(false) + } + })() + }, [state.randomizer, auth, election_id, otp, isLoading, isOpen]) + + const qrUrl = useMemo(() => { + if (!state.randomizer || !keyBase64URL || !otp) return '' + + // Build URL: /malware-check/:election_id/:auth_token/:otp#[symmetric_key] + 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}/${otp}#${keyBase64URL}` + }, [auth, election_id, state.randomizer, keyBase64URL, otp]) + + return ( +
+

+ setIsOpen(!isOpen)}> + {isOpen ? '[-]' : '[+]'} Verify your vote {"wasn't"} changed by malware + +

+ {isOpen && + (qrError ? ( +

⚠️ {qrError}

+ ) : ( +
+ {isLoading || !qrUrl ? ( +
+ Loading QR... +
+ ) : ( + + )} + +
+

+ As an additional layer of security, you can use multiple devices to verify your vote was submitted + as intended. +

+ +

+ How it works: This rebuilds your encrypted vote + submission, then lets you confirm the selections are as you intended. +

+ +

⚠️ This QR contains your private vote selections.

+ + + Learn more → + + {checkStatus && checkStatus.verified_count > 0 && ( +

+ Verified with {checkStatus.verified_count} separate device{checkStatus.verified_count > 1 ? 's' : ''} + {checkStatus.checks + .filter((c) => c.confirmed === true) + .slice(0, 1) + .map((c) => ` - ${c.device_info}`) + .join(', ')} +

+ )} +
+
+ ))} +
+ ) +} diff --git a/src/malware-check/MalwareCheckPage.tsx b/src/malware-check/MalwareCheckPage.tsx new file mode 100644 index 00000000..3c7e1e8d --- /dev/null +++ b/src/malware-check/MalwareCheckPage.tsx @@ -0,0 +1,334 @@ +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' +import { api } from 'src/api-helper' +import { pointToString, RP } from 'src/crypto/curve' +import { decryptWithRandomizer } from 'src/crypto/decrypt' +import { decryptSymmetric, importKeyFromBase64URL } from 'src/crypto/symmetric-encrypt' +import { Head } from 'src/Head' +import { unTruncateSelection } from 'src/status/un-truncate-selection' +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 { getAlreadySeenDevice, SameDeviceWarning } from './SameDeviceWarning' + +type VoteData = Record // questionId: plaintext + +export function MalwareCheckPage() { + return ( +
+ + + +
+ ) +} + +function PageContent() { + const router = useRouter() + const { auth_token, election_id, otp } = router.query + const [voteData, setVoteData] = useState(null) + const [error, setError] = useState('') + const [confirmed, setConfirmed] = useState(false) + const [issueDescription, setIssueDescription] = useState('') + const [showIssueForm, setShowIssueForm] = useState(false) + const [alreadySeenDevice, setAlreadySeenDevice] = useState(null) + const { ballot_design, electionInfoError, threshold_public_key } = useElectionInfo() + + // Extract symmetric key from URL hash and fetch encrypted payload + useEffect(() => { + if (typeof window === 'undefined' || !election_id || !auth_token || !otp || !threshold_public_key) return + if (typeof otp !== 'string') return setError('Missing otp') + if (typeof election_id !== 'string' || typeof auth_token !== 'string') return + + const hash = window.location.hash.slice(1) // Remove # + if (!hash) return setError('Missing symmetric key in URL') + ;(async () => { + try { + // Check if this device has been seen before + const already_seen_device = getAlreadySeenDevice(election_id, auth_token, otp) + setAlreadySeenDevice(already_seen_device) + + // Import symmetric key from base64URL + const symmetricKey = await importKeyFromBase64URL(hash) + + // Fetch encrypted randomizers and vote from server + const downloadResponse = await api('/malware-check/download', { + already_seen_device, + auth_token, + election_id, + otp, + }) + if (downloadResponse.status === 410) return setError('Already used') + if (!downloadResponse.ok) { + const error = await downloadResponse.json().catch(() => ({ error: 'Failed to download encrypted vote data' })) + return setError(error.error) + } + + const { encrypted_randomizers, encrypted_vote } = await downloadResponse.json() + if (!encrypted_randomizers) return setError('No encrypted randomizers received') + if (!encrypted_vote) return setError('No encrypted vote received') + + // Decrypt randomizers using symmetric key + const decryptedPayload = await decryptSymmetric(symmetricKey, encrypted_randomizers) + + // Parse pipe-delimited format: questionId|randomizer|questionId|randomizer|... + const parts = decryptedPayload.split('|') + if (parts.length < 2 || parts.length % 2 !== 0) throw new Error('Invalid data format') + + const randomizers: Record = {} + + // Process in pairs: questionId, randomizer + for (let i = 0; i < parts.length; i += 2) { + const questionId = parts[i] + const randomizer = parts[i + 1] + randomizers[questionId] = randomizer + } + + if (!randomizers || Object.keys(randomizers).length === 0) return setError('Invalid vote data format') + + // Decrypt each cipher using its randomizer + const public_key = RP.fromHex(threshold_public_key) + const decryptedPlaintext: Record = {} + + for (const questionId of Object.keys(randomizers)) { + const cipher = encrypted_vote[questionId] + if (!cipher) return setError(`Missing encrypted vote for question: ${questionId}`) + + // Convert base64url encoded randomizer back to bigint + const randomizerBigInt = base64URLToBigint(randomizers[questionId]) + + // console.log({ cipher, public_key, questionId, randomizerBigInt }) + + // Decrypt using randomizer and public key + const decryptedPoint = decryptWithRandomizer(public_key, randomizerBigInt, { + encrypted: RP.fromHex(cipher.encrypted), + lock: RP.fromHex(cipher.lock), + }) + + // Extract plaintext from point (format: "verification_number:plaintext") + const decodedString = pointToString(decryptedPoint) + const colonIndex = decodedString.indexOf(':') + if (colonIndex === -1) return setError(`Invalid decoded format for question: ${questionId}`) + + const plaintext = decodedString.slice(colonIndex + 1) + decryptedPlaintext[questionId] = plaintext + } + + setVoteData(decryptedPlaintext) + + // Report successful decryption to server + const decryptSuccessResponse = await api('/malware-check/decrypt-success', { + auth_token: auth_token as string, + election_id: election_id as string, + otp, + }) + + if (!decryptSuccessResponse.ok) console.warn('Failed to report decrypt success') + } catch (error) { + setError('Failed to decrypt or verify vote') + console.error('Failed to decrypt or verify vote', error) + } + })() + }, [election_id, auth_token, otp, threshold_public_key]) + + const columns = useMemo(() => generateColumnNames({ ballot_design }).columns, [ballot_design]) + + const handleConfirm = async (confirmed: boolean) => { + try { + const response = await api('/malware-check/confirm', { + auth_token, + confirmed, + election_id, + otp, + }) + + if (!confirmed) return setShowIssueForm(true) + + if (response.ok) setConfirmed(true) + } catch (error) { + // Handle error + console.error('Failed to report confirmation', error) + } + } + + if (error === 'Already used') + return ( +
+

Sorry

+

This 2nd Device Malware Check was already used.

+ +

Restart from the original voting device.

+

+ Refresh the original device's "Vote Submitted" screen to generate a new QR code. +

+ + {/* Why */} +
+

Why?

+

Each malware-check can only be used once.

+

+ This protects against someone viewing the browser history to learn private vote data, when a borrowed device + is used. +

+
+
+ ) + + if (error) + return ( +
+

Error

+

{error}

+
+ ) + + if (electionInfoError) + return ( +
+

Error Loading Election

+

+ {electionInfoError instanceof Error ? electionInfoError.message : String(electionInfoError)} +

+ {election_id &&

Election ID: {election_id}

} +
+ ) + + if (!voteData || !ballot_design || !threshold_public_key || !Object.keys(voteData).length) + return ( +
+

Loading {!voteData ? 'vote data' : !ballot_design ? 'ballot design' : 'public key'}...

+
+ ) + + if (confirmed) + return ( +
+

Thank you

+

You can now close this window.

+
+ ) + + // Show confirmation UI if match + return ( +
+ +

Multi-Device Malware Check

+ +

Confirm your selections

+

+ FOR AUTH TOKEN: {auth_token} +

+ + + + + + + + + + {columns.map((columnId) => { + const plaintext = voteData?.[columnId] + if (!plaintext) return null + + const displayValue = unTruncateSelection(plaintext, ballot_design || [], columnId) + + // Find question title from ballot design + const question = ballot_design.find((item) => { + if (item.id === columnId) return true + // Handle multi-vote columns like "president_1", "president_2" + const baseId = columnId.replace(/_\d+$/, '') + return item.id === baseId + }) + + return ( + + + + + ) + })} + +
QuestionSelection
{question?.title || columnId}{plaintext === 'BLANK' ? '' : displayValue}
+ +
+ {!showIssueForm ? ( + <> +

+ {Object.keys(columns).length === 1 ? 'Does this selection' : 'Do these selections'} match how you intended + to vote? +

+
+ + +
+ + ) : ( + <> + +