mirror of
https://github.com/siv-org/siv.git
synced 2026-01-09 10:27:57 -05:00
/malware-check: Decrypt vote on 2nd-device w/ randomizers
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) => {
|
||||
|
||||
21
pages/api/election/[election_id]/encrypted-vote.ts
Normal file
21
pages/api/election/[election_id]/encrypted-vote.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { firebase } from 'api/_services'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { CipherStrings } from 'src/crypto/stringify-shuffle'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { auth_token, election_id } = req.query
|
||||
|
||||
if (typeof election_id !== 'string') return res.status(400).json({ error: 'Missing election_id' })
|
||||
if (typeof auth_token !== 'string') return res.status(400).json({ error: 'Missing auth_token' })
|
||||
|
||||
const electionDoc = firebase.firestore().collection('elections').doc(election_id)
|
||||
|
||||
// Fetch stored vote from database
|
||||
const votes = await electionDoc.collection('votes').where('auth', '==', auth_token).get()
|
||||
if (votes.empty) return res.status(404).json({ error: 'Vote not found' })
|
||||
|
||||
const storedVote = votes.docs[0].data()
|
||||
const storedEncryptedVote = storedVote.encrypted_vote as Record<string, CipherStrings>
|
||||
|
||||
return res.status(200).json({ encrypted_vote: storedEncryptedVote })
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { firestore } from 'firebase-admin'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { CipherStrings } from 'src/crypto/stringify-shuffle'
|
||||
|
||||
// import { firebase, pushover } from './_services'
|
||||
import { firebase } from './_services'
|
||||
import { generateEmailLoginCode } from './admin-login'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
|
||||
|
||||
const { auth_token, confirmed, election_id, issue_description, recalculated_encrypted_vote } = req.body
|
||||
const { auth_token, confirmed, decrypted, election_id, issue_description } = 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' })
|
||||
@@ -19,51 +19,41 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (confirmed !== undefined) {
|
||||
const checkDoc = electionDoc.collection('malware-checks').doc(auth_token)
|
||||
const check = await checkDoc.get()
|
||||
|
||||
if (!check.exists) return res.status(404).json({ error: 'No malware check found' })
|
||||
|
||||
await checkDoc.update({
|
||||
confirmed,
|
||||
confirmed_at: firestore.FieldValue.serverTimestamp(),
|
||||
issue_description: issue_description || null,
|
||||
confirmations: firestore.FieldValue.arrayUnion({
|
||||
confirmed,
|
||||
confirmed_at: new Date(),
|
||||
issue_description: issue_description || null,
|
||||
otp: req.body.otp || null,
|
||||
}),
|
||||
})
|
||||
|
||||
return res.status(200).json({ success: true })
|
||||
}
|
||||
|
||||
// Otherwise, this is the initial check request
|
||||
if (!recalculated_encrypted_vote) return res.status(400).json({ error: 'Missing recalculated_encrypted_vote' })
|
||||
|
||||
// Fetch stored vote from database
|
||||
const votes = await electionDoc.collection('votes').where('auth', '==', auth_token).get()
|
||||
if (votes.empty) return res.status(404).json({ error: 'Vote not found', match: false })
|
||||
|
||||
const storedVote = votes.docs[0].data()
|
||||
const storedEncryptedVote = storedVote.encrypted_vote as Record<string, CipherStrings>
|
||||
|
||||
// Compare recalculated encrypted vote with stored encrypted vote
|
||||
const match = compareEncryptedVotes(storedEncryptedVote, recalculated_encrypted_vote)
|
||||
|
||||
// Get device info
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown'
|
||||
const deviceInfo = parseUserAgent(userAgent)
|
||||
// Generate a 6-digit OTP to match the device against later.
|
||||
const otp = generateEmailLoginCode()
|
||||
|
||||
// Store device info in malware-checks subcollection
|
||||
const checkData = {
|
||||
auth: auth_token,
|
||||
confirmed: null as boolean | null,
|
||||
created_at: firestore.FieldValue.serverTimestamp(),
|
||||
device_info: {
|
||||
device_type: deviceInfo,
|
||||
timestamp: firestore.FieldValue.serverTimestamp(),
|
||||
user_agent: userAgent,
|
||||
},
|
||||
issue_description: null as null | string,
|
||||
match,
|
||||
recalculated_vote: recalculated_encrypted_vote,
|
||||
}
|
||||
|
||||
await electionDoc.collection('malware-checks').doc(auth_token).set(checkData)
|
||||
await electionDoc
|
||||
.collection('malware-checks')
|
||||
.doc(auth_token)
|
||||
.set(
|
||||
{
|
||||
auth: auth_token,
|
||||
decrypted: firestore.FieldValue.arrayUnion({
|
||||
created_at: new Date(),
|
||||
decrypted,
|
||||
device_info: req.headers['user-agent'] || null,
|
||||
otp,
|
||||
}),
|
||||
},
|
||||
{ merge: true },
|
||||
)
|
||||
|
||||
// Send Pushover notification to admin on mismatch
|
||||
// if (!match)
|
||||
@@ -72,53 +62,5 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// `election: ${election_id}\nauth: ${auth_token}\ndevice: ${deviceInfo}\nVoter's recalculated vote does not match the stored vote.`,
|
||||
// )
|
||||
|
||||
return res.status(200).json({
|
||||
device_info: deviceInfo,
|
||||
match,
|
||||
})
|
||||
}
|
||||
|
||||
function compareEncryptedVotes(
|
||||
stored: Record<string, CipherStrings>,
|
||||
recalculated: Record<string, CipherStrings>,
|
||||
): boolean {
|
||||
// Check if all keys match
|
||||
const storedKeys = Object.keys(stored).sort()
|
||||
const recalculatedKeys = Object.keys(recalculated).sort()
|
||||
|
||||
if (storedKeys.length !== recalculatedKeys.length) return false
|
||||
|
||||
// Compare each cipher
|
||||
for (const key of storedKeys) {
|
||||
if (!recalculated[key]) return false
|
||||
|
||||
const storedCipher = stored[key]
|
||||
const recalculatedCipher = recalculated[key]
|
||||
|
||||
// Compare encrypted and lock values
|
||||
if (storedCipher.encrypted !== recalculatedCipher.encrypted) return false
|
||||
if (storedCipher.lock !== recalculatedCipher.lock) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgent: string): string {
|
||||
// Simple user agent parsing for device type
|
||||
const ua = userAgent.toLowerCase()
|
||||
|
||||
if (ua.includes('iphone')) return 'iPhone'
|
||||
if (ua.includes('ipad')) return 'iPad'
|
||||
if (ua.includes('android')) return 'Android'
|
||||
if (ua.includes('windows')) return 'Windows'
|
||||
if (ua.includes('mac')) return 'Mac'
|
||||
if (ua.includes('linux')) return 'Linux'
|
||||
|
||||
// Extract browser info
|
||||
if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari'
|
||||
if (ua.includes('chrome')) return 'Chrome'
|
||||
if (ua.includes('firefox')) return 'Firefox'
|
||||
if (ua.includes('edge')) return 'Edge'
|
||||
|
||||
return 'Unknown Device'
|
||||
return res.status(200).json({ otp })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from 'src/api-helper'
|
||||
import { RP, stringToPoint } from 'src/crypto/curve'
|
||||
import encrypt from 'src/crypto/encrypt'
|
||||
import { pointToString, RP } from 'src/crypto/curve'
|
||||
import { decryptWithRandomizer } from 'src/crypto/decrypt'
|
||||
import { CipherStrings } from 'src/crypto/stringify-shuffle'
|
||||
import { Head } from 'src/Head'
|
||||
import { unTruncateSelection } from 'src/status/un-truncate-selection'
|
||||
@@ -10,18 +10,15 @@ 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 { decodePlaintext } from 'src/vote/submitted/encodePlaintext'
|
||||
|
||||
type CheckResult = {
|
||||
device_info?: string
|
||||
error?: string
|
||||
match: boolean
|
||||
otp?: string
|
||||
}
|
||||
|
||||
type VoteData = {
|
||||
decryptedPlaintext: Record<string, string> // questionId: plaintext
|
||||
randomizers: Record<string, string> // questionId -> randomizer (base64url)
|
||||
selections: Record<string, string> // questionId -> plaintext
|
||||
verificationNumber: string
|
||||
}
|
||||
|
||||
export function MalwareCheckPage() {
|
||||
@@ -44,108 +41,102 @@ function PageContent() {
|
||||
const [showIssueForm, setShowIssueForm] = useState(false)
|
||||
const { ballot_design, threshold_public_key } = useElectionInfo()
|
||||
|
||||
// Extract vote data from URL hash on page load
|
||||
// Extract randomizers from URL hash on page load
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !election_id || !auth_token) return
|
||||
|
||||
const hash = window.location.hash.slice(1) // Remove #
|
||||
if (!hash) return setCheckResult({ error: 'Missing vote data in URL', match: false })
|
||||
if (!hash) return setCheckResult({ error: 'Missing vote data in URL' })
|
||||
|
||||
try {
|
||||
// Parse compact format directly (no base64url decoding needed): tracking|questionId|plaintext|randomizer|questionId|plaintext|randomizer|...
|
||||
// Parse compact format: questionId|randomizer|questionId|randomizer|...
|
||||
const parts = hash.split('|')
|
||||
if (parts.length < 1 || (parts.length - 1) % 3 !== 0) throw new Error('Invalid data format')
|
||||
if (parts.length < 2 || parts.length % 2 !== 0) throw new Error('Invalid data format')
|
||||
|
||||
// Decode verification number from base64url
|
||||
const verificationNumberBigInt = base64URLToBigint(parts[0])
|
||||
// Convert back to decimal string format, with dashes every 4 digits
|
||||
const verificationNumberString = verificationNumberBigInt.toString().padStart(12, '0')
|
||||
const verificationNumber = `${verificationNumberString.slice(0, 4)}-${verificationNumberString.slice(
|
||||
4,
|
||||
8,
|
||||
)}-${verificationNumberString.slice(8, 12)}`
|
||||
const selections: Record<string, string> = {}
|
||||
const randomizers: Record<string, string> = {}
|
||||
|
||||
// Process in groups of 3: questionId, encodedPlaintext, randomizer
|
||||
// Note: We'll decode plaintext later when we have ballot_design
|
||||
for (let i = 1; i < parts.length; i += 3) {
|
||||
// Process in pairs: questionId, randomizer
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
const questionId = parts[i]
|
||||
const encodedPlaintext = parts[i + 1]
|
||||
const randomizer = parts[i + 2]
|
||||
selections[questionId] = encodedPlaintext
|
||||
const randomizer = parts[i + 1]
|
||||
randomizers[questionId] = randomizer
|
||||
}
|
||||
|
||||
const data: VoteData = {
|
||||
randomizers,
|
||||
selections,
|
||||
verificationNumber,
|
||||
}
|
||||
const data: VoteData = { decryptedPlaintext: {}, randomizers }
|
||||
setVoteData(data)
|
||||
|
||||
// Move private data into document memory and clear URL
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
} catch (_error) {
|
||||
setCheckResult({ error: 'Failed to parse vote data', match: false })
|
||||
setCheckResult({ error: 'Failed to parse vote data' })
|
||||
console.error('Failed to parse vote data', _error)
|
||||
}
|
||||
}, [election_id, auth_token])
|
||||
|
||||
// Recalculate encrypted vote and send to server
|
||||
// Fetch encrypted vote, try to decrypt with randomizers, and send success/fail to server
|
||||
useEffect(() => {
|
||||
if (!voteData || !threshold_public_key || checkResult) return
|
||||
;(async () => {
|
||||
if (!ballot_design) return setCheckResult({ error: 'Missing ballot design', match: false })
|
||||
|
||||
try {
|
||||
if (!threshold_public_key) return setCheckResult({ error: 'Missing election public key', match: false })
|
||||
if (!threshold_public_key) return setCheckResult({ error: 'Missing election public key' })
|
||||
|
||||
const public_key = RP.fromHex(threshold_public_key)
|
||||
const recalculated: Record<string, CipherStrings> = {}
|
||||
const { randomizers } = voteData
|
||||
|
||||
const { randomizers, selections, verificationNumber } = voteData
|
||||
if (!randomizers || Object.keys(randomizers).length === 0)
|
||||
return setCheckResult({ error: 'Invalid vote data format' })
|
||||
|
||||
if (!verificationNumber || !selections || !randomizers)
|
||||
return setCheckResult({ error: 'Invalid vote data format', match: false })
|
||||
// Fetch encrypted vote from server
|
||||
const encryptedVoteResponse = await api(`/election/${election_id}/encrypted-vote?auth_token=${auth_token}`)
|
||||
if (!encryptedVoteResponse.ok) return setCheckResult({ error: 'Failed to fetch encrypted vote' })
|
||||
|
||||
// Recalculate encryption for each selection
|
||||
for (const questionId of Object.keys(selections)) {
|
||||
// Decode plaintext from compact format (i{index}, b, w:{text}, or as-is)
|
||||
const encodedPlaintext = selections[questionId]
|
||||
const plaintext = decodePlaintext(encodedPlaintext, questionId, ballot_design)
|
||||
const { encrypted_vote } = (await encryptedVoteResponse.json()) as {
|
||||
encrypted_vote: Record<string, CipherStrings>
|
||||
}
|
||||
|
||||
// Decrypt each cipher using the randomizer
|
||||
const decryptedPlaintext: Record<string, string> = {}
|
||||
|
||||
for (const questionId of Object.keys(randomizers)) {
|
||||
const cipher = encrypted_vote[questionId]
|
||||
if (!cipher) return setCheckResult({ error: `Missing encrypted vote for question: ${questionId}` })
|
||||
|
||||
// Convert base64url encoded randomizer back to bigint
|
||||
const randomizerBigInt = base64URLToBigint(randomizers[questionId])
|
||||
|
||||
// Encode: stringToPoint(`${tracking}:${plaintext}`)
|
||||
const encoded = stringToPoint(`${verificationNumber}:${plaintext}`)
|
||||
// Decrypt using randomizer and public key
|
||||
const decryptedPoint = decryptWithRandomizer(public_key, randomizerBigInt, {
|
||||
encrypted: RP.fromHex(cipher.encrypted),
|
||||
lock: RP.fromHex(cipher.lock),
|
||||
})
|
||||
|
||||
// Encrypt: encrypt(public_key, randomizer, encoded)
|
||||
const cipher = encrypt(public_key, randomizerBigInt, encoded)
|
||||
// Extract plaintext from point (format: "verification_number:plaintext")
|
||||
const decodedString = pointToString(decryptedPoint)
|
||||
const colonIndex = decodedString.indexOf(':')
|
||||
if (colonIndex === -1) return setCheckResult({ error: `Invalid decoded format for question: ${questionId}` })
|
||||
|
||||
// Convert to CipherStrings format
|
||||
recalculated[questionId] = {
|
||||
encrypted: String(cipher.encrypted),
|
||||
lock: String(cipher.lock),
|
||||
}
|
||||
const plaintext = decodedString.slice(colonIndex + 1)
|
||||
decryptedPlaintext[questionId] = plaintext
|
||||
}
|
||||
|
||||
// Send to API endpoint
|
||||
// Update voteData with decrypted plaintext
|
||||
setVoteData({ ...voteData, decryptedPlaintext })
|
||||
|
||||
// Let server know we were able to decrypt the vote
|
||||
const response = await api('/malware-check', {
|
||||
auth_token,
|
||||
decrypted: true,
|
||||
election_id,
|
||||
recalculated_encrypted_vote: recalculated,
|
||||
})
|
||||
|
||||
const result: CheckResult = await response.json()
|
||||
setCheckResult(result)
|
||||
} catch (error) {
|
||||
setCheckResult({ error: 'Failed to recalculate or verify vote', match: false })
|
||||
console.error('Failed to recalculate or verify vote', error)
|
||||
setCheckResult({ error: 'Failed to decrypt or verify vote' })
|
||||
console.error('Failed to decrypt or verify vote', error)
|
||||
}
|
||||
})()
|
||||
}, [voteData, ballot_design, threshold_public_key, election_id, auth_token, checkResult])
|
||||
}, [voteData, threshold_public_key, election_id, auth_token, checkResult])
|
||||
|
||||
const columns = useMemo(() => generateColumnNames({ ballot_design }).columns, [ballot_design])
|
||||
|
||||
@@ -153,7 +144,7 @@ function PageContent() {
|
||||
if (!confirmed) return setShowIssueForm(true)
|
||||
|
||||
try {
|
||||
const response = await api('/api/malware-check', {
|
||||
const response = await api('/malware-check', {
|
||||
auth_token,
|
||||
confirmed: true,
|
||||
election_id,
|
||||
@@ -168,7 +159,7 @@ function PageContent() {
|
||||
|
||||
const handleSubmitIssue = async () => {
|
||||
try {
|
||||
const response = await api('/api/malware-check', {
|
||||
const response = await api('/malware-check', {
|
||||
auth_token,
|
||||
confirmed: false,
|
||||
election_id,
|
||||
@@ -192,6 +183,14 @@ function PageContent() {
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!voteData.decryptedPlaintext || Object.keys(voteData.decryptedPlaintext).length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p>Decrypting vote...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (checkResult?.error)
|
||||
return (
|
||||
<div className="p-8">
|
||||
@@ -215,7 +214,7 @@ function PageContent() {
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!checkResult.match)
|
||||
if (!checkResult.otp)
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h2 className="mb-4 font-bold text-red-600">⚠️ Vote Mismatch Detected</h2>
|
||||
@@ -244,11 +243,9 @@ function PageContent() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((columnId) => {
|
||||
const encodedPlaintext = voteData.selections?.[columnId]
|
||||
if (!encodedPlaintext) return null
|
||||
const plaintext = voteData.decryptedPlaintext?.[columnId]
|
||||
if (!plaintext) return null
|
||||
|
||||
// Decode plaintext from compact format
|
||||
const plaintext = decodePlaintext(encodedPlaintext, columnId, ballot_design || [])
|
||||
const displayValue = unTruncateSelection(plaintext, ballot_design || [], columnId)
|
||||
|
||||
// Find question title from ballot design
|
||||
@@ -305,8 +302,6 @@ function PageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResult.device_info && <p className="mt-4 text-sm text-gray-600">Device: {checkResult.device_info}</p>}
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
<strong>Note:</strong> You may be prompted to confirm via SMS to ensure that a live person is performing this
|
||||
2nd device check, rather than malware from the original device. If you were given an Anti-Malware Code, please
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo, useState } from 'react'
|
||||
|
||||
import { State } from '../vote-state'
|
||||
import { bigintToBase64URL } from './base64url'
|
||||
import { encodePlaintext } from './encodePlaintext'
|
||||
import { MalwareCheckQRCode } from './MalwareCheckQRCode'
|
||||
|
||||
type CheckStatus = {
|
||||
@@ -56,27 +55,22 @@ export const MalwareCheck = ({
|
||||
// }, [isOpen, election_id, auth])
|
||||
|
||||
const qrUrl = useMemo(() => {
|
||||
if (!state.tracking || !state.plaintext || !state.randomizer || !state.ballot_design) return ''
|
||||
if (!state.randomizer) return ''
|
||||
|
||||
// Build compact format: tracking|questionId|plaintext|randomizer|questionId|plaintext|randomizer|...
|
||||
// Using | as delimiter since it's unlikely to appear in plaintext or question IDs
|
||||
// Build compact format: questionId|randomizer|questionId|randomizer|...
|
||||
// Using | as delimiter since it's unlikely to appear in question IDs
|
||||
// All characters are URL-safe, so no need for base64url encoding
|
||||
// Verification number encoded as base64url for compactness
|
||||
const trackingNum = BigInt(state.tracking.replace(/-/g, ''))
|
||||
const parts: string[] = [bigintToBase64URL(trackingNum)]
|
||||
const parts: string[] = []
|
||||
|
||||
for (const questionId of Object.keys(state.plaintext)) {
|
||||
for (const questionId of Object.keys(state.randomizer)) {
|
||||
parts.push(questionId)
|
||||
// Encode plaintext compactly (index for options, w: prefix for write-ins, b for BLANK)
|
||||
const encodedPlaintext = encodePlaintext(state.plaintext[questionId], questionId, state.ballot_design)
|
||||
parts.push(encodedPlaintext)
|
||||
parts.push(bigintToBase64URL(BigInt(state.randomizer[questionId])))
|
||||
}
|
||||
|
||||
// Use compact format directly (no base64url encoding needed - all chars are URL-safe)
|
||||
const encodedData = parts.join('|')
|
||||
|
||||
// Build URL: siv.org/malware-check/$election_id/$auth_token/#url_encoded_vote_data
|
||||
// Build URL: siv.org/malware-check/$election_id/$auth_token/#randomizers
|
||||
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}#${encodedData}`
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { multi_vote_regex } from '../../status/tally-votes'
|
||||
import { Item } from '../storeElectionInfo'
|
||||
|
||||
/** Decode encoded plaintext back to original selection */
|
||||
export function decodePlaintext(encoded: string, questionId: string, ballotDesign: Item[]): string {
|
||||
if (encoded === 'b') return 'BLANK'
|
||||
|
||||
// If it's a write-in, remove "w:" prefix
|
||||
if (encoded.startsWith('w:')) return encoded.slice(2)
|
||||
|
||||
if (!ballotDesign) return 'MissingBallotDesign'
|
||||
|
||||
// If it's an indexed option, consult ballot design
|
||||
if (encoded.startsWith('i')) {
|
||||
const index = Number.parseInt(encoded.slice(1), 10)
|
||||
if (isNaN(index)) return encoded // Invalid format, return as-is
|
||||
|
||||
// Find the question
|
||||
const multiSuffix = questionId.match(multi_vote_regex)
|
||||
let baseQuestionId =
|
||||
multiSuffix && !ballotDesign.find((item) => item.id === questionId)
|
||||
? questionId.slice(0, -multiSuffix[0].length)
|
||||
: questionId
|
||||
|
||||
// For score/budget, the column format is "questionId_optionName", extract base questionId
|
||||
let question = ballotDesign.find((item) => item.id === baseQuestionId)
|
||||
if (!question) {
|
||||
// Try to find by matching prefix (for score/budget voting: "vote_Chocolate" -> "vote")
|
||||
question = ballotDesign.find((item) => {
|
||||
if (!item.id) return false
|
||||
return baseQuestionId.startsWith(item.id + '_')
|
||||
})
|
||||
if (question) baseQuestionId = question.id || baseQuestionId
|
||||
}
|
||||
|
||||
if (!question || index < 0 || index >= question.options.length) return encoded
|
||||
|
||||
const option = question.options[index]
|
||||
|
||||
// Return the value if available, otherwise the name
|
||||
return option.value || option.name
|
||||
}
|
||||
|
||||
// Otherwise, it's a score or special value, keep as-is
|
||||
return encoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode plaintext selection compactly:
|
||||
* - If matches an option: encode as "i{index}" (0-based)
|
||||
* - If "BLANK": encode as "b"
|
||||
* - Otherwise: encode as "w:{plaintext}" for write-ins, or keep as-is for scores/special values
|
||||
*/
|
||||
export function encodePlaintext(plaintext: string, questionId: string, ballotDesign: Item[]): string {
|
||||
if (plaintext === 'BLANK') return 'b'
|
||||
|
||||
if (!ballotDesign) return `w:${plaintext}`
|
||||
|
||||
// Find the question in ballot design
|
||||
// Handle multi-vote format (e.g., "president_1" -> "president")
|
||||
// Handle score/budget format (e.g., "vote_Chocolate" -> "vote")
|
||||
const multiSuffix = questionId.match(multi_vote_regex)
|
||||
let baseQuestionId =
|
||||
multiSuffix && !ballotDesign.find((item) => item.id === questionId)
|
||||
? questionId.slice(0, -multiSuffix[0].length)
|
||||
: questionId
|
||||
|
||||
// For score/budget, the column format is "questionId_optionName", extract base questionId
|
||||
let question = ballotDesign.find((item) => item.id === baseQuestionId)
|
||||
if (!question && (baseQuestionId.includes('_') || baseQuestionId.includes('-'))) {
|
||||
// Try to find by matching prefix (for score/budget voting)
|
||||
question = ballotDesign.find((item) => {
|
||||
if (!item.id) return false
|
||||
return baseQuestionId.startsWith(item.id + '_') || baseQuestionId.startsWith(item.id + '-')
|
||||
})
|
||||
if (question) baseQuestionId = question.id || baseQuestionId
|
||||
}
|
||||
|
||||
if (!question) return `w:${plaintext}`
|
||||
|
||||
// For score/budget voting, numeric strings are valid and should be kept as-is
|
||||
if ((question.type === 'score' || question.type === 'budget') && /^\d+$/.test(plaintext)) return plaintext
|
||||
|
||||
// Check if plaintext matches any option (check both value and name)
|
||||
// Use original order (not shuffled)
|
||||
const optionIndex = question.options.findIndex((option) => {
|
||||
// Check if plaintext matches the option value or name
|
||||
return (option.value || option.name) === plaintext
|
||||
})
|
||||
if (optionIndex >= 0) return `i${optionIndex}`
|
||||
|
||||
// Otherwise, treat as write-in
|
||||
return `w:${plaintext}`
|
||||
}
|
||||
@@ -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