mirror of
https://github.com/siv-org/siv.git
synced 2026-01-07 17:43:52 -05:00
Merge pull request #290 from siv-org/malware-check
/vote/submitted: UI to begin MalwareCheck
This commit is contained in:
@@ -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) => {
|
||||
|
||||
53
pages/api/malware-check/confirm.ts
Normal file
53
pages/api/malware-check/confirm.ts
Normal 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 })
|
||||
}
|
||||
35
pages/api/malware-check/decrypt-success.ts
Normal file
35
pages/api/malware-check/decrypt-success.ts
Normal 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 })
|
||||
}
|
||||
71
pages/api/malware-check/download.ts
Normal file
71
pages/api/malware-check/download.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
29
pages/api/malware-check/init.ts
Normal file
29
pages/api/malware-check/init.ts
Normal 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 })
|
||||
}
|
||||
39
pages/api/malware-check/malware-check-status.ts
Normal file
39
pages/api/malware-check/malware-check-status.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
1
pages/malware-check/[election_id]/[auth_token]/[otp].tsx
Normal file
1
pages/malware-check/[election_id]/[auth_token]/[otp].tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { MalwareCheckPage as default } from 'src/malware-check/MalwareCheckPage'
|
||||
@@ -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 || {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
80
src/crypto/symmetric-encrypt.ts
Normal file
80
src/crypto/symmetric-encrypt.ts
Normal 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'])
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
184
src/malware-check/InitMalwareCheck.tsx
Normal file
184
src/malware-check/InitMalwareCheck.tsx
Normal 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 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>
|
||||
)
|
||||
}
|
||||
334
src/malware-check/MalwareCheckPage.tsx
Normal file
334
src/malware-check/MalwareCheckPage.tsx
Normal 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's "Vote Submitted" 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>
|
||||
)
|
||||
}
|
||||
122
src/malware-check/MalwareCheckQRCode.tsx
Normal file
122
src/malware-check/MalwareCheckQRCode.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
50
src/malware-check/SameDeviceWarning.tsx
Normal file
50
src/malware-check/SameDeviceWarning.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 || []
|
||||
}
|
||||
|
||||
@@ -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 } : {}) }
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
64
src/vote/submitted/base64url.ts
Normal file
64
src/vote/submitted/base64url.ts
Normal 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, '')
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user