+ Or open this link on your 2nd device:
+
+ {url}
+
+
+
+
+ >
+ )
+}
diff --git a/src/malware-check/SameDeviceWarning.tsx b/src/malware-check/SameDeviceWarning.tsx
new file mode 100644
index 00000000..24bd100a
--- /dev/null
+++ b/src/malware-check/SameDeviceWarning.tsx
@@ -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 (
+
+
+
⚠️ Same Device Warning
+
+
+
+ {alreadySeenDevice === 'original' ? (
+
You're using the same device & browser used to submit your vote.
+ ) : (
+
+ This device was already used for a previous malware check.
+ ID: {alreadySeenDevice}
+
+ )}
+
+
For cross-device security verification, consider using a separate device.
+
+ )
+}
diff --git a/src/proofs/useTrusteeData.ts b/src/proofs/useTrusteeData.ts
index f5e9a65e..dea98f12 100644
--- a/src/proofs/useTrusteeData.ts
+++ b/src/proofs/useTrusteeData.ts
@@ -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 }
}
diff --git a/src/protocol/VoteContext.tsx b/src/protocol/VoteContext.tsx
index d611478a..73e6e45d 100644
--- a/src/protocol/VoteContext.tsx
+++ b/src/protocol/VoteContext.tsx
@@ -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()
diff --git a/src/pusher-helper.ts b/src/pusher-helper.ts
index a4c06e95..fcc63367 100644
--- a/src/pusher-helper.ts
+++ b/src/pusher-helper.ts
@@ -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
+ )
}
diff --git a/src/status/use-decrypted-votes.ts b/src/status/use-decrypted-votes.ts
index 93f8c50c..251333a6 100644
--- a/src/status/use-decrypted-votes.ts
+++ b/src/status/use-decrypted-votes.ts
@@ -6,5 +6,6 @@ type Votes = Record[]
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 || []
}
diff --git a/src/status/use-election-info.ts b/src/status/use-election-info.ts
index 3ac08d53..68bf5abb 100644
--- a/src/status/use-election-info.ts
+++ b/src/status/use-election-info.ts
@@ -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 } : {}) }
}
diff --git a/src/vote/SubmitButton.tsx b/src/vote/SubmitButton.tsx
index 4e021ca4..c386876a 100644
--- a/src/vote/SubmitButton.tsx
+++ b/src/vote/SubmitButton.tsx
@@ -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)
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 (
<>
@@ -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 = {}
+ 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 }}
>
diff --git a/src/vote/generateColumnNames.ts b/src/vote/generateColumnNames.ts
index c71e0bf7..2886eb39 100644
--- a/src/vote/generateColumnNames.ts
+++ b/src/vote/generateColumnNames.ts
@@ -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) }
}
diff --git a/src/vote/submitted/SubmittedScreen.tsx b/src/vote/submitted/SubmittedScreen.tsx
index 73152eff..f38e42d7 100644
--- a/src/vote/submitted/SubmittedScreen.tsx
+++ b/src/vote/submitted/SubmittedScreen.tsx
@@ -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.
+
+
{/* Encryption */}
How your vote was submitted:
diff --git a/src/vote/submitted/base64url.ts b/src/vote/submitted/base64url.ts
new file mode 100644
index 00000000..9c86cb9d
--- /dev/null
+++ b/src/vote/submitted/base64url.ts
@@ -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, '')
+}
diff --git a/src/vote/vote-state.ts b/src/vote/vote-state.ts
index 4a9b27df..fa8b472f 100644
--- a/src/vote/vote-state.ts
+++ b/src/vote/vote-state.ts
@@ -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'