Merge pull request #290 from siv-org/malware-check

/vote/submitted: UI to begin MalwareCheck
This commit is contained in:
David Ernst
2026-01-05 23:50:56 -08:00
committed by GitHub
27 changed files with 1189 additions and 62 deletions

View File

@@ -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) => {

View File

@@ -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 })
}

View File

@@ -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 })
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}

View File

@@ -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,
})
}

View File

@@ -0,0 +1 @@
export { MalwareCheckPage as default } from 'src/malware-check/MalwareCheckPage'

View File

@@ -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 || {}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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<string> {
// 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<string> {
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<string> {
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<CryptoKey> {
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<CryptoKey> {
// 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'])
}

View File

@@ -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'

View File

@@ -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,

View File

@@ -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 | StatusResponse>(null)
const [keyBase64URL, setKeyBase64URL] = useState<null | string>(null)
const [otp, setOtp] = useState<null | string>(null)
const [qrError, setQrError] = useState<null | string>(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 (
<div>
<p className="py-6 mt-5 text-[12px]">
<a className="cursor-pointer text-blue-500/90" onClick={() => setIsOpen(!isOpen)}>
{isOpen ? '[-]' : '[+]'} Verify your vote {"wasn't"} changed by malware
</a>
</p>
{isOpen &&
(qrError ? (
<p className="mb-2 text-xs text-red-600"> {qrError}</p>
) : (
<div className="flex gap-4 items-center">
{isLoading || !qrUrl ? (
<div className="flex flex-col gap-4 items-center p-4 mb-2 text-xs rounded-md border border-solid text-black/50 border-black/15 h-[230px] w-[172px] justify-center">
<Spinner /> Loading QR...
</div>
) : (
<MalwareCheckQRCode url={qrUrl} />
)}
<div className="max-w-sm">
<p className="mb-3">
As an additional layer of security, you can use multiple&nbsp;devices to verify your vote was submitted
as intended.
</p>
<p className="text-[11px] text-black/60">
<span className="font-medium text-black/70">How it works:</span> This rebuilds your encrypted vote
submission, then lets you confirm the selections are as you intended.
</p>
<p className="mb-2 text-[11px] text-black/50"> This QR contains your private vote selections.</p>
<a
className="text-[11px] mt-7 block"
href="https://docs.siv.org/verifiability/detecting-malware"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</a>
{checkStatus && checkStatus.verified_count > 0 && (
<p className="mt-2 text-sm text-green-600">
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(', ')}
</p>
)}
</div>
</div>
))}
</div>
)
}

View File

@@ -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<string, string> // questionId: plaintext
export function MalwareCheckPage() {
return (
<div className="p-5 mx-auto max-w-2xl sm:p-8">
<Head title="Malware Check" />
<PageContent />
<TailwindPreflight />
</div>
)
}
function PageContent() {
const router = useRouter()
const { auth_token, election_id, otp } = router.query
const [voteData, setVoteData] = useState<null | VoteData>(null)
const [error, setError] = useState<string>('')
const [confirmed, setConfirmed] = useState(false)
const [issueDescription, setIssueDescription] = useState('')
const [showIssueForm, setShowIssueForm] = useState(false)
const [alreadySeenDevice, setAlreadySeenDevice] = useState<null | string>(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<string, string> = {}
// 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<string, string> = {}
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 (
<div className="p-8">
<h2 className="mb-4 font-bold text-red-600">Sorry</h2>
<p>This 2nd Device Malware Check was already used.</p>
<p className="mt-2 text-xl font-semibold">Restart from the original voting device.</p>
<p className="mt-2">
Refresh the original device&apos;s &quot;Vote Submitted&quot; screen to generate a new QR code.
</p>
{/* Why */}
<div className="mt-8 text-sm">
<p className="text-xs font-semibold opacity-50">Why?</p>
<p>Each malware-check can only be used once.</p>
<p className="mt-1">
This protects against someone viewing the browser history to learn private vote data, when a borrowed device
is used.
</p>
</div>
</div>
)
if (error)
return (
<div className="p-8">
<h2 className="mb-4 font-bold text-red-600">Error</h2>
<p className="whitespace-pre-wrap">{error}</p>
</div>
)
if (electionInfoError)
return (
<div className="p-8">
<h2 className="mb-4 font-bold text-red-600">Error Loading Election</h2>
<p className="whitespace-pre-wrap">
{electionInfoError instanceof Error ? electionInfoError.message : String(electionInfoError)}
</p>
{election_id && <p className="mt-2 text-sm opacity-70">Election ID: {election_id}</p>}
</div>
)
if (!voteData || !ballot_design || !threshold_public_key || !Object.keys(voteData).length)
return (
<div className="p-8 text-center animate-pulse">
<p>Loading {!voteData ? 'vote data' : !ballot_design ? 'ballot design' : 'public key'}...</p>
</div>
)
if (confirmed)
return (
<div className="p-8">
<h2 className="mb-4 font-bold text-green-600">Thank you</h2>
<p>You can now close this window.</p>
</div>
)
// Show confirmation UI if match
return (
<div>
<SameDeviceWarning {...{ alreadySeenDevice }} />
<h1 className="mb-3 text-2xl font-semibold">Multi-Device Malware Check</h1>
<h2 className="text-xl font-bold">Confirm your selections</h2>
<h3 className="mb-4 text-xs tracking-wide opacity-70">
FOR AUTH TOKEN: <span className="text-sm font-semibold">{auth_token}</span>
</h3>
<table className="mb-6 w-full border border-gray-300 border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="p-2 text-left border border-gray-300">Question</th>
<th className="p-2 text-left border border-gray-300">Selection</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={columnId}>
<td className="p-2 font-semibold border border-gray-300">{question?.title || columnId}</td>
<td className="p-2 border border-gray-300">{plaintext === 'BLANK' ? '' : displayValue}</td>
</tr>
)
})}
</tbody>
</table>
<div className="mb-6">
{!showIssueForm ? (
<>
<p className="mb-4">
{Object.keys(columns).length === 1 ? 'Does this selection' : 'Do these selections'} match how you intended
to vote?
</p>
<div className="flex gap-4">
<button
className="px-6 py-2 text-white bg-green-600 rounded hover:bg-green-700 active:bg-green-800"
onClick={() => handleConfirm(true)}
>
Yes, match
</button>
<button
className="px-6 py-2 text-white bg-red-600 rounded hover:bg-red-700 active:bg-red-800"
onClick={() => handleConfirm(false)}
>
No, something is wrong
</button>
</div>
</>
) : (
<>
<label className="block mb-2 font-semibold">Please describe the issue:</label>
<textarea
className="p-2 w-full rounded border border-gray-300"
onChange={(e) => setIssueDescription(e.target.value)}
rows={4}
value={issueDescription}
/>
<div className="flex gap-7 justify-end items-center mt-2">
<button
className="text-sm text-gray-400 rounded hover:text-gray-700"
onClick={async () => {
await api('/malware-check/confirm', {
auth_token,
confirmed: false,
election_id,
issue_description: 'User pressed "Back" button',
otp,
})
setShowIssueForm(false)
}}
>
Back
</button>
<button
className="px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600"
onClick={async () => {
try {
const response = await api('/malware-check/confirm', {
auth_token,
confirmed: false,
election_id,
issue_description: issueDescription,
otp,
})
if (response.ok) {
setConfirmed(true)
setShowIssueForm(false)
}
} catch (error) {
// Handle error
console.error('Failed to submit issue', error)
}
}}
>
Submit
</button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,122 @@
import type { Options } from 'qr-code-styling'
import { FullscreenOutlined } from '@ant-design/icons'
import { useEffect, useRef, useState } from 'react'
const sharedOptions: Options = {
cornersSquareOptions: { color: '#000000', type: 'square' },
dotsOptions: { type: 'classy-rounded' },
qrOptions: { errorCorrectionLevel: 'L' },
}
const smallQROptions: Options = {
...sharedOptions,
height: 150,
imageOptions: { hideBackgroundDots: true, imageSize: 0.5, margin: 0 },
width: 150,
}
export const MalwareCheckQRCode = ({ url }: { url: string }) => {
const smallQrRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
// Render small QR on initial load
useEffect(() => {
if (typeof window === 'undefined') return // Client-side only
import('qr-code-styling').then(({ default: QRCodeStyling }) => {
if (!smallQrRef.current) return console.warn('Missing QR container smallQrRef')
const qrCode = new QRCodeStyling({ ...smallQROptions, data: url })
if (smallQrRef.current.firstChild) smallQrRef.current.removeChild(smallQrRef.current.firstChild)
qrCode.append(smallQrRef.current)
})
}, [url])
// Render big QR when modal opened or window resized
useEffect(() => {
if (typeof window === 'undefined') return
const renderQR = () => {
import('qr-code-styling').then(({ default: QRCodeStyling }) => {
if (!modalRef.current) return console.warn('Missing modal QR container smallQrRef')
// Calculate responsive size: use 400px or 80vw, whichever is smaller
const maxSize = Math.min(400, window.innerWidth * 0.8)
const qrCode = new QRCodeStyling({
...sharedOptions,
data: url,
height: maxSize,
margin: 10,
width: maxSize,
})
if (modalRef.current.firstChild) modalRef.current.removeChild(modalRef.current.firstChild)
qrCode.append(modalRef.current)
})
}
renderQR()
// Re-render on window resize
window.addEventListener('resize', renderQR)
return () => window.removeEventListener('resize', renderQR)
}, [isModalOpen, url])
const handleClose = () => setIsModalOpen(false)
return (
<>
{/* Small QR Code */}
<div
className="flex flex-col items-center rounded-md border border-solid cursor-pointer group w-fit border-black/25 hover:border-black/40 hover:shadow-lg"
onClick={() => setIsModalOpen(true)}
>
<h4 className="mt-4 mb-0 text-sm font-semibold">Scan to Begin</h4>
<div className="p-2.5 w-[170px] h-[175px]" ref={smallQrRef} />
<div className="flex mb-2 text-gray-500 transition-colors group-hover:text-gray-900">
<FullscreenOutlined className="text-[14px] mr-1.5" />
<span className="text-xs">Tap for fullscreen</span>
</div>
</div>
{/* Modal */}
<div
className={`flex fixed inset-0 z-50 justify-center items-center bg-black bg-opacity-50 ${
isModalOpen ? '':'hidden'}`}
onClick={(e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose()
}}
>
<div className="relative p-4 sm:p-8 mx-4 max-w-[90vw] bg-white rounded-lg">
{/* Close button */}
<button
aria-label="Close modal"
className="flex absolute top-2 right-2 justify-center items-center w-10 h-10 text-gray-500 bg-transparent rounded-full border-none cursor-pointer hover:text-gray-700 hover:bg-gray-900/10"
onClick={handleClose}
>
<span className="relative bottom-px text-2xl font-bold">×</span>
</button>
<h3 className="mb-3 text-2xl text-center">Scan to Begin</h3>
{/* Warning */}
<p className="mt-4 text-sm text-center text-black/60"> Contains your private vote selections</p>
{/* Big QR code */}
<div className="flex justify-center" ref={modalRef} />
{/* Text link */}
<p className="mt-4 text-sm text-left break-all">
Or open this link on your 2nd device:
<a className="block text-blue-600 hover:underline" href={url} rel="noreferrer" target="_blank">
{url}
</a>
</p>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,50 @@
import { useState } from 'react'
/**
* Check localStorage to determine if this device has been seen before.
* Returns "original" if this is the same device that submitted the vote,
* the stored OTP if this device was used for a previous check, or null if it's a new device. And stores the new OTP for future checks.
*/
export function getAlreadySeenDevice(election_id: string, auth_token: string, otp: string): null | string {
if (typeof window === 'undefined') return null
// Check if this is the original voting device
if (localStorage.getItem(`voter-${election_id}-${auth_token}`)) return 'original'
// Check if this device was used for a previous malware check
const malwareCheckKey = `malware-check-${election_id}-${auth_token}`
const storedOtp = localStorage.getItem(malwareCheckKey)
if (storedOtp) return storedOtp
// New device - store current OTP for future checks
localStorage.setItem(malwareCheckKey, otp)
return null
}
export function SameDeviceWarning({ alreadySeenDevice }: { alreadySeenDevice: null | string }) {
const [dismissed, setDismissed] = useState(false)
if (!alreadySeenDevice || dismissed) return null
return (
<div className="p-4 mb-6 text-yellow-800 bg-yellow-50 rounded-lg border-2 border-yellow-400">
<div className="flex justify-between items-start mb-2 text-lg">
<h3 className="font-semibold"> Same Device Warning</h3>
<button className="text-yellow-600 hover:text-yellow-800" onClick={() => setDismissed(true)} type="button">
×
</button>
</div>
{alreadySeenDevice === 'original' ? (
<p>You&apos;re using the same device & browser used to submit your vote.</p>
) : (
<p>
This device was already used for a previous malware check.
<span className="block text-xs opacity-70">ID: {alreadySeenDevice}</span>
</p>
)}
<p className="mt-3 text-sm">For cross-device security verification, consider using a separate device.</p>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useElectionId } from '../status/use-election-id'
export function useTrusteeData() {
const e_id = useElectionId()
const data = useData(`election/${e_id}/trustees/latest`) as TrusteesLatest | undefined
return data || { t: undefined, trustees: undefined }
const { data } = useData(`election/${e_id}/trustees/latest`)
const typedData = data as TrusteesLatest | undefined
return typedData || { t: undefined, trustees: undefined }
}

View File

@@ -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()

View File

@@ -35,8 +35,11 @@ export const useData = (key: string, pusherChannel?: [string | undefined, string
return useSWR(cacheKey, (url: string) =>
fetch(url).then(async (r) => {
if (!r.ok) throw await r.json()
if (!r.ok) {
const errorData = await r.json().catch(() => ({ error: `HTTP ${r.status}: ${r.statusText}` }))
throw new Error(errorData.error || `Failed to load: ${r.status}`)
}
return await r.json()
}),
).data
)
}

View File

@@ -6,5 +6,6 @@ type Votes = Record<string, string>[]
export const useDecryptedVotes = (): Votes => {
const e_id = useElectionId()
return useData(`election/${e_id}/decrypted-votes`, [e_id, 'decrypted'])
const { data } = useData(`election/${e_id}/decrypted-votes`, [e_id, 'decrypted'])
return data || []
}

View File

@@ -2,10 +2,10 @@ import { ElectionInfo } from '../../pages/api/election/[election_id]/info'
import { useData } from '../pusher-helper'
import { useElectionId } from './use-election-id'
export function useElectionInfo(): ElectionInfo {
export function useElectionInfo(): ElectionInfo & { electionInfoError?: Error } {
const e_id = useElectionId()
const data = useData(`election/${e_id}/info`, [e_id, 'decrypted'])
const { data, error } = useData(`election/${e_id}/info`, [e_id, 'decrypted'])
return !data ? {} : data
return { ...(!data ? {} : data), ...(error ? { electionInfoError: error } : {}) }
}

View File

@@ -1,10 +1,11 @@
import * as Sentry from '@sentry/browser'
import router, { useRouter } from 'next/router'
import { Dispatch, useState } from 'react'
import { Dispatch, useEffect, useState } from 'react'
import { OnClickButton } from '../_shared/Button'
import { api } from '../api-helper'
import { AirGappedSubmission } from './AirGappedSubmission'
import { generateColumnNames } from './generateColumnNames'
import { State } from './vote-state'
export const SubmitButton = ({
@@ -19,8 +20,66 @@ export const SubmitButton = ({
state: State
}) => {
const [buttonText, setButtonText] = useState('Submit')
const [expectedColumns, setExpectedColumns] = useState<null | string[]>(null)
const { embed } = useRouter().query as { embed?: string }
useEffect(() => {
// The SubmitButton's onClick might add missing BLANKs (to avoid visible holes)
// but these additions are asynchronous, so this useEffect waits for them to finish.
// Before Submit.onClick, expectedColumns=null
if (expectedColumns === null) return
// Check if all expected columns exist in state.encrypted
const allPresent = expectedColumns.every((columnId) => state.encrypted[columnId])
if (!allPresent) return
;(async () => {
// All columns are present, submit now
const response = await api('submit-vote', {
auth,
election_id,
embed,
encrypted_vote: state.encrypted,
})
// Stop if there was an error
if (response.status !== 200) {
const { error } = (await response.json()) as { error: string }
Sentry.captureMessage(error, {
extra: { auth, election_id, encrypted_vote: state.encrypted },
level: Sentry.Severity.Error,
})
console.log(error)
if (error.startsWith('Vote already recorded')) {
setButtonText('Submitted.')
} else {
setButtonText('Error')
}
return setExpectedColumns(null)
}
// If auth is `link`, redirect to /auth page
if (auth === 'link') {
const { link_auth, visit_to_add_auth } = await response.json()
if (embed) {
// console.log('SIV submit button', link_auth, embed)
window.parent.postMessage({ link_auth }, embed)
dispatch({ submitted_at: new Date().toString() })
}
if (visit_to_add_auth) router.push(visit_to_add_auth)
}
dispatch({ submitted_at: new Date().toString() })
setExpectedColumns(null)
// Scroll page to top
window.scrollTo(0, 0)
})()
}, [expectedColumns, state.encrypted, auth, election_id, embed, dispatch])
return (
<>
<AirGappedSubmission {...{ auth, election_id, state }} />
@@ -33,59 +92,34 @@ export const SubmitButton = ({
Object.values(state.plaintext).filter((v) => v !== 'BLANK').length === 0 ||
buttonText !== 'Submit'
}
onClick={async () => {
onClick={() => {
if (state.submission_confirmation) {
if (!confirm(state.submission_confirmation)) return
}
if (auth === 'preview') return alert('You are in preview mode.\n\nNot submitting.')
// Guard against multiple clicks
if (expectedColumns !== null) return
setButtonText('Submitting...')
// Add plaintext "BLANK" for questions left blank
state.ballot_design?.map((item) => {
const id = item.id || 'vote'
// Determine expected columns from ballot design (type-agnostic)
const { columns } = generateColumnNames({ ballot_design: state.ballot_design })
if (state.plaintext[id]) return
if (item.multiple_votes_allowed) return
if (item.type === 'ranked-choice-irv') return
if (item.type === 'approval') return
// Check which columns are missing from state.encrypted
const missingColumns = columns.filter((columnId) => !state.encrypted[columnId])
dispatch({ [id]: 'BLANK' })
})
// Dispatch BLANKs for missing columns
if (missingColumns.length > 0) {
const blanksToDispatch: Record<string, string> = {}
missingColumns.forEach((columnId) => (blanksToDispatch[columnId] = 'BLANK'))
const response = await api('submit-vote', { auth, election_id, embed, encrypted_vote: state.encrypted })
// Stop if there was there an error
if (response.status !== 200) {
const { error } = (await response.json()) as { error: string }
Sentry.captureMessage(error, {
extra: { auth, election_id, encrypted_vote: state.encrypted },
level: Sentry.Severity.Error,
})
console.log(error)
if (error.startsWith('Vote already recorded')) return setButtonText('Submitted.')
return setButtonText('Error')
dispatch(blanksToDispatch)
}
// If auth is `link`, redirect to /auth page
if (auth === 'link') {
const { link_auth, visit_to_add_auth } = await response.json()
if (embed) {
// console.log('SIV submit button', link_auth, embed)
window.parent.postMessage({ link_auth }, embed)
dispatch({ submitted_at: new Date().toString() })
}
if (visit_to_add_auth) router.push(visit_to_add_auth)
}
dispatch({ submitted_at: new Date().toString() })
// Scroll page to top
window.scrollTo(0, 0)
// Always set expectedColumns, for useEffect to wait for
setExpectedColumns(columns)
}}
style={{ marginRight: 0 }}
>

View File

@@ -30,10 +30,10 @@ export const generateColumnNames = ({ ballot_design }: { ballot_design?: Item[]
// 'Score' & 'budget' expect a vote for each of the question's options
if (type === 'score' || type === 'budget') return options.map(({ name, value }) => `${id}_${value || name}`)
// Otherwise we'll just show the question ID, like Just Choose One ("Plurality")
// Otherwise we just show the question ID, eg "Choose Only One Plurality"
return id
})
// Finally we flatten the possibly two-dimensional arrays into a single one
// Finally, flatten the possibly two-dimensional arrays into a single one
return { columns: flatten(for_each_question) }
}

View File

@@ -2,6 +2,7 @@ import Link from 'next/link'
import { useEffect, useReducer } from 'react'
import { NoSsr } from 'src/_shared/NoSsr'
import { MalwareCheck } from '../../malware-check/InitMalwareCheck'
import { generateColumnNames } from '../generateColumnNames'
import { State } from '../vote-state'
import { DetailedEncryptionReceipt } from './DetailedEncryptionReceipt'
@@ -64,6 +65,8 @@ export function SubmittedScreen({
No one else can possibly know it.
</p>
<MalwareCheck {...{ auth, election_id, state }} />
{/* Encryption */}
<h3 className="mt-16">How your vote was submitted:</h3>

View File

@@ -0,0 +1,64 @@
/**
* Base64URL encoding/decoding utilities
* Base64URL is URL-safe: uses '-' instead of '+', '_' instead of '/', and removes padding '='
*/
const Big0 = BigInt(0)
const Big8 = BigInt(8)
const BigFF = BigInt(0xff)
/**
* Convert a base64url string back to bigint
*/
export function base64URLToBigint(encoded: string): bigint {
// Add padding back if needed
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padding = base64.length % 4
if (padding) {
base64 += '='.repeat(4 - padding)
}
// Decode from base64 to bytes
const bytes =
typeof window !== 'undefined'
? new Uint8Array(
atob(base64)
.split('')
.map((c) => c.charCodeAt(0)),
)
: new Uint8Array(Buffer.from(base64, 'base64'))
// Convert bytes to bigint (big-endian)
let result = Big0
for (let i = 0; i < bytes.length; i++) {
result = (result << Big8) | BigInt(bytes[i])
}
return result
}
/**
* Convert a bigint to base64url string
*/
export function bigintToBase64URL(value: bigint): string {
// Convert bigint to bytes (little-endian)
const bytes: number[] = []
let n = value
while (n > Big0) {
bytes.push(Number(n & BigFF))
n = n >> Big8
}
// Reverse to get big-endian (most significant byte first)
bytes.reverse()
// Convert bytes to base64url
const bytesArray = new Uint8Array(bytes)
const base64 =
typeof window !== 'undefined'
? btoa(String.fromCharCode(...bytesArray))
: Buffer.from(bytesArray).toString('base64')
// Convert to base64url format
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}

View File

@@ -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'