Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
83f1e21
/vote/submitted: UI to begin MalwareCheck
dsernst Dec 27, 2025
22a0861
/malware-check: QR code to export private vote data
dsernst Dec 27, 2025
ffbae23
/malware-check: Improve modal
dsernst Dec 27, 2025
1ef4656
/malware-check: base64 encode the url
dsernst Dec 27, 2025
0f5f964
/malware-check: encode randomizers directly as base64
dsernst Dec 27, 2025
854399f
/malware-check: Encode data as | delimited, instead of json
dsernst Dec 27, 2025
a834bfe
/malware-check: remove bloating base64 vs direct string
dsernst Dec 27, 2025
df9622c
/malware-check: Fix warnings about BigInt literals
dsernst Dec 27, 2025
20326a7
/malware-check: Shorter url path
dsernst Dec 27, 2025
ea6b70a
/malware-check: base64 encode verif number directly
dsernst Dec 27, 2025
dfff150
/malware-check: Encode selections from ballot_design
dsernst Dec 27, 2025
5b3f28a
/malware-check: Warning about private selections
dsernst Dec 27, 2025
03bb7c5
Begin cleaning up /malware-check page
dsernst Dec 27, 2025
0ad8f01
/malware-check: Move MalwareCheckPage.tsx to src/
dsernst Dec 27, 2025
cde69e0
/malware-check: Simplify genColNames()
dsernst Dec 27, 2025
437c62d
/malware-check: Add <TailwindPreflight/> & <Head/>
dsernst Dec 27, 2025
1711c7b
/malware-check: Begin rebuilding compacted vote data
dsernst Dec 27, 2025
d2c2b84
/malware-check: Decrypt vote on 2nd-device w/ randomizers
dsernst Dec 27, 2025
475a289
/malware-check: Send OTP w/ confirmations
dsernst Dec 27, 2025
31ac3b9
/malware-check: Improve look of confirmation screen
dsernst Dec 27, 2025
fcb9cf0
/malware-check: Improve hover effects for QR launcher
dsernst Dec 28, 2025
2c6aad2
/malware-check: Rename db could_decrypt field
dsernst Dec 29, 2025
49a7827
malware-check: Disable incomplete history.replace()
dsernst Dec 30, 2025
3045356
/malware-check: Send encrypted randomizers via server, for constant s…
dsernst Dec 31, 2025
6ba7357
malware-check: Better messaging when check expired (eg after page ref…
dsernst Dec 31, 2025
42cc52f
/vote Submit: Disable adding BLANK to approval IDs, which use generat…
dsernst Jan 1, 2026
2fdf01a
Merge branch 'main' into malware-check
dsernst Jan 2, 2026
c0b944f
/vote Submit: Wait for missing BLANK additions before submitting
dsernst Jan 2, 2026
3f9edaa
/malware-check: Add instructions to generate new QR code
dsernst Jan 2, 2026
9a466c8
malware-check: update text on Vote Submitted page & change +/- state
arianabuilds Jan 2, 2026
163a02a
malware-check: Tweak Init link text size
dsernst Jan 2, 2026
0afc043
/malware-check: Detect & warn if already seen device
dsernst Jan 2, 2026
3612a14
malware-check: add warning text Beta Feature
arianabuilds Jan 5, 2026
0da3804
malware-check: update text w/ more explanation
arianabuilds Jan 5, 2026
2dee048
malware-check: remove Beta Feature signal text
arianabuilds Jan 5, 2026
aad20e8
malware-check: delete mention about re-freshing page before each addi…
arianabuilds Jan 5, 2026
b51fe06
/malware-check: Show loadingElectionInfo errors
dsernst Jan 6, 2026
9aa3251
/malware-check: Better instructions on SubmittedScreen
dsernst Jan 6, 2026
a33b5a9
/malware-check: Get IP of 2nd device loading the check
dsernst Jan 6, 2026
635f85c
malware-check: Tweak SameDeviceWarning
dsernst Jan 6, 2026
b527803
malware-check: clarify 'Vote Submitted' screen
dsernst Jan 6, 2026
4f08c5b
MalwareCheckPage: Show auth token
dsernst Jan 6, 2026
d6cbef2
MalwareCheckPage: Better padding on small screens
dsernst Jan 6, 2026
02bfec4
MalwareCheckPage: Add h1 title Multi-Device Malware Check
dsernst Jan 6, 2026
febff43
/malware-check: Notify admin if issue detected
dsernst Jan 6, 2026
1db3ea8
MalwareCheckPage: Remove weird line about 'your response has been rec…
dsernst Jan 6, 2026
88ed9b7
malware-check: Simplify error logic
dsernst Jan 6, 2026
c52af08
malware-check: Fix SameDeviceWarning 'original' logic
dsernst Jan 6, 2026
9f258a0
MalwareCheckPage: Add 'Back' btn after 'Something is wrong'
dsernst Jan 6, 2026
834f013
MalwareCheckPage: Store if they hit 'Back' btn
dsernst Jan 6, 2026
4f20d40
Only load /malware-check/init when they expand the MalwareCheck link
dsernst Jan 6, 2026
eddcc5b
InitMalwareCheck: Show instructional text while QR loading
dsernst Jan 6, 2026
009a244
InitMalwareCheck: 'What happens' -> 'How it works'
dsernst Jan 6, 2026
5d096c4
/malware-check: Add admin ping whenever voter hits 'Already downloade…
dsernst Jan 6, 2026
2564ed4
malware-check: Remove now-unused /api/encrypted-vote endpoint
dsernst Jan 6, 2026
c4a5c60
InitMalwareCheck: Prevent page-reflow on QR load
dsernst Jan 6, 2026
99822f8
Disable /malware-check-status endpoint
dsernst Jan 6, 2026
a5e790a
/malware-check/confirm: Alert admin on bad OTP
dsernst Jan 6, 2026
0b14fc2
/malware-check: Alert admin on bad OTPs
dsernst Jan 6, 2026
1657ad3
/api/malware-check: Alert admin on edge-case endpoint errors
dsernst Jan 6, 2026
95564a3
/api/malware-check/download: Remove unnecessary typing
dsernst Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pages/api/election/[election_id]/admin/decrypt-column.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
53 changes: 53 additions & 0 deletions pages/api/malware-check/confirm.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
35 changes: 35 additions & 0 deletions pages/api/malware-check/decrypt-success.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
71 changes: 71 additions & 0 deletions pages/api/malware-check/download.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
29 changes: 29 additions & 0 deletions pages/api/malware-check/init.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
39 changes: 39 additions & 0 deletions pages/api/malware-check/malware-check-status.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
1 change: 1 addition & 0 deletions pages/malware-check/[election_id]/[auth_token]/[otp].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MalwareCheckPage as default } from 'src/malware-check/MalwareCheckPage'
6 changes: 5 additions & 1 deletion src/admin/Voters/useVoterInvites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {}
}
23 changes: 21 additions & 2 deletions src/crypto/decrypt.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
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)
const message = encrypted.subtract(shared_secret)

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
}
2 changes: 1 addition & 1 deletion src/crypto/encrypt.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
80 changes: 80 additions & 0 deletions src/crypto/symmetric-encrypt.ts
Original file line number Diff line number Diff line change
@@ -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'])
}
4 changes: 2 additions & 2 deletions src/crypto/tests/shuffle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions src/crypto/tests/threshold-keygen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading