From 83f1e21805fce317574f2b0d61abd4ee66506ec0 Mon Sep 17 00:00:00 2001 From: David Ernst Date: Fri, 26 Dec 2025 20:29:15 -0800 Subject: [PATCH 01/60] /vote/submitted: UI to begin MalwareCheck --- src/vote/submitted/MalwareCheck.tsx | 30 ++++++++++++++++++++++++++ src/vote/submitted/QRCode.tsx | 29 +++++++++++++++++++++++++ src/vote/submitted/SubmittedScreen.tsx | 3 +++ 3 files changed, 62 insertions(+) create mode 100644 src/vote/submitted/MalwareCheck.tsx create mode 100644 src/vote/submitted/QRCode.tsx diff --git a/src/vote/submitted/MalwareCheck.tsx b/src/vote/submitted/MalwareCheck.tsx new file mode 100644 index 00000000..48e937ab --- /dev/null +++ b/src/vote/submitted/MalwareCheck.tsx @@ -0,0 +1,30 @@ +import { useState } from 'react' + +import { QRCode } from './QRCode' + +export const MalwareCheck = () => { + const [isOpen, setIsOpen] = useState(false) + return ( +
+

setIsOpen(!isOpen)} + > + [+] Test for Malware +

+ {isOpen && ( +
+
+

Scan to Begin

+ +
+ +
+ As an additional layer of security, you can use multiple devices to verify your vote was submitted as + intended. +
+
+ )} +
+ ) +} diff --git a/src/vote/submitted/QRCode.tsx b/src/vote/submitted/QRCode.tsx new file mode 100644 index 00000000..0adfdbea --- /dev/null +++ b/src/vote/submitted/QRCode.tsx @@ -0,0 +1,29 @@ +import type { Options } from 'qr-code-styling' + +import { useEffect, useRef } from 'react' + +const qrOptions: Options = { + cornersSquareOptions: { color: '#000000', type: 'square' }, + dotsOptions: { type: 'classy-rounded' }, + height: 111, + imageOptions: { hideBackgroundDots: true, imageSize: 0.5, margin: 0 }, + qrOptions: { errorCorrectionLevel: 'H' }, + width: 111, +} + +export const QRCode = ({ className, url }: { className?: string; url: string }) => { + const ref = useRef(null) + + useEffect(() => { + if (typeof window === 'undefined') return // Client-side only + + import('qr-code-styling').then(({ default: QRCodeStyling }) => { + if (!ref.current) return console.warn('Missing QR container ref') + const qrCode = new QRCodeStyling({ ...qrOptions, data: url }) + if (ref.current.firstChild) ref.current.removeChild(ref.current.firstChild) + qrCode.append(ref.current) + }) + }, []) + + return
+} diff --git a/src/vote/submitted/SubmittedScreen.tsx b/src/vote/submitted/SubmittedScreen.tsx index 73152eff..c32b4406 100644 --- a/src/vote/submitted/SubmittedScreen.tsx +++ b/src/vote/submitted/SubmittedScreen.tsx @@ -7,6 +7,7 @@ import { State } from '../vote-state' import { DetailedEncryptionReceipt } from './DetailedEncryptionReceipt' import { EncryptedVote } from './EncryptedVote' import { InvalidatedVoteMessage } from './InvalidatedVoteMessage' +import { MalwareCheck } from './MalwareCheck' import { UnlockedVote } from './UnlockedVote' import { UnverifiedEmailModal } from './UnverifiedEmailModal' @@ -64,6 +65,8 @@ export function SubmittedScreen({ No one else can possibly know it.

+ + {/* Encryption */}

How your vote was submitted:

From 22a0861ec1cac1a8c87f914e4f835e57aa183736 Mon Sep 17 00:00:00 2001 From: David Ernst Date: Fri, 26 Dec 2025 22:56:03 -0800 Subject: [PATCH 02/60] /malware-check: QR code to export private vote data --- .../[election_id]/malware-check-status.ts | 33 ++ pages/api/malware-check.ts | 131 ++++++++ .../malware-check/[auth_token].tsx | 300 ++++++++++++++++++ src/vote/submitted/MalwareCheck.tsx | 98 +++++- src/vote/submitted/MalwareCheckQRCode.tsx | 119 +++++++ src/vote/submitted/QRCode.tsx | 29 -- src/vote/submitted/SubmittedScreen.tsx | 2 +- 7 files changed, 676 insertions(+), 36 deletions(-) create mode 100644 pages/api/election/[election_id]/malware-check-status.ts create mode 100644 pages/api/malware-check.ts create mode 100644 pages/election/[election_id]/malware-check/[auth_token].tsx create mode 100644 src/vote/submitted/MalwareCheckQRCode.tsx delete mode 100644 src/vote/submitted/QRCode.tsx diff --git a/pages/api/election/[election_id]/malware-check-status.ts b/pages/api/election/[election_id]/malware-check-status.ts new file mode 100644 index 00000000..90e8817a --- /dev/null +++ b/pages/api/election/[election_id]/malware-check-status.ts @@ -0,0 +1,33 @@ +import { firebase } from 'api/_services' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + 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/api/malware-check.ts b/pages/api/malware-check.ts new file mode 100644 index 00000000..aba8f29a --- /dev/null +++ b/pages/api/malware-check.ts @@ -0,0 +1,131 @@ +import { firestore } from 'firebase-admin' +import { NextApiRequest, NextApiResponse } from 'next' +import { CipherStrings } from 'src/crypto/stringify-shuffle' + +import { firebase, pushover } from './_services' + +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 + + 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) + + // If this is a confirmation submission (after match) + if (confirmed !== undefined) { + const checkDoc = electionDoc.collection('malware-checks').where('auth', '==', auth_token) + const checks = await checkDoc.get() + + if (checks.empty) return res.status(404).json({ error: 'No malware check found' }) + + // Find the most recent check (sort by created_at in memory) + const sortedChecks = checks.docs.sort((a, b) => { + const aTime = a.data().created_at?._seconds || 0 + const bTime = b.data().created_at?._seconds || 0 + return bTime - aTime + }) + + const checkRef = sortedChecks[0].ref + await checkRef.update({ + confirmed, + confirmed_at: firestore.FieldValue.serverTimestamp(), + issue_description: issue_description || 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) + + // 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').add(checkData) + + // Send Pushover notification to admin on mismatch + if (!match) + await pushover( + 'SIV Malware Check Mismatch', + `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' +} diff --git a/pages/election/[election_id]/malware-check/[auth_token].tsx b/pages/election/[election_id]/malware-check/[auth_token].tsx new file mode 100644 index 00000000..f6df0bcc --- /dev/null +++ b/pages/election/[election_id]/malware-check/[auth_token].tsx @@ -0,0 +1,300 @@ +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 { CipherStrings } from 'src/crypto/stringify-shuffle' +import { unTruncateSelection } from 'src/status/un-truncate-selection' +import { generateColumnNames } from 'src/vote/generateColumnNames' + +import { ElectionInfo } from '../../../api/election/[election_id]/info' + +type CheckResult = { + device_info?: string + error?: string + match: boolean +} + +type VoteData = { + s?: Record // short for s=selections, p=plaintext, r=randomizer + v?: string // short for v=verification_number +} + +export default function MalwareCheckPage() { + const router = useRouter() + const { auth_token, election_id } = router.query + const [voteData, setVoteData] = useState(null) + const [electionInfo, setElectionInfo] = useState(null) + const [checkResult, setCheckResult] = useState(null) + const [confirmed, setConfirmed] = useState(false) + const [issueDescription, setIssueDescription] = useState('') + const [showIssueForm, setShowIssueForm] = useState(false) + + // Extract vote data 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) { + setCheckResult({ error: 'Missing vote data in URL', match: false }) + return + } + + try { + const decoded = decodeURIComponent(hash) + const data: VoteData = JSON.parse(decoded) + 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 }) + console.error('Failed to parse vote data', _error) + } + }, [election_id, auth_token]) + + // Load election info to get public key and ballot design + useEffect(() => { + if (!election_id || typeof election_id !== 'string') return + ;(async () => { + try { + const response = await fetch(`/api/election/${election_id}/info`) + const info: ElectionInfo = await response.json() + setElectionInfo(info) + } catch (error) { + setCheckResult({ error: 'Failed to load election info', match: false }) + console.error('Failed to load election info', error) + } + })() + }, [election_id]) + + // Recalculate encrypted vote and send to server + useEffect(() => { + if (!voteData || !electionInfo?.threshold_public_key || checkResult) return + ;(async () => { + try { + if (!electionInfo.threshold_public_key) + return setCheckResult({ error: 'Missing election public key', match: false }) + + const public_key = RP.fromHex(electionInfo.threshold_public_key) + const recalculated: Record = {} + + // Un-shorten the vote data + const verificationNumber = voteData.v + const selections = voteData.s + + if (!verificationNumber || !selections) + return setCheckResult({ error: 'Invalid vote data format', match: false }) + + // Recalculate encryption for each selection + for (const [questionId, selection] of Object.entries(selections)) { + const plaintext = selection.p + const randomizer = selection.r + + // Encode: stringToPoint(`${tracking}:${plaintext}`) + const encoded = stringToPoint(`${verificationNumber}:${plaintext}`) + + // Encrypt: encrypt(public_key, randomizer, encoded) + const randomizerBigInt = BigInt(randomizer) + const cipher = encrypt(public_key, randomizerBigInt, encoded) + + // Convert to CipherStrings format + recalculated[questionId] = { + encrypted: String(cipher.encrypted), + lock: String(cipher.lock), + } + } + + // Send to API endpoint + const response = await fetch('/api/malware-check', { + body: JSON.stringify({ + auth_token, + election_id, + recalculated_encrypted_vote: recalculated, + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + + 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) + } + })() + }, [voteData, electionInfo, election_id, auth_token, checkResult]) + + const columns = useMemo(() => { + if (!electionInfo?.ballot_design) return [] + return generateColumnNames({ ballot_design: electionInfo.ballot_design }).columns + }, [electionInfo]) + + const handleConfirm = async (confirmed: boolean) => { + if (!confirmed) { + setShowIssueForm(true) + return + } + + try { + const response = await api('/api/malware-check', { + auth_token, + confirmed: true, + election_id, + }) + + if (response.ok) setConfirmed(true) + } catch (error) { + // Handle error + console.error('Failed to report confirmation', error) + } + } + + const handleSubmitIssue = async () => { + try { + const response = await api('/api/malware-check', { + auth_token, + confirmed: false, + election_id, + issue_description: issueDescription, + }) + + if (response.ok) { + setConfirmed(true) + setShowIssueForm(false) + } + } catch (error) { + // Handle error + console.error('Failed to submit issue', error) + } + } + + if (!voteData || !electionInfo) + return ( +
+

Loading...

+
+ ) + + if (checkResult?.error) + return ( +
+

Error

+

{checkResult.error}

+
+ ) + + if (!checkResult) + return ( +
+

Verifying your vote...

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

Thank you

+

Your response has been recorded. You can now close this window.

+
+ ) + + if (!checkResult.match) + return ( +
+

⚠️ Vote Mismatch Detected

+

+ The vote recalculated on this device does not match the vote stored on the server. This may indicate malware + on your original voting device. +

+

+ Please contact the election administrator immediately. +

+

The SIV admin has been notified about this issue.

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

Confirm your selections:

+ + + + + + + + + + {columns.map((columnId) => { + const selection = voteData.s?.[columnId] + if (!selection) return null + + const plaintext = selection.p + const displayValue = unTruncateSelection(plaintext, electionInfo.ballot_design || [], columnId) + + // Find question title from ballot design + const question = electionInfo.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 ? ( +
+ +