Merge branch 'main' into HEAD

This commit is contained in:
David Ernst
2024-08-17 19:36:12 -07:00
64 changed files with 1727 additions and 720 deletions

View File

@@ -5,6 +5,12 @@
JWT_SECRET=ReplaceWithYourOwnBigLongRandomStringHere
ADMIN_EMAIL=
# SIV blocks sending voter-invites emails with `localhost` links.
# They won't work for voters! Very embarassing when you accidentally
# send hundreds of them in a rushed election moment.
# But you can (optionally) add your own email (or domain) here, for local testing
ACCEPTS_LOCALHOST_EMAILS = "you@email.com" # or "yourdomain.com"
# Firebase - https://firebase.google.com - database
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=

10
.github/ISSUE_TEMPLATE/new-issue.md vendored Normal file
View File

@@ -0,0 +1,10 @@
---
name: New issue
about: Report an issue about SIV
title: ''
labels: ''
assignees: ''
---
<!-- Reminder: Title issues as issues, not a feature or solution to the issue. -->

View File

@@ -31,44 +31,10 @@ See the [SIV Public License](/LICENSE) for further details.
### Initial set up
1. Duplicate the file `.env.local.TEMPLATE` into `.env.local`
2. Create (free) accounts with the providers listed in the `.env.local` file and add your new API keys as described below:
**Firebase**:
1. Visit [Firebase](https://firebase.google.com/) and sign in with your Google account
2. Click on "Get Started" and create a new project
3. Navigate to the project settings (cog icon) and go to "Project setting" -> "Service accounts"
4. Generate a new private key for the Firebase Admin SDK and download the JSON file
5. Extract the following values from the JSON file and add them to your `.env.local`:
* `FIREBASE_CLIENT_EMAIL`: Found in the "client_email" field
* `FIREBASE_PRIVATE_KEY`: Found in the "private_key" field
* Note: Ensure this key is enclosed in double quotes
* `FIREBASE_PROJECT_ID`: Found in the "project_id" field
**Supabase**:
1. Visit [Supabase](https://supabase.com/) and sign up for an account
2. Create a new project and note down the project URL and the public API key
3. Go to the project settings and add the following values to your `.env.local`:
* `SUPABASE_ADMIN_KEY`: Found in "API" -> "Project API keys" under "anon public"
* `SUPABASE_DB_URL`: Found in "Database" -> "Connection parameters" under "Host"
* Note: Be sure to add "https://" to the start of the address
* `SUPABASE_JWT_SECRET`: Found in "API" -> "JWT Settings" under "JWT Secret"
**Mailgun**:
1. Visit [Mailgun](https://mailgun.com/) and sign up for a free account
2. Use the default sandbox domain provided by Mailgun for testing purposes.
3. Click on your account menu (top-right)
* Go to "Mailgun API keys" and hit "Add new key"
* Give the key a description such as "SIV"
* Copy the new API key and add it to `.env.local` under `MAILGUN_API_KEY`
4. Go back to the Dashboard and click on the sandbox account under "Sending domains"
* Note: the domain will look like `sandbox<XXXX>.mailgun.org`
* Copy this domain and add it to `.env.local` under `MAILGUN_DOMAIN`
5. Add "Authorized Recipients" email addresses on the right that you will test with
6. Now test that everything is set up correctly using the appropriate command provided in the API panel
3. Install local dependencies:
1. Fork the repo
2. Duplicate the file `.env.local.TEMPLATE` into `.env.local`
3. Create (free) accounts with the providers listed in that file, adding your new API keys. [These detailed instructions](/cloud-services.md) can help if stuck.
4. Install local dependencies:
```bash
npm install

39
cloud-services.md Normal file
View File

@@ -0,0 +1,39 @@
# Tips For Getting API Keys for Cloud Services
Here are more detailed instructions for how to get API keys for the cloud services needed for your `.env.local` file:
**Firebase**:
1. Visit [Firebase](https://firebase.google.com/) and sign in with your Google account
2. Click on "Get Started" and create a new project
3. Navigate to the project settings (cog icon) and go to "Project setting" -> "Service accounts"
4. Generate a new private key for the Firebase Admin SDK and download the JSON file
5. Extract the following values from the JSON file and add them to your `.env.local`:
- `FIREBASE_CLIENT_EMAIL`: Found in the "client_email" field
- `FIREBASE_PRIVATE_KEY`: Found in the "private_key" field
- Note: Ensure this key is enclosed in double quotes
- `FIREBASE_PROJECT_ID`: Found in the "project_id" field
**Supabase**:
1. Visit [Supabase](https://supabase.com/) and sign up for an account
2. Create a new project and note down the project URL and the public API key
3. Go to the project settings and add the following values to your `.env.local`:
- `SUPABASE_ADMIN_KEY`: Found in "API" -> "Project API keys" under "anon public"
- `SUPABASE_DB_URL`: Found in "Database" -> "Connection parameters" under "Host"
- Note: Be sure to add "https://" to the start of the address
- `SUPABASE_JWT_SECRET`: Found in "API" -> "JWT Settings" under "JWT Secret"
**Mailgun**:
1. Visit [Mailgun](https://mailgun.com/) and sign up for a free account
2. Use the default sandbox domain provided by Mailgun for testing purposes.
3. Click on your account menu (top-right)
- Go to "Mailgun API keys" and hit "Add new key"
- Give the key a description such as "SIV"
- Copy the new API key and add it to `.env.local` under `MAILGUN_API_KEY`
4. Go back to the Dashboard and click on the sandbox account under "Sending domains"
- Note: the domain will look like `sandbox<XXXX>.mailgun.org`
- Copy this domain and add it to `.env.local` under `MAILGUN_DOMAIN`
5. Add "Authorized Recipients" email addresses on the right that you will test with
6. Now test that everything is set up correctly using the appropriate command provided in the API panel

View File

@@ -1,13 +1,19 @@
// For `new-democratic-primary`, we want to script to copy:
// - current 136 encrypted votes (in `votes-pending` collection)
// - current trustees list
// - other fields relevant to decentralized shuffle + unlock
// - Other trustees, in their local storage, also need to copy their private keys to the new election_id
// See function `cloneTrusteeDetails` at bottom
import './_env'
import { pick } from 'lodash'
import { firebase } from '../pages/api/_services'
const election_id_from = '1721122924218'
const election_id_to = '1721314324882'
const election_id_to = '1722034716600'
async function copySubcollection(
sourceDoc: FirebaseFirestore.DocumentReference,
@@ -18,17 +24,23 @@ async function copySubcollection(
const sourceSubcollection = sourceDoc.collection(subcollectionName)
const targetSubcollection = targetDoc.collection(subcollectionName)
const snapshot = await sourceSubcollection.get()
const docs = snapshot.docs
const amount = docs.length
const { docs } = await sourceSubcollection.get()
let progress = 0
const intervalToReport = 20
for (const doc of docs) {
await targetSubcollection.doc(doc.id).set(doc.data())
progress++
if (progress % intervalToReport === 0)
console.log(`${subcollectionName}: ${progress}/${amount} - ${((progress / amount) * 100).toFixed(0)}%`)
// Use firestore batched writes
const batchLimit = 500 // max operations per batch
for (let i = 0; i < docs.length; i += batchLimit) {
const batch: FirebaseFirestore.WriteBatch = sourceDoc.firestore.batch()
const batchDocs = docs.slice(i, i + batchLimit)
for (const doc of batchDocs) {
batch.set(targetSubcollection.doc(doc.id), doc.data())
}
await batch.commit()
progress += batchDocs.length
console.log(`${subcollectionName}: ${progress}/${docs.length} - ${((progress / docs.length) * 100).toFixed(0)}%`)
}
console.log('Finished copy: ' + subcollectionName)
@@ -40,6 +52,40 @@ async function copySubcollection(
const electionFromDoc = db.collection('elections').doc(election_id_from)
const electionToDoc = db.collection('elections').doc(election_id_to)
// Copy subcollections
await copySubcollection(electionFromDoc, electionToDoc, 'votes-pending')
await copySubcollection(electionFromDoc, electionToDoc, 'trustees')
// Copy other relevant fields
const fromElectionData = await electionFromDoc.get()
const fieldsToCopy = pick(fromElectionData.data(), [
'num_votes', // not strictly necessary but led to some graphical glitches
'ballot_design', // for shuffle
't', // for admin to know when to decrypt
'threshold_public_key', // for shuffle
])
await electionToDoc.update(fieldsToCopy)
})()
/** Clone localStorage trustee details
Useful for making a subset of votes to shuffle separately from main election */
export function cloneTrusteeDetails(from_election_id, to_election_id) {
let matches = 0
Object.entries(localStorage).forEach(([key, value]) => {
// Only care about from_election_id 'observer' entries
if (!key.includes(`observer-${from_election_id}-`)) return
// Update state.election_id field
const state = JSON.parse(value)
state.election_id = to_election_id
// Update key with to_election_id
const new_key = key.replace(from_election_id, to_election_id)
// Save to localStorage
localStorage.setItem(new_key, JSON.stringify(state))
console.log(`${++matches}. Cloned ${key} to ${to_election_id}`)
})
if (!matches) return console.warn('No localStorage matches for from_election_id: ' + from_election_id)
}

View File

@@ -5,7 +5,7 @@ import './_env'
import { firebase } from '../pages/api/_services'
const election_id = '1721314324882' // NAP Day 1 Results, All Submitted Votes
const election_id = '1722034716600' // NDP, Final, All 230 Votes
async function renameSubcollection(
parentDoc: FirebaseFirestore.DocumentReference,

View File

@@ -0,0 +1,123 @@
// For `new-democratic-primary`, we want to script to copy:
// - current 136 encrypted votes (in `votes-pending` collection)
// - current trustees list
// - other fields relevant to decentralized shuffle + unlock
// - Other trustees, in their local storage, also need to copy their private keys to the new election_id
// See function `cloneTrusteeDetails` at bottom
import './_env'
import { pick } from 'lodash'
import { firebase } from '../pages/api/_services'
const election_id_from = '1721122924218'
const election_id_to = '1722029077573'
async function copySubcollection(
sourceDoc: FirebaseFirestore.DocumentReference,
targetDoc: FirebaseFirestore.DocumentReference,
subcollectionName: string,
) {
console.log('Starting copy: ' + subcollectionName)
const sourceSubcollection = sourceDoc.collection(subcollectionName)
const targetSubcollection = targetDoc.collection(subcollectionName)
const { docs } = await sourceSubcollection.get()
let progress = 0
// Use firestore batched writes
const batchLimit = 500 // max operations per batch
for (let i = 0; i < docs.length; i += batchLimit) {
const batch: FirebaseFirestore.WriteBatch = sourceDoc.firestore.batch()
const batchDocs = docs.slice(i, i + batchLimit)
for (const doc of batchDocs) {
if (doc.id === '79168f4563') console.log('Found doc 79168f4563!') // where did this 1 of 26 go?
if (subcollectionName === 'votes-pending' && !subset_to_keep.includes(doc.id)) continue // skip votes not in has_good_passport_auth subset
batch.set(targetSubcollection.doc(doc.id), doc.data())
progress++
}
await batch.commit()
console.log(`${subcollectionName}: ${progress}/${docs.length} - ${((progress / docs.length) * 100).toFixed(0)}%`)
}
console.log('Finished copy: ' + subcollectionName)
}
// eslint-disable-next-line no-extra-semi
;(async function main() {
const db = firebase.firestore()
const electionFromDoc = db.collection('elections').doc(election_id_from)
const electionToDoc = db.collection('elections').doc(election_id_to)
// Copy subcollections
await copySubcollection(electionFromDoc, electionToDoc, 'votes-pending')
await copySubcollection(electionFromDoc, electionToDoc, 'trustees')
// Copy other relevant fields
const fromElectionData = await electionFromDoc.get()
const fieldsToCopy = pick(fromElectionData.data(), [
'num_votes', // not strictly necessary but led to some graphical glitches
'ballot_design', // for shuffle
't', // for admin to know when to decrypt
'threshold_public_key', // for shuffle
])
await electionToDoc.update(fieldsToCopy)
})()
/** Clone localStorage trustee details
Useful for making a subset of votes to shuffle separately from main election */
export function cloneTrusteeDetails(from_election_id, to_election_id) {
let matches = 0
Object.entries(localStorage).forEach(([key, value]) => {
// Only care about from_election_id 'observer' entries
if (!key.includes(`observer-${from_election_id}-`)) return
// Update state.election_id field
const state = JSON.parse(value)
state.election_id = to_election_id
// Update key with to_election_id
const new_key = key.replace(from_election_id, to_election_id)
// Save to localStorage
localStorage.setItem(new_key, JSON.stringify(state))
console.log(`${++matches}. Cloned ${key} to ${to_election_id}`)
})
if (!matches) return console.warn('No localStorage matches for from_election_id: ' + from_election_id)
}
// Just the subset of votes we verified have a good passport proof
const subset_to_keep = [
'13ec850164',
'159b08430a',
'15ba012b15',
'1669afb3d7',
'18fa92d241',
'1b73c6418b',
'1be44cfdb4',
'1fd1cb693a',
'1fff356f52',
'20b8a9fa94',
'235fccb4ee',
'251abba6e5',
'27302f85e9',
'2a8248e0d8',
'2ad0de9383',
'2d49c20201',
'6758f7a2db',
'71313c9066',
'79168f4563',
'8de58d0228',
'91cba5ab38',
'9d8190bd08',
'a529c9cc2b',
'a28873bdc4',
'a5285a0ee8',
'b16dfae0b1',
]

View File

@@ -92,7 +92,7 @@
"@types/jsonwebtoken": "^8.5.1",
"@types/mailgun-js": "^0.22.10",
"@types/node": "^13.9.5",
"@types/react-linkify": "^1.0.0",
"@types/react-linkify": "^1.0.4",
"@types/react-scroll": "^1.8.2",
"@types/smoothscroll-polyfill": "^0.3.1",
"@types/ua-parser-js": "^0.7.36",
@@ -116,7 +116,7 @@
"prettier-plugin-import-sort": "^0.0.4",
"prettier-plugin-packagejson": "^2.2.5",
"tailwindcss": "^3.3.1",
"typescript": "5.4"
"typescript": "5.5"
},
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72",
"importSort": {

View File

@@ -1,3 +1,4 @@
import { validate } from 'email-validator'
import { firestore } from 'firebase-admin'
import { NextApiRequest, NextApiResponse } from 'next'
import { pick_random_bigint } from 'src/crypto/pick-random-bigint'
@@ -9,7 +10,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
// Confirm they sent a valid email address
if (!email) return res.status(400).send('Missing email')
if (!email.includes('@') || !email.includes('.')) return res.status(400).send('Malformed')
if (!validate(email)) return res.status(400).send('Malformed email')
email = email.toLowerCase()
// Is this email an approved election manager?

View File

@@ -1,4 +1,5 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { exampleAuthToken } from 'src/vote/EnterAuthToken'
import { format } from 'timeago.js'
import { firebase, pushover } from './_services'
@@ -37,13 +38,18 @@ export async function validateAuthToken(
// Is election_id in DB?
if (!(await election).exists) return fail('Unknown Election ID. It may have been deleted.')
if ((await election).data()?.stop_accepting_votes)
return fail('The election administrator has stopped accepting new votes.')
// Is there a voter w/ this Auth Token?
const [voter] = (await voters).docs
if (!voter) {
await pushover(
'SIV auth token lookup miss',
`election: ${election_id}\nbad auth: ${auth}\nPossible brute force attack?`,
)
if (auth !== exampleAuthToken)
await pushover(
'SIV auth token lookup miss',
`election: ${election_id}\nbad auth: ${auth}\nPossible brute force attack?`,
)
return fail('Invalid Auth Token.')
}

View File

@@ -18,6 +18,11 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
// Begin preloading all these docs
const loadTrustees = election.collection('trustees').orderBy('index', 'asc').get()
const loadTrusteePartials = Promise.all(
(await loadTrustees).docs.map(async (doc) =>
(await doc.ref.collection('post-election-data').doc('partials').get()).data(),
),
)
// Confirm they're a valid admin that created this election
const jwt = await checkJwtOwnsElection(req, res, election_id)
@@ -52,16 +57,17 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
}
return !waiting_on // Break out of loop when we find one
})
if (waiting_on) return res.status(206).send(waiting_on)
if (waiting_on)
return res.status(206).send(waiting_on)
// Check if any trustees haven't decrypted
trustees.every(({ email, partials }, index) => {
// Check if any trustees haven't decrypted
;(await loadTrusteePartials).every((data, index) => {
// Skip admin
if (index === 0) return true
const num_decrypted = (partials || {})[first_col]?.length || 0
const num_decrypted = (data?.partials || {})[first_col]?.length || 0
if (num_decrypted < num_admin_shuffled) {
waiting_on = email
waiting_on = trustees[index].email
}
return !waiting_on // Break out of loop when we find one
})

View File

@@ -48,6 +48,7 @@ export type AdminData = {
esignature_requested?: boolean
notified_unlocked?: number
pending_votes?: PendingVote[]
stop_accepting_votes?: boolean
threshold_public_key?: string
trustees?: Trustee[]
voter_applications_allowed?: boolean
@@ -88,6 +89,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
election_title,
esignature_requested,
notified_unlocked,
stop_accepting_votes,
threshold_public_key,
voter_applications_allowed,
} = {
@@ -99,6 +101,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
election_title?: string
esignature_requested?: boolean
notified_unlocked?: number
stop_accepting_votes?: boolean
threshold_public_key?: string
voter_applications_allowed?: boolean
}
@@ -221,6 +224,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
esignature_requested,
notified_unlocked,
pending_votes,
stop_accepting_votes,
threshold_public_key,
trustees,
voter_applications_allowed,

View File

@@ -0,0 +1,18 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { firebase } from '../../../_services'
import { checkJwtOwnsElection } from '../../../validate-admin-jwt'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { stop_accepting_votes } = req.body
const { election_id } = req.query as { election_id: string }
// Confirm they're a valid admin that created this election
const jwt = await checkJwtOwnsElection(req, res, election_id)
if (!jwt.valid) return
// Store in db
await firebase.firestore().collection('elections').doc(election_id).update({ stop_accepting_votes })
return res.status(201).json({ message: 'Saved stop_accepting_votes' })
}

View File

@@ -3,8 +3,8 @@ import { mapValues } from 'lodash-es'
import { NextApiRequest, NextApiResponse } from 'next'
import { getStatus } from 'src/admin/Voters/Signature'
import { RP } from 'src/crypto/curve'
import { fastShuffle, shuffle } from 'src/crypto/shuffle'
import { CipherStrings, stringifyShuffle } from 'src/crypto/stringify-shuffle'
import { fastShuffle, shuffleWithProof, shuffleWithoutProof } from 'src/crypto/shuffle'
import { CipherStrings, stringifyShuffle, stringifyShuffleWithoutProof } from 'src/crypto/stringify-shuffle'
import { firebase, pushover } from '../../../_services'
import { pusher } from '../../../pusher'
@@ -31,6 +31,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
elapsed('init')
const { election_id } = req.query as { election_id: string }
const { options = {} } = req.body
const { skip_shuffle_proofs } = options
// Confirm they're a valid admin that created this election
const jwt = await checkJwtOwnsElection(req, res, election_id)
@@ -60,7 +62,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
threshold_public_key: string
}
elapsed('election data')
if (!threshold_public_key) return res.status(400).json({ error: 'election missing `threshold_public_key`' })
if (!threshold_public_key) return res.status(400).json({ error: 'Election missing `threshold_public_key`' })
// If esignature_requested, filter for only approved
let votes_to_unlock = (await loadVotes).docs
@@ -153,20 +155,30 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
).toFixed(2)} ms/ciphertext)`,
)
} else {
console.log('starting admin shuffle')
// Then admin does a SIV shuffle (permute + re-encryption) for each item's list
const shuffled = await bluebird.props(
mapValues(split, async (list) =>
stringifyShuffle(
await shuffle(
RP.fromHex(threshold_public_key),
list.map((row) => mapValues(row, RP.fromHex)),
),
),
),
mapValues(split, async (list) => {
const shuffleArgs: Parameters<typeof shuffleWithProof> = [
RP.fromHex(threshold_public_key!),
list.map((row) => mapValues(row, RP.fromHex)),
]
if (skip_shuffle_proofs) {
return stringifyShuffleWithoutProof(await shuffleWithoutProof(...shuffleArgs))
} else {
return stringifyShuffle(await shuffleWithProof(...shuffleArgs))
}
}),
)
// Store admins shuffled lists
await adminDoc.update({ preshuffled: split, shuffled })
console.log("starting to write admin's shuffle to db\n\n\n\n\n")
await Promise.all([
electionDoc.update({ skip_shuffle_proofs: !!skip_shuffle_proofs }),
adminDoc.update({ preshuffled: split, shuffled }),
])
console.log("succeeded to write admin's shuffle.")
try {
await pusher.trigger(`keygen-${election_id}`, 'update', {
'admin@secureintervoting.org': { shuffled: shuffled.length },
@@ -176,7 +188,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
}
}
res.status(201).json({ message: 'Triggered unlock' })
return res.status(201).json({ message: 'Triggered unlock' })
}
/** Recombine the columns back together via tracking numbers */

View File

@@ -13,6 +13,8 @@ export type ElectionInfo = {
last_decrypted_at?: Date
observers?: string[]
p?: string
privacy_protectors_statements?: string
skip_shuffle_proofs?: boolean
submission_confirmation?: string
threshold_public_key?: string
voter_applications_allowed?: boolean
@@ -51,6 +53,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
g,
last_decrypted_at,
p,
privacy_protectors_statements,
skip_shuffle_proofs,
submission_confirmation,
threshold_public_key,
voter_applications_allowed,
@@ -67,6 +71,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
last_decrypted_at: last_decrypted_at ? new Date(last_decrypted_at._seconds * 1000) : undefined,
observers,
p,
privacy_protectors_statements,
skip_shuffle_proofs,
submission_confirmation,
threshold_public_key,
voter_applications_allowed,

View File

@@ -25,12 +25,10 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
const t = { ...doc.data() }.t
// Grab trustees
const trustees = (await loadTrustees).docs.map((doc) => {
const prepTrustees = (await loadTrustees).docs.map(async (doc) => {
const data = { ...doc.data() }
// Add you: true if requester's own document
if (data.auth_token === auth) {
data.you = true
}
if (data.auth_token === auth) data.you = true
// Fields to keep
const public_fields = [
@@ -40,7 +38,6 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
'index',
'name',
'partial_decryption',
'partials',
'preshuffled',
'recipient_key',
'shuffled',
@@ -50,11 +47,16 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
const public_data = pick(data, public_fields)
// Get partials from separate sub-doc
const partials = await doc.ref.collection('post-election-data').doc('partials').get()
if (partials.exists) public_data.partials = { ...partials.data() }.partials
// Convert commas back into dots
const decommafied = transform_email_keys(public_data, 'decommafy')
return sortObject(decommafied) as Trustee
})
const trustees = await Promise.all(prepTrustees)
const response: TrusteesLatest = { t, trustees }

View File

@@ -0,0 +1,202 @@
import { firebase } from 'api/_services'
import { pusher } from 'api/pusher'
import bluebird from 'bluebird'
import { NextApiRequest, NextApiResponse } from 'next'
import { RP, pointToString } from 'src/crypto/curve'
import { destringifyPartial, stringifyPartial } from 'src/crypto/stringify-partials'
import {
combine_partials,
compute_g_to_keyshare,
generate_partial_decryption_proof,
partial_decrypt,
unlock_message_with_shared_secret,
verify_partial_decryption_proof,
} from 'src/crypto/threshold-keygen'
import { Partial, Shuffled, State } from 'src/trustee/trustee-state'
import { mapValues } from 'src/utils'
import { recombine_decrypteds } from '../admin/unlock'
const { ADMIN_EMAIL } = process.env
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (!ADMIN_EMAIL) return res.status(501).send('Missing process.env.ADMIN_EMAIL')
const { election_id } = req.query
if (typeof election_id !== 'string') return res.status(400).send('Malformed election_id')
const electionDoc = firebase.firestore().collection('elections').doc(election_id)
// Begin preloading these docs
const adminDoc = electionDoc.collection('trustees').doc(ADMIN_EMAIL)
const adminPartials = adminDoc.collection('post-election-data').doc('partials').get()
const trusteesDocs = (await electionDoc.collection('trustees').orderBy('index').get()).docs
const trustees = trusteesDocs.map((doc) => ({ ...doc.data() }))
const loadTrusteePartials = trusteesDocs.map(async (doc) =>
(await doc.ref.collection('post-election-data').doc('partials').get()).data(),
)
// Is election_id in DB?
const election = await electionDoc.get()
if (!election.exists) return res.status(400).send('Unknown Election ID.')
const promises: Promise<unknown>[] = []
// Get admin's private data
const admin = trustees[0] as Required<State> & { decryption_key: string }
const { private_keyshare } = admin
// Get election parameters
const parameters = { ...election.data() }
// If all have now shuffled, admin can partially decrypt
const have_all_shuffled = trustees.every((trustee) => 'shuffled' in trustee)
if (have_all_shuffled) {
// Did admin upload enough partials?
const last_shuffled = trustees[trustees.length - 1].shuffled as Shuffled
const columns = Object.keys(last_shuffled)
const last_shuffled_length = last_shuffled[columns[0]].shuffled.length
const admin_partials_uploaded = (await adminPartials).data()?.partials?.[columns[0]].length
const admin_uploaded_all_partials = admin_partials_uploaded >= last_shuffled_length
console.log({ admin_partials_uploaded, admin_uploaded_all_partials, last_shuffled_length })
if (!admin_uploaded_all_partials) {
// Confirm that every column's shuffle proof is valid
const { shuffled } = trustees[parameters.t - 1]
// const checks = await bluebird.map(Object.keys(shuffled), (column) => {
const checks = await bluebird.map(Object.keys(shuffled), () => {
return true
// const { shuffled: prevShuffle } = destringifyShuffle(trustees[trustee.index - 1].shuffled[column])
// const { proof, shuffled: currShuffle } = destringifyShuffle(shuffled[column])
// return verify_shuffle_proof(rename_to_c1_and_2(prevShuffle), rename_to_c1_and_2(currShuffle), proof)
})
if (!checks.length || !checks.every((x) => x)) {
console.log("Final shuffle proof didn't fully pass")
} else {
console.log('Beginning to generate partials')
// Partially decrypt each item in every list
const partials = await bluebird.reduce(
Object.keys(shuffled),
(acc: Record<string, Partial[]>, column) =>
bluebird.props({
...acc,
[column]: bluebird.map((shuffled as Shuffled)[column].shuffled, async ({ lock }) => ({
partial: partial_decrypt(RP.fromHex(lock), BigInt(private_keyshare)).toHex(),
proof: stringifyPartial(
await generate_partial_decryption_proof(RP.fromHex(lock), BigInt(private_keyshare)),
),
})),
}),
{},
)
// Store partials
await adminDoc.collection('post-election-data').doc('partials').set({ partials }, { merge: true })
// console.log('Updated admin partials:', partials)
console.log('Updated admin partials')
// Notify all participants there's been an update
promises.push(
pusher.trigger(`keygen-${election_id}`, 'update', { [ADMIN_EMAIL]: { partials: partials.length } }),
)
}
}
}
// If all have provided partials, admin can now combine partials
const trusteePartials = await Promise.all(loadTrusteePartials)
// console.log({ trusteePartials })
type TrusteeWithPartial = { partials: { [col: string]: Partial[] } }
const hasPartial = (trustee: FirebaseFirestore.DocumentData | undefined): trustee is TrusteeWithPartial =>
!!trustee?.partials
const all_have_partials = trusteePartials.every(hasPartial)
console.log({ all_have_partials })
if (all_have_partials) {
// Have all trustees now uploaded partials for the last shuffled list?
const last_shuffled = trustees[trustees.length - 1].shuffled as Shuffled
const columns = Object.keys(last_shuffled)
const last_shuffled_length = last_shuffled[columns[0]].shuffled.length
// Do they have *enough* partials?
const all_have_enough_partials = trusteePartials.every(
(t) => t.partials && t.partials[columns[0]].length >= last_shuffled_length,
)
console.log({ all_have_enough_partials })
if (!all_have_enough_partials) {
console.log('⚠️ Not all trustees have provided enough partials')
} else {
// Verify that all partials have passing ZK Proofs
const all_broadcasts = trustees.map(({ commitments }) => commitments.map(RP.fromHex))
const last_trustees_shuffled = trustees[trustees.length - 1].shuffled
let any_failed = false
console.log('Verifying all partial proofs...')
// For all trustees...
await bluebird.map(trusteePartials, ({ partials }, index) => {
const g_to_trustees_keyshare = compute_g_to_keyshare(index + 1, all_broadcasts)
// For all columns...
return bluebird.map(
Object.keys(partials),
// For all votes
(column) =>
bluebird.map(partials[column], async ({ partial, proof }: Partial, voteIndex) => {
const result = await verify_partial_decryption_proof(
RP.fromHex(last_trustees_shuffled[column].shuffled[voteIndex].lock),
g_to_trustees_keyshare,
RP.fromHex(partial),
destringifyPartial(proof),
)
if (!result) any_failed = true
}),
)
})
if (any_failed) {
console.log('⚠️ Not all Partial proofs passed, refusing to combine')
} else {
// Ok, now ready to combine partials and finish decryption...
console.log('All passed. Now beginning to combine partials...')
// For each column
const decrypted_and_split = mapValues(last_shuffled, (list, key) => {
// For each row
return (list as { shuffled: { encrypted: string }[] }).shuffled.map(({ encrypted }, index) => {
// 1. First we combine the partials to get the ElGamal shared secret
const partials = trusteePartials.map((t) => RP.fromHex(t.partials[key][index].partial))
const shared_secret = combine_partials(partials)
// 2. Then we can unlock each messages
const unlocked = unlock_message_with_shared_secret(shared_secret, RP.fromHex(encrypted))
return pointToString(unlocked)
})
}) as Record<string, string[]>
// 3. Finally we recombine the separated columns back together via tracking numbers
const decrypteds_by_tracking = recombine_decrypteds(decrypted_and_split)
// Store decrypteds as an array
const decrypted = Object.values(decrypteds_by_tracking)
console.log('Done combining partials... saving decrypteds to DB.')
// 4. And save the results to election.decrypted
await electionDoc.update({ decrypted, last_decrypted_at: new Date() })
// And notify everyone we have new decrypted
await pusher.trigger(election_id, 'decrypted', '')
}
}
}
// Wait for all pending promises to finish
await Promise.all(promises)
console.log('/update-admin Done.')
return res.status(201).json({ _message: `Ran /update-admin`, all_have_partials, have_all_shuffled })
}

View File

@@ -1,32 +1,21 @@
import bluebird from 'bluebird'
import { sumBy } from 'lodash-es'
import { NextApiRequest, NextApiResponse } from 'next'
import { RP, pointToString, random_bigint } from 'src/crypto/curve'
import { RP, random_bigint } from 'src/crypto/curve'
import { keygenDecrypt, keygenEncrypt } from 'src/crypto/keygen-encrypt'
import { rename_to_c1_and_2 } from 'src/crypto/shuffle'
import { verify_shuffle_proof } from 'src/crypto/shuffle-proof'
import { destringifyPartial, stringifyPartial } from 'src/crypto/stringify-partials'
import { destringifyShuffle } from 'src/crypto/stringify-shuffle'
import {
combine_partials,
compute_g_to_keyshare,
compute_keyshare,
compute_pub_key,
evaluate_private_polynomial,
generate_partial_decryption_proof,
is_received_share_valid,
partial_decrypt,
unlock_message_with_shared_secret,
verify_partial_decryption_proof,
} from 'src/crypto/threshold-keygen'
import { randomizer } from 'src/trustee/keygen/11-PartialDecryptionTest'
import { Partial, Shuffled, State, Trustee } from 'src/trustee/trustee-state'
import { mapValues } from 'src/utils'
import { State, Trustee } from 'src/trustee/trustee-state'
import { firebase } from '../../../_services'
import { pusher } from '../../../pusher'
import { recombine_decrypteds } from '../admin/unlock'
import { commafy, transform_email_keys } from './commafy'
import updateAdmin from './update-admin'
const { ADMIN_EMAIL } = process.env
@@ -71,7 +60,12 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
const commafied = transform_email_keys(body, 'commafy')
// Save whatever other new data they gave us
await trusteeDoc.update(commafied)
// Except partial goes into its own sub-doc
if (body.partials) {
await trusteeDoc.collection('post-election-data').doc('partials').set(commafied)
} else {
await trusteeDoc.update(commafied)
}
console.log('Saved update to', email, commafied)
const promises: Promise<unknown>[] = []
@@ -81,12 +75,10 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
// If they provided their public key, admin can now encrypt pairwise shares for them.
// If they provided encrypted shares, admin can decrypt their own and verify them.
// If they provided the final shuffled votes, admin can partially decrypt
// If they provided the final partial, admin can combine partials
if (body.recipient_key || body.encrypted_pairwise_shares_for || body.shuffled || body.partials) {
if (body.recipient_key || body.encrypted_pairwise_shares_for) {
// Get admin's private data
const admin = { ...(await loadAdmin).data() } as Required<State> & { decryption_key: string }
const { decryption_key, private_coefficients, private_keyshare } = admin
const { decryption_key, private_coefficients } = admin
// Get election parameters
const parameters = { ...election.data() }
@@ -190,127 +182,22 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
// Notify all participants there's been an update
promises.push(pusher.trigger(`keygen-${election_id}`, 'update', { [ADMIN_EMAIL]: { partial_decryption } }))
}
// Logic for final shuffled votes
if (body.shuffled) {
// If this is the last trustee, we can begin partially decrypting
if (trustee.index === parameters.t - 1) {
const { shuffled } = body
const trustees = (await electionDoc.collection('trustees').orderBy('index').get()).docs.map((doc) => ({
...doc.data(),
}))
// Confirm that every column's shuffle proof is valid
const checks = await bluebird.map(Object.keys(shuffled), (column) => {
const { shuffled: prevShuffle } = destringifyShuffle(trustees[trustee.index - 1].shuffled[column])
const { proof, shuffled: currShuffle } = destringifyShuffle(shuffled[column])
return verify_shuffle_proof(rename_to_c1_and_2(prevShuffle), rename_to_c1_and_2(currShuffle), proof)
})
if (!checks.length || !checks.every((x) => x)) {
console.log("Final shuffle proof didn't fully pass")
} else {
// Partially decrypt each item in every list
const partials = await bluebird.reduce(
Object.keys(shuffled),
(acc: Record<string, Partial[]>, column) =>
bluebird.props({
...acc,
[column]: bluebird.map((shuffled as Shuffled)[column].shuffled, async ({ lock }) => ({
partial: partial_decrypt(RP.fromHex(lock), BigInt(private_keyshare)).toHex(),
proof: stringifyPartial(
await generate_partial_decryption_proof(RP.fromHex(lock), BigInt(private_keyshare)),
),
})),
}),
{},
)
// Store partials
await adminDoc.update({ partials })
console.log('Updated admin partials:', partials)
// Notify all participants there's been an update
promises.push(
pusher.trigger(`keygen-${election_id}`, 'update', { [ADMIN_EMAIL]: { partials: partials.length } }),
)
}
}
}
// Logic for final partial
if (body.partials) {
// Get all trustees
const trustees = (await electionDoc.collection('trustees').orderBy('index').get()).docs.map((doc) => ({
...doc.data(),
}))
// Have all trustees now uploaded partials for the last shuffled list?
const last_shuffled = trustees[trustees.length - 1].shuffled as Shuffled
const columns = Object.keys(last_shuffled)
const last_shuffled_length = last_shuffled[columns[0]].shuffled.length
if (trustees.every((t) => t.partials && t.partials[columns[0]].length >= last_shuffled_length)) {
// Verify that all partials have passing ZK Proofs
const all_broadcasts = trustees.map(({ commitments }) => commitments.map(RP.fromHex))
const last_trustees_shuffled = trustees[trustees.length - 1].shuffled
let any_failed = false
// For all trustees...
await bluebird.map(trustees, ({ partials }, index) => {
const g_to_trustees_keyshare = compute_g_to_keyshare(index + 1, all_broadcasts)
// For all columns...
return bluebird.map(
Object.keys(partials),
// For all votes
(column) =>
bluebird.map(partials[column], async ({ partial, proof }: Partial, voteIndex) => {
const result = await verify_partial_decryption_proof(
RP.fromHex(last_trustees_shuffled[column].shuffled[voteIndex].lock),
g_to_trustees_keyshare,
RP.fromHex(partial),
destringifyPartial(proof),
)
if (!result) any_failed = true
}),
)
})
if (any_failed) {
console.log('⚠️ Not all Partial proofs passed, refusing to combine')
} else {
// Ok, now ready to combine partials and finish decryption...
// For each column
const decrypted_and_split = mapValues(last_shuffled, (list, key) => {
// For each row
return (list as { shuffled: { encrypted: string }[] }).shuffled.map(({ encrypted }, index) => {
// 1. First we combine the partials to get the ElGamal shared secret
const partials = trustees.map((t) => RP.fromHex(t.partials[key][index].partial))
const shared_secret = combine_partials(partials)
// 2. Then we can unlock each messages
const unlocked = unlock_message_with_shared_secret(shared_secret, RP.fromHex(encrypted))
return pointToString(unlocked)
})
}) as Record<string, string[]>
// 3. Finally we recombine the separated columns back together via tracking numbers
const decrypteds_by_tracking = recombine_decrypteds(decrypted_and_split)
// Store decrypteds as an array
const decrypted = Object.values(decrypteds_by_tracking)
// 4. And save the results to election.decrypted
await electionDoc.update({ decrypted, last_decrypted_at: new Date() })
// And notify everyone we have new decrypted
await pusher.trigger(election_id, 'decrypted', '')
}
}
}
}
// If they provided the final shuffled votes, admin can partially decrypt
// If they provided the final partial, admin can combine partials
if (body.shuffled || body.partials) await updateAdmin(req, mockNextResponse)
// Wait for all pending promises to finish
await Promise.all(promises)
res.status(201).send(`Updated ${email} object`)
return res.status(201).send(`Updated ${email} object`)
}
/** Call another endpoint, printing its response to the console */
const mockNextResponse = {
status: (status: number) => {
const print = (message: unknown) => console.log(`${status}: ${message}`)
return { json: print, send: print }
},
} as unknown as NextApiResponse

View File

@@ -12,18 +12,20 @@ export default allowCors(async (req: NextApiRequest, res: NextApiResponse) => {
if (!email) return res.status(400).json({ error: 'Email is required' })
if (!validateEmail(email)) return res.status(400).json({ error: 'Invalid email' })
// Store submission in Firestore
await firebase
.firestore()
.collection('news-signups')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
email,
})
await Promise.all([
// Store submission in Firestore
firebase
.firestore()
.collection('news-signups')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
email,
}),
// Notify admin via Pushover
pushover(`SIV newsletter signup`, email)
// Notify admin via Pushover
pushover(`SIV newsletter signup`, email),
])
// Send back success
return res.status(201).json({ success: true })

View File

@@ -5,18 +5,20 @@ import { firebase, pushover } from './_services'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { question } = req.body
// Store submission in Firestore
await firebase
.firestore()
.collection('faq-submissions')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
question,
})
await Promise.all([
// Store submission
firebase
.firestore()
.collection('faq-submissions')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
question,
}),
// Notify admin via Pushover
pushover(`SIV FAQ submission`, question)
// Notify admin
pushover(`SIV FAQ submission`, question),
])
// Send back success
return res.status(201).json({ success: true })

View File

@@ -0,0 +1,55 @@
// POST siv.org/api/hack-siv/register
// req.body { email: string }
import { allowCors } from 'api/_cors'
import { firebase, pushover, sendEmail } from 'api/_services'
import { validate } from 'email-validator'
import { firestore } from 'firebase-admin'
import { NextApiRequest, NextApiResponse } from 'next'
export default allowCors(async (req: NextApiRequest, res: NextApiResponse) => {
const { email } = req.body
if (!email || typeof email !== 'string') res.status(400).json({ error: '`email` is required' })
// Validate that it is a valid email
if (!validate(email)) {
pushover('hacksiv/register: Invalid email submitted', email)
return res.status(400).json({ error: 'Invalid email' })
}
// Store new record in db
// Don't overwrite multiple submissions, just concat array. With timestamps
// Store device useragent to compare HACK SIV participants to the population at large
// Don't store IP addresses
// Do store geoip country & state estimates for rough sense where people join from
await firebase
.firestore()
.collection('hack-siv')
.doc(email)
.set(
{
last_seen_at: new Date(),
registration: firestore.FieldValue.arrayUnion({
created_at: new Date(),
email,
geoip: { country: req.headers['x-vercel-ip-country'], region: req.headers['x-vercel-ip-country-region'] },
user_agent: req.headers['user-agent'],
}),
},
{ merge: true },
)
// Send welcome email
await sendEmail({
bcc: 'hack@siv.org',
from: 'SIV',
fromEmail: 'hack@siv.org',
recipient: email,
subject: 'Welcome to HACK SIV',
text: `<div style="text-align:center"><h2>Welcome to HACK SIV</h2>
Someone, hopefully you, just registered this email on <a href="https://hack.siv.org">hack.siv.org</a>.
<p style="opacity:0.5; font-style:italic;">Press REPLY if that sounds wrong.</p>`,
})
return res.status(201).json({ email, message: 'Sent verification' })
})

View File

@@ -2,6 +2,8 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { sendEmail } from './_services'
const { ACCEPTS_LOCALHOST_EMAILS = 'none set' } = process.env
export default async (req: NextApiRequest, res: NextApiResponse) => res.status(401).send('Deprecated')
export const buildSubject = (election_title?: string) => `Vote Invitation${election_title ? `: ${election_title}` : ''}`
@@ -19,9 +21,12 @@ export const send_invitation_email = ({
tag: string
voter: string
}) => {
// Don't send localhost emails to non-admins
if (link.includes('localhost') && !voter.endsWith('@dsernst.com') && !voter.endsWith('@arianaivan.com')) {
throw `Blocking sending 'localhost' email link to ${voter}`
// Don't accidentally send localhost emails
if (link.includes('localhost')) {
// Unless the recipient is a whitelisted admin address
const toAdmin = voter.endsWith(ACCEPTS_LOCALHOST_EMAILS)
if (!toAdmin)
throw `Blocking sending 'localhost' email link to ${voter}. Override with env.ACCEPTS_LOCALHOST_EMAILS`
}
// Make sure auth_token is well formed

View File

@@ -5,18 +5,20 @@ import { firebase, pushover } from './_services'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const fields = req.body
// Store submission in Firestore
await firebase
.firestore()
.collection('jurisdictions-leads')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
...fields,
created_at: new Date().toString(),
})
await Promise.all([
// Store submission
firebase
.firestore()
.collection('jurisdictions-leads')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
...fields,
created_at: new Date().toString(),
}),
// Notify admin via Pushover
pushover(`SIV jurisdiction-lead: ${fields.name} (${fields.location})`, `${fields.email}\n\n${fields.message}`)
// Notify admin
pushover(`SIV jurisdiction-lead: ${fields.name} (${fields.location})`, `${fields.email}\n\n${fields.message}`),
])
// Send back success
return res.status(201).json({ success: true })

View File

@@ -20,18 +20,20 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(400).json({ error: 'Invalid email' })
}
// Store submission in Firestore
await firebase
.firestore()
.collection('endorsers')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
...fields,
created_at: new Date().toString(),
})
await Promise.all([
// Store submission
firebase
.firestore()
.collection('endorsers')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
...fields,
created_at: new Date().toString(),
}),
// Notify admin via Pushover
await pushover(`SIV Endorsement: ${fields.name} (${fields.zip})`, `${fields.email}\n\n${fields.message}`)
// Notify admin
pushover(`SIV Endorsement: ${fields.name} (${fields.zip})`, `${fields.email}\n\n${fields.message}`),
])
// Send back success
return res.status(201).json({ success: true })

View File

@@ -11,7 +11,7 @@ import { pusher } from './pusher'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const payload = req.method === 'POST' ? req.body : req.query
const { auth, election_id, embed } = payload
const { auth, election_id, embed = '' } = payload
let { encrypted_vote } = payload
if (typeof encrypted_vote === 'string') encrypted_vote = JSON.parse(encrypted_vote)

View File

@@ -13,17 +13,19 @@ export default allowCors(async (req: NextApiRequest, res: NextApiResponse) => {
if (!validateEmail(email)) return res.status(400).json({ error: 'Invalid email' })
// Store submission in Firestore
await firebase
.firestore()
.collection('ukraine-updates')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
email,
})
await Promise.all([
firebase
.firestore()
.collection('ukraine-updates')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
email,
}),
// Notify admin via Pushover
pushover(`SIV ukraine signup`, email)
// Notify admin via Pushover
pushover(`SIV ukraine signup`, email),
])
// Send back success
return res.status(201).json({ success: true })

View File

@@ -105,7 +105,6 @@ export const OnClickButton = forwardRef<HTMLAnchorElement, OnClickProps>(
a:not(.disabled):hover {
background-color: ${invertColor ? '#fff' : darkBlue};
color: ${invertColor ? '#000' : '#fff'};
border-color: ${invertColor ? darkBlue : '#fff'};
cursor: pointer;
}
`}</style>

View File

@@ -1,4 +1,14 @@
export const Switch = ({ checked, label, onClick }: { checked: boolean; label: string; onClick: () => void }) => {
export const Switch = ({
checked,
label,
labelClassName,
onClick,
}: {
checked: boolean
label: string
labelClassName?: string
onClick: () => void
}) => {
return (
<span {...{ onClick }}>
<input
@@ -9,7 +19,11 @@ export const Switch = ({ checked, label, onClick }: { checked: boolean; label: s
type="checkbox"
{...{ checked }}
/>
<label className="pl-[0.15rem] relative bottom-0.5 cursor-pointer" htmlFor={`switch-${label}`} {...{ onClick }}>
<label
className={`pl-[0.15rem] relative bottom-0.5 cursor-pointer ${labelClassName}`}
htmlFor={`switch-${label}`}
{...{ onClick }}
>
{label}
</label>
</span>

View File

@@ -29,7 +29,10 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
}`}
>
{json?.map(
({ id, max_score, min_score, multiple_votes_allowed, options, title, write_in_allowed }, questionIndex) => (
(
{ budget_available, id, max_score, min_score, multiple_votes_allowed, options, title, write_in_allowed },
questionIndex,
) => (
// Each question
<div className="p-2.5 bg-white mt-4 first:mt-0" key={questionIndex}>
{/* Question ID Label */}
@@ -113,6 +116,14 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
delete new_json[questionIndex].max_score
}
// Handling 'budget'
if (new_json[questionIndex].type == 'budget') {
new_json[questionIndex].budget_available = 50
} else {
// Unsupported, remove
delete new_json[questionIndex].budget_available
}
saveDesign(new_json)
}}
>
@@ -121,6 +132,7 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
<option value="approval">Approval Vote for All That You Approve Of</option>
<option value="multiple-votes-allowed">Multiple Votes Allowed Choose Up to X</option>
<option value="score">Score Scale from Low to High</option>
<option value="budget">Budget Allocate a Fixed Amount of Money</option>
</select>
</div>
</div>
@@ -171,6 +183,26 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
</div>
)}
{json[questionIndex].type == 'budget' && (
<div className="mt-1">
<label className="text-[10px] italic">Total available?</label>
<div className="relative inline">
<div className="absolute text-lg opacity-50 -top-[3px] left-3">$</div>
<input
className="w-24 p-1 m-1 text-sm text-right"
type="number"
value={budget_available}
onChange={({ target }) => {
const update = +target.value
const new_json = [...json]
new_json[questionIndex].budget_available = update
saveDesign(new_json)
}}
/>
</div>
</div>
)}
{/* Question Title Label */}
<label className="block mt-4 text-[10px] italic">Question Title:</label>
{/* Question Title Input */}

View File

@@ -19,12 +19,16 @@ export function check_for_urgent_ballot_errors(design: string): string | null {
throw `Question ${question.id ? `'${question.id}'` : ''} is missing an options array`
// Validate options
question.options.forEach(({ name }: { name?: string }) => {
question.options.forEach(({ name, value }: { name?: string; value?: string }) => {
// Check for name field
if (name === undefined || typeof name !== 'string') throw 'Each option should have a { name: string } field'
// Make sure name field isn't too long.
if (name.length > 26) throw `Keep names under 26 characters: ${name}`
// If value, keep short enough
if (value && value.length > 26) throw `Keep "value" < 26 characters: ${value}`
// If no value, name needs to be shorter
if (!value && name.length > 26)
throw `Name is too long. Add shorter "value" field, then longer name is ok: ${name}`
// 'BLANK' is a reserved option
if (name.toLowerCase() === 'blank') throw `'BLANK' is a reserved option name`

View File

@@ -6,6 +6,7 @@ import { revalidate, useStored } from '../useStored'
import { AddVoterTextarea } from './AddVotersTextarea'
import { ExistingVoters } from './ExistingVoters'
import { RequestEsignatures } from './RequestEsignatures'
import { StopAcceptingVotes } from './StopAcceptingVotes'
import { ToggleShareableLink } from './ToggleShareableLink'
export const AddVoters = () => {
@@ -40,6 +41,7 @@ export const AddVoters = () => {
<ToggleShareableLink />
<RequestEsignatures />
<StopAcceptingVotes />
<ExistingVoters />
</div>
)

View File

@@ -35,7 +35,7 @@ export const ExistingVoters = () => {
return (
<>
{/* Group around Accepted Voters table */}
<div className="pt-3 pb-1 pl-4 -ml-4 rounded shadow-md bg-blue-200/40">
<div className="pt-3 pb-1 pl-4 mt-8 -ml-4 rounded shadow-md bg-blue-200/40">
<UnlockedStatus />
<div className="pr-4">
<TopBarButtons {...{ checked, num_approved, num_voted }} />

View File

@@ -26,7 +26,7 @@ export const RequestEsignatures = () => {
}
return (
<div className="mb-8">
<div>
<label className="cursor-pointer" onClick={toggleESignature}>
<span className="mr-1.5 relative top-0.5">
<Image height={(218 / 700) * 70} layout="fixed" src={esignatureIcon} width={70} />

View File

@@ -0,0 +1,39 @@
import { CloseSquareOutlined } from '@ant-design/icons'
import { useState } from 'react'
import { api } from '../../api-helper'
import { Switch } from '../BallotDesign/Switch'
import { Spinner } from '../Spinner'
import { revalidate, useStored } from '../useStored'
export const StopAcceptingVotes = () => {
const [updating, setUpdating] = useState(false)
const { election_id, stop_accepting_votes } = useStored()
async function toggle() {
setUpdating(true)
const response = await api(`election/${election_id}/admin/set-stop-accepting-votes`, {
stop_accepting_votes: !stop_accepting_votes,
})
if (response.status === 201) {
revalidate(election_id)
setUpdating(false)
} else {
throw await response.json()
}
}
return (
<section className="mt-1 pt-0 p-1 ml-[-5px] cursor-pointer inline-block pr-3" onClick={toggle}>
<label>
<CloseSquareOutlined className="text-[20px] mr-1.5 relative top-0.5" />
Stop accepting new votes?
</label>
<span className="relative bottom-[3px] ml-2">
<Switch checked={!!stop_accepting_votes} label="" onClick={() => {}} />
</span>
{updating && <Spinner />}
</section>
)
}

View File

@@ -7,63 +7,78 @@ import { Spinner } from '../Spinner'
import { useStored } from '../useStored'
export const UnlockVotesButton = ({ num_approved, num_voted }: { num_approved: number; num_voted: number }) => {
const { election_id, election_title, esignature_requested } = useStored()
const { election_id, election_title, esignature_requested, trustees } = useStored()
const show_dropdown = trustees && trustees?.length > 1
const { user } = useUser()
const [unlocking, toggle_unlocking] = useReducer((state) => !state, false)
return (
<OnClickButton
className="bg-white"
disabled={!num_approved}
disabledExplanation={
!num_approved &&
esignature_requested &&
!!num_voted &&
"No votes with approved signatures.\n\nHover over individual signatures to Approve/Reject them, or click 'Signature' column header to Approve All."
async function triggerUnlock(options?: { skip_shuffle_proofs?: boolean }) {
const startTime = Date.now()
const expectedTimeout = 60
const expectedMilliseconds = (expectedTimeout - 1) * 1000
toggle_unlocking()
const response = await api(`election/${election_id}/admin/unlock`, { options }).catch(() => {})
const adminMsg = `Election title: ${election_title}\nElection ID: ${election_id}\nUser: ${user.name}\nEmail: ${user.email}\nVotes: ${num_approved}`
if (!response || response?.status !== 201) {
if (Date.now() - startTime > expectedMilliseconds) {
toggle_unlocking()
api('/pushover', {
message: adminMsg,
title: `Admin ${user.email} unlock timeout`,
})
alert('The backend server timed out after 60 seconds. Alert team@siv.org for manual unlock')
return
}
style={{ alignSelf: 'baseline', margin: 0, marginLeft: 5, padding: '5px 10px' }}
onClick={async () => {
const startTime = Date.now()
const expectedTimeout = 60
const expectedMilliseconds = (expectedTimeout - 1) * 1000
toggle_unlocking()
const response = await api(`election/${election_id}/admin/unlock`).catch(() => {})
const json = await response?.json()
console.error('Unlocking error:', json)
const adminMsg = `Election title: ${election_title}\nElection ID: ${election_id}\nUser: ${user.name}\nEmail: ${user.email}\nVotes: ${num_approved}`
api('/pushover', {
message: `${adminMsg}\nError: ${JSON.stringify(json)}`,
title: `Admin ${user.email} unlock error`,
})
if (!response || response?.status !== 201) {
if (Date.now() - startTime > expectedMilliseconds) {
toggle_unlocking()
api('/pushover', {
message: adminMsg,
title: `Admin ${user.email} unlock timeout`,
})
alert('The backend server timed out after 60 seconds. Alert team@siv.org for manual unlock')
return
}
if (json) {
alert(JSON.stringify(json.error))
} else {
alert('Unknown error during unlocking')
}
}
toggle_unlocking()
}
const json = await response?.json()
console.error('Unlocking error:', json)
api('/pushover', {
message: `${adminMsg}\nError: ${JSON.stringify(json)}`,
title: `Admin ${user.email} unlock error`,
})
if (json) {
alert(JSON.stringify(json.error))
} else {
alert('Unknown error during unlocking')
}
return (
<div>
<OnClickButton
className={`bg-white !m-0 !ml-[5px] self-baseline !py-[5px] !px-2.5 ${show_dropdown && '!rounded-r-none'}`}
disabled={!num_approved}
disabledExplanation={
!num_approved &&
esignature_requested &&
!!num_voted &&
"No votes with approved signatures.\n\nHover over individual signatures to Approve/Reject them, or click 'Signature' column header to Approve All."
}
toggle_unlocking()
}}
>
<>
{unlocking && <Spinner />}
Unlock{unlocking ? 'ing' : ''} {num_approved} Vote{num_approved === 1 ? '' : 's'}
</>
</OnClickButton>
onClick={triggerUnlock}
>
<>
{unlocking && <Spinner />}
Unlock{unlocking ? 'ing' : ''} {num_approved} Vote{num_approved === 1 ? '' : 's'}
</>
</OnClickButton>
{/* Dropdown arrow to skip shuffle proofs */}
{show_dropdown && (
<OnClickButton
className="!m-0 bg-white !rounded-l-none !border-l-0 self-baseline !py-px !px-[7px] text-[20px] relative top-0.5"
disabled={!num_approved}
onClick={() => confirm('Unlock without shuffle proofs?') && triggerUnlock({ skip_shuffle_proofs: true })}
>
</OnClickButton>
)}
</div>
)
}

View File

@@ -16,22 +16,29 @@ export const UnlockedStatus = () => {
const more_to_unlock = num_voted > unlocked_votes.length
return (
<div className={more_to_unlock || isUnlockBlocked ? 'warning' : ''}>
<div
className={`border border-solid rounded-md p-2.5 mr-4 mb-3.5 ${
more_to_unlock || isUnlockBlocked
? '!border-[rgba(175,157,0,0.66)] !bg-[rgba(237,177,27,0.07)]'
: 'border-[rgba(26,89,0,0.66)] bg-[rgba(0,128,0,0.07)]'
}`}
>
{isUnlockBlocked ? (
<p>
Unlocking: Waiting on Observer <i> {isUnlockBlocked}</i>
Unlocking: Waiting on Observer <i className="font-medium"> {isUnlockBlocked}</i>
</p>
) : !more_to_unlock ? (
<p>
Successfully{' '}
<Link href={`/election/${election_id}`}>
<a className="status" target="_blank">
<a className="font-medium text-black cursor-pointer" target="_blank">
unlocked {unlocked_votes.length}
</a>
</Link>{' '}
votes.{' '}
{notified_unlocked !== unlocked_votes.length ? (
<a
className="font-semibold cursor-pointer"
onClick={async () => {
await api(`election/${election_id}/admin/notify-unlocked`)
revalidate(election_id)
@@ -40,7 +47,7 @@ export const UnlockedStatus = () => {
Notify voters?
</a>
) : (
<b>Voters notified.</b>
<b className="font-semibold">Voters notified.</b>
)}
</p>
) : (
@@ -49,41 +56,9 @@ export const UnlockedStatus = () => {
</p>
)}
<style jsx>{`
div {
border: 1px solid rgba(26, 89, 0, 0.66);
background: rgba(0, 128, 0, 0.07);
border-radius: 5px;
padding: 10px;
margin-bottom: 15px;
}
div.warning {
border-color: rgba(175, 157, 0, 0.66);
background: rgba(237, 177, 27, 0.07);
}
p {
margin: 0;
}
i {
font-weight: 500;
}
a {
font-weight: 600;
cursor: pointer;
}
a.status {
color: black;
font-weight: 500;
}
b {
font-weight: 600;
}
`}</style>
</div>
)

View File

@@ -1,3 +1,5 @@
import { randomBytes } from 'crypto'
export function generateAuthToken() {
let auth_token = generateOne()
let attempts = 1
@@ -11,11 +13,7 @@ export function generateAuthToken() {
}
function generateOne() {
const random = Math.random()
const integer = String(random).slice(2)
const hex = Number(integer).toString(16)
const auth_token = hex.slice(0, 10)
return auth_token
return randomBytes(5).toString('hex') // 10 characters hex string
}
const isBadPatterns = (auth_token: string) => {

View File

@@ -6,10 +6,33 @@ export type Cipher = { encrypted: RP; lock: RP }
export type Public_Key = RP
const G = RP.BASE
export async function shuffle(
export async function shuffleWithProof(
pub_key: Public_Key,
inputs: Cipher[],
): Promise<{ proof: Shuffle_Proof; shuffled: Cipher[] }> {
const { proof, shuffled } = await shuffle(pub_key, inputs)
return { proof, shuffled }
}
export async function shuffleWithoutProof(pub_key: Public_Key, inputs: Cipher[]): Promise<{ shuffled: Cipher[] }> {
const { shuffled } = await shuffle(pub_key, inputs, { skip_proof: true })
return { shuffled }
}
/** Private function that does the shuffling, with option to skip generating the costly proof.
* We export non-overloaded functions `shuffleWithProof()` and `shuffleWithoutProof()`, so types can be more cleanly inferred.
*/
async function shuffle(pub_key: Public_Key, inputs: Cipher[]): Promise<{ proof: Shuffle_Proof; shuffled: Cipher[] }>
async function shuffle(
pub_key: Public_Key,
inputs: Cipher[],
options: { skip_proof: true },
): Promise<{ shuffled: Cipher[] }>
async function shuffle(
pub_key: Public_Key,
inputs: Cipher[],
options: { skip_proof?: boolean } = {},
): Promise<{ proof?: Shuffle_Proof; shuffled: Cipher[] }> {
// First, we need a permutation array and reencryption values
const permutes = build_permutation_array(inputs.length)
@@ -33,6 +56,9 @@ export async function shuffle(
return { encrypted: new_encrypted, lock: new_lock }
})
// Can skip generating costly proof
if (options?.skip_proof) return { shuffled }
// Finally we generate a ZK proof that it's a valid shuffle
const proof = await generate_shuffle_proof(
rename_to_c1_and_2(inputs),

View File

@@ -2,10 +2,10 @@ import { mapValues } from 'lodash'
import { AsyncReturnType } from './async-return-type'
import { RP } from './curve'
import { shuffle } from './shuffle'
import { shuffleWithProof, shuffleWithoutProof } from './shuffle'
export type CipherStrings = ReturnType<typeof stringifyShuffle>['shuffled'][0]
export function stringifyShuffle({ proof, shuffled }: AsyncReturnType<typeof shuffle>) {
export function stringifyShuffle({ proof, shuffled }: AsyncReturnType<typeof shuffleWithProof>) {
const p = proof
const simple = p.simple_shuffle_proof
return {
@@ -33,10 +33,14 @@ export function stringifyShuffle({ proof, shuffled }: AsyncReturnType<typeof shu
}
}
export function stringifyShuffleWithoutProof({ shuffled }: AsyncReturnType<typeof shuffleWithoutProof>) {
return { shuffled: shuffled.map((r) => mapValues(r, String)) }
}
export function destringifyShuffle({
proof,
shuffled,
}: ReturnType<typeof stringifyShuffle>): AsyncReturnType<typeof shuffle> {
}: ReturnType<typeof stringifyShuffle>): AsyncReturnType<typeof shuffleWithProof> {
const p = proof
const simple = p.simple_shuffle_proof
return {
@@ -63,3 +67,9 @@ export function destringifyShuffle({
shuffled: shuffled.map((r) => mapValues(r, RP.fromHex)),
}
}
export function destringifyShuffleWithoutProof({
shuffled,
}: ReturnType<typeof stringifyShuffleWithoutProof>): AsyncReturnType<typeof shuffleWithoutProof> {
return { shuffled: shuffled.map((r) => mapValues(r, RP.fromHex)) }
}

View File

@@ -52,8 +52,9 @@ export const FAQPage = (): JSX.Element => {
</OnClickButton>
</div>
{faq.map(({ id, q, resp }, index) => (
{faq.map(({ deprecated_ids, id, q, resp }, index) => (
<div className="question" key={index}>
{deprecated_ids && deprecated_ids.map((id) => <div id={id} key={id} />)}
<h3
id={id}
onClick={() => {

View File

@@ -1,4 +1,4 @@
export const faq: { id?: string; q: string; resp: string }[] = [
export const faq: { deprecated_ids?: string[]; id?: string; q: string; resp: string }[] = [
{
id: 'secure',
q: 'What is a "Secure" election?',
@@ -50,7 +50,7 @@ The entire process leaves a written audit trail, for independent verification.
Once a voter makes their selections, all their options get encrypted on their voting device.
Their plaintext never leaves their device.
Once all votes are received, the <a href="https://siv.org/faq#observers">Verifying Observers'</a> computers each add their own cryptographic shuffle to all the votes, for thorough anonymization, before working together to unlock the votes for tallying.
Once all votes are received, the <a href="https://siv.org/faq#privacy-protectors">Privacy Protectors'</a> computers each add their own cryptographic shuffle to all the votes, for thorough anonymization, before working together to unlock the votes for tallying.
This is a similar process as with paper ballots, where voters are confirmed, but the voter's identification is not on the submitted ballot.
@@ -61,11 +61,14 @@ The SIV system offers even more rigorous privacy, so nobody has the ability to c
q: 'How does SIV ensure election results are Verifiable?',
resp: `All final election tallies can be independently recounted.
There are two ways in which votes can be verified.
There are many ways in which votes can be verified.
1. Voters themselves can personally verify their vote in the final tally. When they submit their vote, voters' devices create a random secret <i><b>Verification #</b></i>. Once votes are unlocked for tallying, voters can find their <i>Verification #</i> to confirm that their vote was cast and counted exactly as intended. This provides far greater assurance than paper elections offer, where voters have little first-hand verifiability after they submit their vote.
2. SIV also allows for cryptographic <i><b>Universal Verifiability</b></i>. Election administrators and approved organizations can run the SIV Universal Verifier. This provides the ability to retrace all the election steps for all votes, from encrypted submissions to final results.`,
2. SIV also allows for cryptographic <i><b>Universal Verifiability</b></i>. Anyone can run the cryptographic verification code. This provides the ability to retrace all the election steps for all votes, from encrypted submissions to final results.
For all the other methods, check out the <a href="https://docs.siv.org/verifiability">SIV Docs: Verifiability</a> section.
`,
},
{
q: 'How can voters be confident in election results?',
@@ -189,7 +192,7 @@ Voter Authorization tokens can be invalidated as soon as a vote is recorded from
{
id: 'better-voting-methods',
q: 'Does SIV support other voting methods, like Approval Voting?',
resp: `Currently, SIV supports Plurality Voting, Block Voting, and Approval Voting. We add new voting methods as requested. Please let us know if you need more: <a href="mailto:voting-methods@siv.org" target="_blank">voting-methods@siv.org</a>.
resp: `Currently, SIV supports Plurality, Block, Score, Ranked Choice, and Approval Voting. We add new voting methods as requested. Please let us know if you need more: <a href="mailto:voting-methods@siv.org" target="_blank">voting-methods@siv.org</a>.
Digital voting can make it much easier for voters to adopt these more advanced voting methods, with immediate feedback and automatically preventing voters from accidentally disqualifying their ballot.`,
},
@@ -252,21 +255,22 @@ This protects voters' network connection to prevent tampering and surveillance.`
SIV automatically creates complete end-to-end verifiable elections, so that anyone who submits record requests can simply be directed to the publicly posted election data.`,
},
{
id: 'observers',
q: 'What are Verifying Observers?',
resp: `Appointing Verifying Observers is a powerful SIV feature for Election Administrators.
deprecated_ids: ['observers'],
id: 'privacy-protectors',
q: 'What are Privacy Protectors?',
resp: `Appointing Privacy Protectors is a powerful SIV feature for Election Administrators.
These Verifying Observers are similar to the election observers we use in our existing paper elections. But the SIV process runs on computers and uses advanced mathematics and strong cryptography, including what are called Zero-Knowledge Proofs. It offers total privacy and verifiability, proving that none of the votes are tampered with. And it requires only a small handful of people, unlike our large paper elections which can require tens of thousands of observers, but who can ultimately provide only incomplete security.
These Privacy Protectors are similar to the election observers we use in our existing paper elections. But the SIV process runs on computers and uses advanced mathematics and strong cryptography, including what are called Zero-Knowledge Proofs. It offers total privacy and verifiability, proving that none of the votes are tampered with. And it requires only a small handful of people, unlike our large paper elections which can require tens of thousands of observers, but who can ultimately provide only incomplete security.
Protocol <a href="/protocol#4" target="_blank">Steps 4</a> & <a href="/protocol#5" target="_blank">5</a> detail more about their role.
`,
},
{
id: 'picking-observers',
q: 'How should Verifying Observers be picked?',
resp: `The most secure and safest approach is to assign Verifying Observers with independent interests, such as one nominated by each participating political party.
q: 'How should Privacy Protectors be picked?',
resp: `The most secure and safest approach is to assign Privacy Protectors with independent interests, such as one nominated by each participating political party.
To be confident that the privacy of the vote is protected, voters need to trust just a single Verifying Observer. Verifying Observers do not need to trust each other, and cannot possibly tamper with votes.`,
To be confident that the privacy of the vote is protected, voters need to trust just a single Privacy Protector. Privacy Protectors do not need to trust each other, and cannot possibly tamper with votes.`,
},
{
q: 'How does SIV impact Risk Limiting Audits?',
@@ -274,7 +278,9 @@ To be confident that the privacy of the vote is protected, voters need to trust
RLAs are often currently used to double check vote tallies. SIV makes this unnecessary because all vote tallies are independently verifiable and automatically recounted by every device that visits the public election status page.
RLAs are still very useful to audit voter rolls & Voter Authorization token issuance, and to help voters check their Voter Verification #s are in the final tally.`,
RLAs are still very useful to audit voter rolls & Voter Authorization token issuance, and to help voters check their Voter Verification #s are in the final tally.
Learn More: <a href="https://docs.siv.org/verifiability/rla">docs.siv.org/verifiability/rla</a>`,
},
{
id: 'phishing',

View File

@@ -1,4 +1,4 @@
import Image from 'next/image'
import Image, { StaticImageData } from 'next/image'
import verifiability1 from 'public/home3/verifiability-1.png'
import verifiability2 from 'public/home3/verifiability-2.png'
@@ -12,8 +12,8 @@ export const Verifiability = () => (
<br /> own submission is counted correctly, and recount all results themselves.
</p>
<div className="verifiability-container">
<Screenshot n={1} />
<Screenshot n={2} />
<Screenshot image={verifiability1} label="1 - After Vote Submission" />
<Screenshot image={verifiability2} label="2 - End of Election" />
</div>
<style jsx>{`
section {
@@ -67,12 +67,10 @@ export const Verifiability = () => (
</section>
)
const images = { 1: verifiability1, 2: verifiability2 }
const Screenshot = ({ n }: { n: 1 | 2 }) => (
const Screenshot = ({ image, label }: { image: StaticImageData; label: string }) => (
<div>
<span>{n}</span>
<Image height={516} layout="responsive" placeholder="blur" src={images[n]} width={992} />
<span className="px-2">{label}</span>
<Image height={516} layout="responsive" placeholder="blur" src={image} width={992} />
<style jsx>{`
div {
position: relative;
@@ -89,7 +87,6 @@ const Screenshot = ({ n }: { n: 1 | 2 }) => (
border-radius: 10vw;
border: 0.25vw solid ${darkBlue};
width: 2.8vw;
height: 2.8vw;
z-index: 10;
text-align: center;
@@ -107,9 +104,8 @@ const Screenshot = ({ n }: { n: 1 | 2 }) => (
}
span {
font-size: 4vw;
width: 6vw;
height: 6vw;
font-size: 3.5vw;
height: 5.5vw;
top: -3vw;
left: -3vw;
}

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { Fragment, useEffect, useState } from 'react'
import { CipherStrings } from 'src/crypto/stringify-shuffle'
import { EncryptedVote } from 'src/protocol/EncryptedVote'
import { useTruncatedTable } from 'src/trustee/decrypt/useTruncatedTable'
import { generateColumnNames } from 'src/vote/generateColumnNames'
import { Item } from '../vote/storeElectionInfo'
@@ -13,11 +14,13 @@ export type EncryptedVote = { auth: string } & { [index: string]: CipherStrings
const fetcher = (url: string) => fetch(url).then((r) => r.json())
export const AcceptedVotes = ({
allow_truncation = false,
ballot_design,
esignature_requested,
has_decrypted_votes,
title_prefix = '',
}: {
allow_truncation?: boolean
ballot_design?: Item[]
esignature_requested?: boolean
has_decrypted_votes?: boolean
@@ -42,12 +45,16 @@ export const AcceptedVotes = ({
.then(setVotes)
}, [election_id])
const { columns } = generateColumnNames({ ballot_design })
const { TruncationToggle, rows_to_show } = useTruncatedTable({
num_cols: columns.length,
num_rows: num_votes,
})
if (!votes || !ballot_design) return <div>Loading...</div>
const newTotalVotes = num_votes - votes.length
const { columns } = generateColumnNames({ ballot_design })
return (
<>
<TotalVotesCast numVotes={num_votes} />
@@ -94,7 +101,7 @@ export const AcceptedVotes = ({
</thead>
<tbody>
{votes.map((vote, index) => (
{votes.slice(0, allow_truncation ? rows_to_show : num_votes).map((vote, index) => (
<tr key={index}>
<td>{index + 1}.</td>
{esignature_requested && (
@@ -115,6 +122,7 @@ export const AcceptedVotes = ({
))}
</tbody>
</table>
{allow_truncation && <TruncationToggle />}
{/* Load new votes */}
{!!newTotalVotes && (

View File

@@ -1,6 +1,7 @@
import { orderBy } from 'lodash-es'
import { generateColumnNames } from 'src/vote/generateColumnNames'
import { BudgetEntry, BudgetsAveraged, findBudgetQuestion, sumBudgetVotes } from './tallyBudgetLogic'
import { unTruncateSelection } from './un-truncate-selection'
import { useDecryptedVotes } from './use-decrypted-votes'
import { useElectionInfo } from './use-election-info'
@@ -12,19 +13,21 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El
if (!votes || !votes.length || !ballot_design) return <></>
const sorted_votes = orderBy(votes, 'tracking')
const budgetSums = sumBudgetVotes(sorted_votes, ballot_design)
const { columns } = generateColumnNames({ ballot_design })
return (
<div>
<div className="bg-white p-4 rounded-lg shadow-[0_2px_2px_hsla(0,0%,50%,0.333),0_4px_4px_hsla(0,0%,50%,0.333),0_6px_6px_hsla(0,0%,50%,0.333)]">
{!proofsPage && (
<>
<h3>Decrypted Votes</h3> <p>Anonymized for vote secrecy.</p>
<h3 className="mt-0 mb-1.5">Decrypted Votes</h3>
<p className="mt-0 text-[13px] italic opacity-70">Anonymized for vote secrecy.</p>
</>
)}
<table>
<table className="block overflow-auto border-collapse [&_tr>*]:[border:1px_solid_#ccc] [&_tr>*]:px-2.5 [&_tr>*]:py-[3px]">
<thead>
<tr>
<tr className="text-[11px]">
<th></th>
<th style={{ backgroundColor: 'rgba(10, 232, 10, 0.24)' }}>verification #</th>
{columns.map((c) => (
@@ -33,13 +36,18 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El
</tr>
</thead>
<tbody>
{sorted_votes.map((vote, index) => (
<tr key={index}>
<td>{index + 1}.</td>
<BudgetsAveraged {...{ ballot_design, budgetSums, columns, sorted_votes }} />
{sorted_votes.map((vote, voteIndex) => (
<tr key={voteIndex}>
<td>{voteIndex + 1}.</td>
<td>{vote.tracking?.padStart(14, '0')}</td>
{columns.map((c) => (
<td className="text-center" key={c}>
{unTruncateSelection(vote[c], ballot_design, c)}
{findBudgetQuestion(ballot_design, c) ? (
<BudgetEntry {...{ ballot_design, budgetSums, col: c, original: vote[c], voteIndex }} />
) : (
unTruncateSelection(vote[c], ballot_design, c)
)}
{/* Fix centering for negative Scores */}
{vote[c]?.match(/^-\d$/) && <span className="inline-block w-1.5" />}
@@ -49,43 +57,6 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El
))}
</tbody>
</table>
<style jsx>{`
div {
background: #fff;
padding: 1rem;
border-radius: 8px;
box-shadow: 0px 2px 2px hsl(0 0% 50% / 0.333), 0px 4px 4px hsl(0 0% 50% / 0.333),
0px 6px 6px hsl(0 0% 50% / 0.333);
}
h3 {
margin: 0 0 5px;
}
p {
margin-top: 0px;
font-size: 13px;
font-style: italic;
opacity: 0.7;
}
table {
border-collapse: collapse;
display: block;
overflow: auto;
}
th,
td {
border: 1px solid #ccc;
padding: 3px 10px;
margin: 0;
}
th {
font-size: 11px;
}
`}</style>
</div>
)
}

View File

@@ -4,9 +4,8 @@ import { Item } from 'src/vote/storeElectionInfo'
import { mapValues } from '../utils'
import { tally_IRV_Items } from './tallying/rcv-irv'
export const multi_vote_regex = /_(\d+)$/
export function tallyVotes(ballot_items_by_id: Record<string, Item>, votes: Record<string, string>[]) {
const multi_vote_regex = /_(\d+)$/
// Sum up votes
const tallies: Record<string, Record<string, number>> = {}
const IRV_columns_seen: Record<string, boolean> = {}

View File

@@ -0,0 +1,184 @@
import { Tooltip } from 'src/admin/Voters/Tooltip'
import { Item } from 'src/vote/storeElectionInfo'
/** Find the original ballot_design id for a given column */
export function findBudgetQuestion(ballot_design: Item[], col: string) {
// Check for matching ballot types = 'budget'
const possibleItem = ballot_design.find(({ id = 'vote', type }) => type === 'budget' && col.startsWith(id))
if (!possibleItem) return false
// If found, check if col is in options
const matchingCol = possibleItem.options.find(({ name, value }) => col.endsWith(value || name))
if (matchingCol) return possibleItem
}
/** Validate a submission is a valid type=budget response */
function invalidBudgetValues(vote: string): string {
if (Number(vote) < 0) return 'Negative amounts not allowed'
if (Number.isNaN(Number(vote))) return 'Not a number'
if (Number(vote) === Infinity) return "Can't normalize Infinity"
return ''
}
/** Sum up the total for a particular row (all the columns, from one person), to see if it matches the budget_available */
export function sumBudgetVotes(votes: Record<string, string>[], ballot_design: Item[]) {
// For all budget questions
return ballot_design.map(({ id = 'vote', options, type }) => {
// Stop if question isn't a budget item
if (type !== 'budget') return []
// Otherwise, for every submission (row in table)
return votes.map((vote) => {
// Add up their total budget allocated
let total = 0
options.forEach(({ name, value }) => {
const cell = vote[id + '_' + value || name]
// Filter out invalid entries
if (invalidBudgetValues(cell)) return
total += Number(cell)
})
return total
})
})
}
/** If their submitted total went over or under the budget_available, calculate a normalization factor */
function calculateFactor(
ballot_design: Item[],
budgetSums: number[][],
col: string,
original: string,
voteIndex: number,
) {
// Find the right question
const question = findBudgetQuestion(ballot_design, col)
if (!question) return {}
const questionIndex = ballot_design.findIndex(({ id }) => id === question?.id)
// Find the precalculated total budget allocated for this row
const total = budgetSums[questionIndex][voteIndex]
// Calculate factor to display normalized
const factor = (question.budget_available || 0) / total
const normalized = Number(original) * factor
return { factor, normalized, question, total }
}
/** Display a single type=budget cell */
export function BudgetEntry({
ballot_design,
budgetSums,
col,
original,
voteIndex,
}: {
ballot_design: Item[]
budgetSums: number[][]
col: string
original: string
voteIndex: number
}) {
// Handle blanks
if (!original) return null
// Handle invalid entries
const error = invalidBudgetValues(original)
if (error)
return (
<Tooltip tooltip={error}>
<span className="text-red-500 border-0 border-b border-red-500 border-dashed opacity-70">{original}</span>
</Tooltip>
)
// Calculate factor
const { factor, normalized, question, total } = calculateFactor(ballot_design, budgetSums, col, original, voteIndex)
if (normalized === 0) return <span className="opacity-30">{normalized}</span>
// If total === budget_allocated, return original
if (normalized === Number(original)) return <span className="text-green-800">${original}</span>
if (!factor) return null
return (
<Tooltip
tooltip={
<span className="text-black/50">
Row total: <span className="text-black/80">${total}</span> of ${question.budget_available}. Scaling by{' '}
<span className="font-semibold text-green-800">{factor.toFixed(2)}</span>
</span>
}
>
<div>
<span className="opacity-80">{original}</span>
<span className="opacity-40"> </span>
<span className="text-green-800 border-0 border-b border-dashed border-green-800/70 ">
${Math.floor(normalized)}
</span>
</div>
</Tooltip>
)
}
/** Calculate & render the top row for type=budget questions, averaging all the normalized values in a column */
export function BudgetsAveraged({
ballot_design,
budgetSums,
columns,
sorted_votes,
}: {
ballot_design: Item[]
budgetSums: number[][]
columns: string[]
sorted_votes: Record<string, string>[]
}) {
// If no budget questions, show nothing
if (!budgetSums.some((votes) => votes.length > 0)) return null
// Calculate normalized average for each column
const averages = columns.map((col) => {
let sum = 0
sorted_votes.forEach((vote, voteIndex) => {
if (!vote[col]) return
if (invalidBudgetValues(vote[col])) return
const original = vote[col]
// Get normalized
const { normalized } = calculateFactor(ballot_design, budgetSums, col, original, voteIndex)
sum += normalized || 0
})
sum /= sorted_votes.length
return sum
})
return (
<tr className="border-0 border-b-2 border-solid border-blue-900/20">
<td className="italic opacity-70">Avg</td>
<td></td>
{columns.map((col, colIndex) => (
<td className="text-center" key={col}>
{averages[colIndex] === 0 ? (
<span className="opacity-20">0</span>
) : (
<Tooltip
tooltip={
<span>
Mean of <span className="font-semibold text-green-800">normalized</span> amounts below{' '}
<span className="opacity-50">(blanks = 0)</span>
</span>
}
>
<span className="font-semibold text-green-800">${Math.floor(averages[colIndex])}</span>
</Tooltip>
)}
</td>
))}
</tr>
)
}

View File

@@ -163,7 +163,10 @@ describe('IRV tallying', () => {
expect(rounds[2].tallies).toEqual({ 'Abraham Lincoln': 4, 'Bill Clinton': 5 })
})
test.todo('handles when there are a large number of distinct write-ins') // See https://siv.org/election/1721314324882
test.todo('handles when there is a tie in the bottom choice')
test.todo('handles when there is a tie in the top choice early on')
test.todo('handles when there is a tie in the final round')
test.todo('handles when there are a two digit number of multiple_votes_allowed')
})

View File

@@ -2,7 +2,7 @@ import { isNotUndefined, mapKeys, mapValues, omit } from 'src/utils'
import { defaultRankingsAllowed } from 'src/vote/Ballot'
import { Item } from 'src/vote/storeElectionInfo'
import { tallyVotes } from '../tally-votes'
import { multi_vote_regex, tallyVotes } from '../tally-votes'
export type IRV_Round = {
ordered: string[]
@@ -18,13 +18,16 @@ export const tally_IRV_Items = (
// First we undo the multi-vote suffixes to get back to the ballot_design ids
const items: Record<string, { rounds: IRV_Round[]; winner?: string }> = {}
Object.keys(IRV_columns_seen).forEach((key) => {
const item = key.slice(0, -2)
const multi_suffix = key.match(multi_vote_regex)
if (!multi_suffix) throw new Error(`Unexpected key ${key} breaking multi-vote regex`)
const item = key.slice(0, -multi_suffix[0].length)
items[item] = { rounds: [] }
})
// Then for each voting item....
Object.keys(items).forEach((item) => {
const eliminated: string[] = []
const max_selections = ballot_items_by_id[item].multiple_votes_allowed || defaultRankingsAllowed
// The IRV algorithm is to go round-by-round,

View File

@@ -0,0 +1,29 @@
import { useState } from 'react'
import { PrivateBox } from '../PrivateBox'
export const PrivateKeyshare = ({ private_keyshare }: { private_keyshare: string }) => {
const [blurred, setBlurred] = useState(true)
return (
<div className="mt-8">
<PrivateBox>
<p>
Your Private keyshare is:{' '}
<span className={`font-mono cursor-pointer ${blurred && 'blur-sm'}`} onClick={() => setBlurred(!blurred)}>
{!blurred ? private_keyshare : blurredTextMask(private_keyshare.length)}
</span>
</p>
</PrivateBox>
</div>
)
}
function blurredTextMask(length: number) {
let newString = ''
const blurred_text = 'BLURRED&TEXTREPLACEDBECAUSEJUSTBLURRINGALONECANBEUNDONEPRETTYEASILYACTUALLY.'
for (let i = 0; i < length; i += blurred_text.length) {
newString += blurred_text
}
return newString.slice(0, length)
}

View File

@@ -3,17 +3,18 @@ import { useState } from 'react'
import { AcceptedVotes } from '../../status/AcceptedVotes'
import { useElectionInfo } from '../../status/use-election-info'
import { Trustees } from '../keygen/1-Trustees'
import { PrivateBox } from '../PrivateBox'
import { StateAndDispatch } from '../trustee-state'
import { ResetButton } from './_ResetButton'
import { PrivateKeyshare } from './PrivateKeyshare'
import { VotesToDecrypt } from './VotesToDecrypt'
import { VotesToShuffle } from './VotesToShuffle'
export const ShuffleAndDecrypt = ({ dispatch, state }: StateAndDispatch): JSX.Element => {
const { private_keyshare } = state
const { ballot_design } = useElectionInfo()
const { ballot_design, skip_shuffle_proofs } = useElectionInfo()
const [final_shuffle_verifies, set_final_shuffle_verifies] = useState(false)
if (!ballot_design) return <p>Loading election info...</p>
if (!private_keyshare) return <p>Error: No `private_keyshare` found in localStorage.</p>
return (
@@ -26,16 +27,15 @@ export const ShuffleAndDecrypt = ({ dispatch, state }: StateAndDispatch): JSX.El
<Trustees {...{ state }} />
{/* Do we have a private keyshare stored? */}
<br />
<PrivateBox>
<p>Your Private keyshare is: {private_keyshare}</p>
</PrivateBox>
<PrivateKeyshare {...{ private_keyshare }} />
{/* All Accepted Votes */}
<AcceptedVotes {...{ ballot_design }} title_prefix="II. " />
<AcceptedVotes {...{ ballot_design }} allow_truncation title_prefix="II. " />
{/* Are there new votes shuffled from the trustee ahead of us that we need to shuffle? */}
<VotesToShuffle {...{ dispatch, final_shuffle_verifies, set_final_shuffle_verifies, state }} />
<VotesToShuffle
{...{ dispatch, final_shuffle_verifies, set_final_shuffle_verifies, skip_shuffle_proofs, state }}
/>
{/* Are there fully shuffled votes we need to partially decrypt? */}
<VotesToDecrypt {...{ dispatch, final_shuffle_verifies, state }} />

View File

@@ -2,7 +2,7 @@
import { LoadingOutlined } from '@ant-design/icons'
import bluebird from 'bluebird'
import { mapValues } from 'lodash-es'
import { Dispatch, Fragment, SetStateAction, useEffect, useReducer, useState } from 'react'
import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'
import { RP } from 'src/crypto/curve'
import { destringifyPartial, stringifyPartial } from 'src/crypto/stringify-partials'
@@ -15,6 +15,8 @@ import {
} from '../../crypto/threshold-keygen'
import { Partial, StateAndDispatch } from '../trustee-state'
import { YouLabel } from '../YouLabel'
import { useTruncatedTable } from './useTruncatedTable'
import { sortColumnsForTrustees } from './VotesToShuffle'
type Partials = Record<string, Partial[]>
type Validations_Table = Record<string, Record<string, (boolean | null)[]>>
@@ -100,7 +102,7 @@ export const VotesToDecrypt = ({
async function partialDecryptFinalShuffle() {
console.log(
`Last trusteee has shuffled: ${num_last_shuffled}, We decrypted: ${num_we_decrypted}. Beginning partial decryption...`,
`Last trustee has shuffled: ${num_last_shuffled}, We decrypted: ${num_we_decrypted}. Beginning partial decryption...`,
)
// Partially decrypt each item in every list
@@ -139,14 +141,22 @@ export const VotesToDecrypt = ({
return (
<>
<h3>IV. Votes to Decrypt</h3>
<ol>
<ol className="pl-5">
{trustees?.map(({ email, partials, you }) => (
<li key={email}>
{email}
{you && <YouLabel />} partially decrypted {!partials ? 0 : Object.values(partials)[0].length} votes.
{partials && (
<ValidationSummary {...{ email, partials, proofs_shown, set_proofs_shown, validated_proofs }} />
)}
<li className="mb-8" key={email}>
{/* Top row */}
<div className="flex flex-col justify-between sm:flex-row">
{/* Left */}
<span>
{email}
{you && <YouLabel />} partially decrypted {!partials ? 0 : Object.values(partials)[0].length}
&nbsp;votes.
</span>
{/* Right */}
{partials && (
<ValidationSummary {...{ email, partials, proofs_shown, set_proofs_shown, validated_proofs }} />
)}
</div>
{partials && (
<>
<PartialsTable {...{ email, partials, validated_proofs }} />
@@ -156,11 +166,6 @@ export const VotesToDecrypt = ({
</li>
))}
</ol>
<style jsx>{`
li {
margin-bottom: 2rem;
}
`}</style>
</>
)
}
@@ -175,77 +180,47 @@ const PartialsTable = ({
validated_proofs: Validations_Table
}): JSX.Element => {
const trustees_validations = validated_proofs[email]
const columns = sortColumnsForTrustees(Object.keys(partials))
const { TruncationToggle, rows_to_show } = useTruncatedTable({
num_cols: columns.length,
num_rows: Object.values(partials)[0].length,
})
if (!trustees_validations) return <></>
const columns = Object.keys(partials)
return (
<table>
<thead>
<tr>
<th></th>
{columns.map((c) => (
<th key={c}>{c}</th>
))}
</tr>
</thead>
<tbody>
{partials[columns[0]].map((_, index) => (
<tr key={index}>
<td>{index + 1}.</td>
{columns.map((key) => {
const validated = trustees_validations[key][index]
return (
<Fragment key={key}>
<td className="monospaced">
<div>
<>
<table className="block w-full pb-3 overflow-auto border-collapse [&_tr>*]:[border:1px_solid_#ccc] [&_tr>*]:px-2.5 [&_tr>td]:pr-5 [&_tr>*]:py-[3px]">
<thead className="text-[11px] text-center">
<tr>
<th></th>
{columns.map((c) => (
<th key={c}>{c}</th>
))}
</tr>
</thead>
<tbody className="[&>tr>*]:max-w-[239px]">
{partials[columns[0]].slice(0, rows_to_show).map((_, index) => (
<tr key={index}>
<td className="!pr-2.5">{index + 1}.</td>
{columns.map((key) => {
const validated = trustees_validations[key][index]
return (
<td className="font-mono text-[10px]" key={key}>
<div className="relative">
{partials[key][index].partial}{' '}
<span>{validated === null ? <LoadingOutlined /> : validated ? '' : '❌'}</span>
<span className="absolute top-2 -right-3.5 text-[10px] opacity-30">
{validated === null ? <LoadingOutlined /> : validated ? '' : '❌'}
</span>
</div>
</td>
</Fragment>
)
})}
</tr>
))}
</tbody>
<style jsx>{`
table {
border-collapse: collapse;
display: block;
overflow: auto;
margin-bottom: 15px;
}
th,
td {
border: 1px solid #ccc;
padding: 3px 10px;
padding-right: 20px;
margin: 0;
max-width: 250px;
}
td div {
position: relative;
}
td span {
position: absolute;
top: 1px;
right: -16px;
font-size: 10px;
opacity: 0.3;
}
td.monospaced {
font-family: monospace;
}
th,
.subheading td {
font-size: 11px;
font-weight: 700;
}
`}</style>
</table>
)
})}
</tr>
))}
</tbody>
</table>
<TruncationToggle />
</>
)
}
@@ -254,14 +229,9 @@ const DecryptionProof = ({ partials }: { partials: Partials }) => (
{Object.keys(partials).map((column) => (
<div key={column}>
<h4>{column}</h4>
<code>{JSON.stringify(partials[column])}</code>
<code className="text-[13px]">{JSON.stringify(partials[column])}</code>
</div>
))}
<style jsx>{`
code {
font-size: 13px;
}
`}</style>
</>
)
@@ -288,30 +258,16 @@ const ValidationSummary = ({
const num_total_partials = !partials ? 0 : num_votes_decrypted * Object.keys(partials).length
return (
<i>
<i className="sm:text-right text-[11px] block">
{!!num_partials_passed && num_partials_passed === num_total_partials && '✅ '}
{num_partials_passed} of {num_total_partials} Decryption Proofs verified (
<a className="show-proof" onClick={() => set_proofs_shown({ ...proofs_shown, [email]: !proofs_shown[email] })}>
<a
className="font-mono cursor-pointer"
onClick={() => set_proofs_shown({ ...proofs_shown, [email]: !proofs_shown[email] })}
>
{proofs_shown[email] ? '-Hide' : '+Show'}
</a>
)
<style jsx>{`
i {
font-size: 11px;
display: block;
}
@media (min-width: 600px) {
i {
float: right;
}
}
.show-proof {
cursor: pointer;
font-family: monospace;
}
`}</style>
</i>
)
}

View File

@@ -4,20 +4,27 @@ import bluebird from 'bluebird'
import { mapValues } from 'lodash-es'
import { Dispatch, Fragment, SetStateAction, useEffect, useReducer, useState } from 'react'
import { RP } from 'src/crypto/curve'
import { destringifyShuffle, stringifyShuffle } from 'src/crypto/stringify-shuffle'
import { destringifyShuffle, stringifyShuffle, stringifyShuffleWithoutProof } from 'src/crypto/stringify-shuffle'
import { api } from '../../api-helper'
import { rename_to_c1_and_2, shuffle } from '../../crypto/shuffle'
import { rename_to_c1_and_2, shuffleWithProof, shuffleWithoutProof } from '../../crypto/shuffle'
import { verify_shuffle_proof } from '../../crypto/shuffle-proof'
import { Shuffled, StateAndDispatch } from '../trustee-state'
import { YouLabel } from '../YouLabel'
import { useTruncatedTable } from './useTruncatedTable'
type Validations_Table = Record<string, { columns: Record<string, boolean | null>; num_votes: number }>
const nbsp = '\u00A0'
export const VotesToShuffle = ({
set_final_shuffle_verifies,
skip_shuffle_proofs = false,
state,
}: StateAndDispatch & { set_final_shuffle_verifies: Dispatch<SetStateAction<boolean>> }) => {
}: StateAndDispatch & {
set_final_shuffle_verifies: Dispatch<SetStateAction<boolean>>
skip_shuffle_proofs?: boolean
}) => {
const { own_index, threshold_public_key, trustees = [] } = state
const [proofs_shown, set_proofs_shown] = useState<Record<string, boolean>>({})
@@ -76,19 +83,23 @@ export const VotesToShuffle = ({
// Begin (async) validating each proof...
Object.keys(trustee_validations).forEach((column) => {
// Inputs are the previous party's outputs
// except for admin, who provides the original split list.
const inputs = index > 0 ? trustees[index - 1].shuffled![column].shuffled : trustees[0].preshuffled![column]
if (skip_shuffle_proofs) {
set_validated_proofs({ column, email, result: true, type: 'UPDATE' })
} else {
// Inputs are the previous party's outputs
// except for admin, who provides the original split list.
const inputs = index > 0 ? trustees[index - 1].shuffled![column].shuffled : trustees[0].preshuffled![column]
const { proof, shuffled: shuffledCol } = destringifyShuffle(shuffled[column])
const { proof, shuffled: shuffledCol } = destringifyShuffle(shuffled[column])
verify_shuffle_proof(
rename_to_c1_and_2(inputs.map((c) => mapValues(c, RP.fromHex))),
rename_to_c1_and_2(shuffledCol),
proof,
).then((result) => {
set_validated_proofs({ column, email, result, type: 'UPDATE' })
})
verify_shuffle_proof(
rename_to_c1_and_2(inputs.map((c) => mapValues(c, RP.fromHex))),
rename_to_c1_and_2(shuffledCol),
proof,
).then((result) => {
set_validated_proofs({ column, email, result, type: 'UPDATE' })
})
}
})
})
}, [num_shuffled_from_trustees])
@@ -104,15 +115,20 @@ export const VotesToShuffle = ({
// Do a SIV shuffle (permute + re-encryption) for each item's list
const shuffled = await bluebird.props(
mapValues(prev_trustees_shuffled, async (list) =>
stringifyShuffle(
await shuffle(
RP.fromHex(threshold_public_key!),
list.shuffled.map((c) => mapValues(c, RP.fromHex)),
),
),
),
mapValues(prev_trustees_shuffled, async (list) => {
const shuffleArgs: Parameters<typeof shuffleWithProof> = [
RP.fromHex(threshold_public_key!),
list.shuffled.map((c) => mapValues(c, RP.fromHex)),
]
if (skip_shuffle_proofs) {
return stringifyShuffleWithoutProof(await shuffleWithoutProof(...shuffleArgs))
} else {
return stringifyShuffle(await shuffleWithProof(...shuffleArgs))
}
}),
)
console.log('Shuffled complete.')
// Tell admin our new shuffled list
api(`election/${state.election_id}/trustees/update`, {
@@ -140,14 +156,33 @@ export const VotesToShuffle = ({
return (
<>
<h3>III. Votes to Shuffle</h3>
<ol>
<ol className="pl-5">
{trustees?.map(({ email, shuffled, you }) => (
<li key={email}>
{email}
{you && <YouLabel />} shuffled {!shuffled ? '0' : Object.values(shuffled)[0].shuffled.length} votes.
{shuffled && (
<ValidationSummary {...{ email, proofs_shown, set_proofs_shown, shuffled, validated_proofs }} />
)}
<li className="mb-8" key={email}>
{/* Top row above table */}
<div className="flex flex-col justify-between sm:flex-row">
{/* Left */}
<span>
{email}
{you && <YouLabel />} shuffled {!shuffled ? '0' : Object.values(shuffled)[0].shuffled.length}&nbsp;votes
{shuffled && `${nbsp}x${nbsp}${Object.keys(shuffled).length}${nbsp}columns`}.
</span>
{/* Right */}
{shuffled && (
<ValidationSummary
{...{
email,
proofs_shown,
set_proofs_shown,
shuffled,
skip_shuffle_proofs,
validated_proofs,
}}
/>
)}
</div>
{/* Table */}
{shuffled && (
<>
<ShuffledVotesTable {...{ email, shuffled, validated_proofs }} />
@@ -157,11 +192,6 @@ export const VotesToShuffle = ({
</li>
))}
</ol>
<style jsx>{`
li {
margin-bottom: 2rem;
}
`}</style>
</>
)
}
@@ -176,96 +206,73 @@ const ShuffledVotesTable = ({
validated_proofs: Validations_Table
}): JSX.Element => {
const trustees_validations = validated_proofs && validated_proofs[email]
const columns = Object.keys(shuffled)
const columns = sortColumnsForTrustees(Object.keys(shuffled))
const { TruncationToggle, rows_to_show } = useTruncatedTable({
num_cols: columns.length,
num_rows: Object.values(shuffled)[0].shuffled.length,
})
return (
<table>
<thead>
<tr>
<th></th>
{columns.map((c) => {
const verified = trustees_validations ? trustees_validations.columns[c] : null
return (
<th colSpan={2} key={c}>
{c} <span>{verified === null ? <LoadingOutlined /> : verified ? '' : '❌'}</span>
</th>
)
})}
</tr>
</thead>
<tbody>
{/* Column subheadings */}
<tr className="subheading">
<td></td>
{columns.map((c) => (
<Fragment key={c}>
<td>encrypted</td>
<td>lock</td>
</Fragment>
))}
</tr>
{shuffled[columns[0]].shuffled.map((_, index) => (
<tr key={index}>
<td>{index + 1}.</td>
{columns.map((key) => {
const cipher = shuffled[key].shuffled[index]
<>
<table className="block w-full pb-3 overflow-auto border-collapse [&_tr>*]:[border:1px_solid_#ccc] [&_tr>*]:px-2.5 [&_tr>*]:py-[3px] [&_tr>*]:max-w-[227px]">
<thead className="text-[11px] text-center">
<tr>
<td rowSpan={2}></td>
{columns.map((c) => {
const verified = trustees_validations ? trustees_validations.columns[c] : null
return (
<Fragment key={key}>
<td className="monospaced">{cipher.encrypted}</td>
<td className="monospaced">{cipher.lock}</td>
</Fragment>
<th colSpan={2} key={c}>
{c}{' '}
<span className="pl-[5px] opacity-50">
{verified === null ? <LoadingOutlined /> : verified ? '' : '❌'}
</span>
</th>
)
})}
</tr>
))}
</tbody>
<style jsx>{`
table {
border-collapse: collapse;
display: block;
overflow: auto;
margin-bottom: 10px;
}
th,
td {
border: 1px solid #ccc;
padding: 3px 10px;
margin: 0;
max-width: 240px;
}
td.monospaced {
font-family: monospace;
}
th,
.subheading td {
font-size: 11px;
font-weight: 700;
}
th span {
padding-left: 5px;
opacity: 0.6;
}
`}</style>
</table>
{/* Column subheadings */}
<tr>
{columns.map((c) => (
<Fragment key={c}>
<th>encrypted</th>
<th>lock</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{shuffled[columns[0]].shuffled.slice(0, rows_to_show).map((_, index) => (
<tr key={index}>
<td>{index + 1}.</td>
{columns.map((key) => {
const cipher = shuffled[key].shuffled[index]
return (
<Fragment key={key}>
<td className="font-mono text-[10px]">{cipher.encrypted}</td>
<td className="font-mono text-[10px]">{cipher.lock}</td>
</Fragment>
)
})}
</tr>
))}
</tbody>
</table>
<TruncationToggle />
</>
)
}
const ShuffleProof = ({ shuffled }: { shuffled: Shuffled }) => (
<>
{Object.keys(shuffled).map((column) => (
{sortColumnsForTrustees(Object.keys(shuffled)).map((column) => (
<div key={column}>
<h4>{column}</h4>
<code>{JSON.stringify(shuffled[column].proof)}</code>
<h4>{column} shuffle proof</h4>
<code className="text-[13px] overflow-y-scroll max-h-96 block bg-black/5 p-2 rounded">
{JSON.stringify(shuffled[column].proof)}
</code>
</div>
))}
<style jsx>{`
code {
font-size: 13px;
}
`}</style>
</>
)
@@ -273,40 +280,30 @@ const ValidationSummary = ({
email,
proofs_shown,
set_proofs_shown,
skip_shuffle_proofs,
validated_proofs,
}: {
email: string
proofs_shown: Record<string, boolean>
set_proofs_shown: Dispatch<SetStateAction<Record<string, boolean>>>
skip_shuffle_proofs: boolean
validated_proofs: Validations_Table
}) => {
const validations = validated_proofs[email]
return (
<i>
{all_proofs_passed(validations) && '✅ '}
{num_proofs_passed(validations)} of {num_total_proofs(validations)} Shuffle Proofs verified (
<a className="show-proof" onClick={() => set_proofs_shown({ ...proofs_shown, [email]: !proofs_shown[email] })}>
{proofs_shown[email] ? '-Hide' : '+Show'}
</a>
)
<style jsx>{`
i {
font-size: 11px;
display: block;
}
@media (min-width: 600px) {
i {
float: right;
}
}
.show-proof {
cursor: pointer;
font-family: monospace;
}
`}</style>
<i className="text-[11px] block sm:text-right">
{skip_shuffle_proofs ? '⏸️ ' : all_proofs_passed(validations) && '✅ '}
{num_proofs_passed(validations)} of {num_total_proofs(validations)} Shuffle Proofs{' '}
{skip_shuffle_proofs ? 'skipped' : 'verified'}{' '}
{!skip_shuffle_proofs && (
<a
className="font-mono cursor-pointer"
onClick={() => set_proofs_shown({ ...proofs_shown, [email]: !proofs_shown[email] })}
>
{proofs_shown[email] ? '-Hide' : '+Show'}
</a>
)}
</i>
)
}
@@ -319,3 +316,21 @@ const num_total_proofs = (validations: Validations_Table['email']) =>
const all_proofs_passed = (validations: Validations_Table['email']) =>
!!num_proofs_passed(validations) && num_proofs_passed(validations) === num_total_proofs(validations)
/** First tries to sort alphabetically, then numerically.
Useful for sorting vote column names when you don't have ballot_schema easily accessible.
If you do, just use generateColumns(), which will preserve ballot display order too. */
export function sortColumnsForTrustees(data: string[]): string[] {
return [...data].sort((a, b) => {
// Extract the non-numeric and numeric parts of the strings
const regex = /^(.*?)(\d*)$/
const [, textA, numA] = a.match(regex) || []
const [, textB, numB] = b.match(regex) || []
// Compare the textual part
if (textA !== textB) return textA.localeCompare(textB)
// If textual part is the same, compare the numeric part (if present)
return (+numA || 0) - (+numB || 0)
})
}

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react'
export const useTruncatedTable = ({
max_cells_to_show = 50,
num_cols,
num_rows,
}: {
max_cells_to_show?: number
num_cols: number
num_rows: number
}) => {
const num_truncated_rows = Math.floor(max_cells_to_show / num_cols)
const any_to_truncate = num_rows > num_truncated_rows
const [isTruncated, setTruncated] = useState(any_to_truncate)
useEffect(() => setTruncated(any_to_truncate), [any_to_truncate]) // In case data loads after init render
const rows_to_show = isTruncated ? num_truncated_rows : num_rows
function TruncationToggle() {
if (!any_to_truncate) return null
return (
<div>
<a className="text-xs text-center opacity-50 cursor-pointer" onClick={() => setTruncated(!isTruncated)}>
{isTruncated
? `...and ${Math.ceil(num_rows - num_truncated_rows)} more rows.`
: `Truncate to < ${max_cells_to_show} total selections.`}
</a>
</div>
)
}
return { TruncationToggle, rows_to_show }
}

View File

@@ -4,6 +4,7 @@ import { GlobalCSS } from '../GlobalCSS'
import { Ballot } from './Ballot'
import { ESignScreen } from './esign/ESignScreen'
import { Instructions } from './Instructions'
import { PrivacyProtectorsStatements } from './PrivacyProtectorsStatements'
import { storeElectionInfo } from './storeElectionInfo'
import { SubmitButton } from './SubmitButton'
import { SubmittedScreen } from './submitted/SubmittedScreen'
@@ -36,6 +37,7 @@ export const AuthenticatedContent = ({ auth, election_id }: { auth: string; elec
<YourAuthToken {...{ auth, election_id }} />
<div className="fade-in">
<Instructions />
<PrivacyProtectorsStatements {...{ state }} />
<Ballot {...{ dispatch, election_id, state }} />
<SubmitButton {...{ auth, dispatch, election_id, state }} />
</div>

View File

@@ -6,6 +6,7 @@ import { build_permutation_array } from 'src/crypto/shuffle'
import { Paper } from '../protocol/Paper'
import { BallotPreview } from './BallotPreview'
import { BudgetItem } from './BudgetItem'
import { Item } from './Item'
import { MultiVoteItem } from './MultiVoteItem'
import { RankedChoiceItem } from './RankedChoiceItem'
@@ -31,7 +32,7 @@ export const Ballot = ({
return (
<NoSsr>
<Paper noFade className="!px-4 pt-4 overflow-x-scroll">
<Paper noFade className="!px-4 pt-4">
<>
<BallotPreview {...{ state }} />
@@ -93,6 +94,10 @@ export const Ballot = ({
if (item.type === 'score')
return <ScoreItem {...{ ...item, dispatch, options: shuffled, state }} key={index} />
// Is it "Budget"?
if (item.type === 'budget')
return <BudgetItem {...{ ...item, dispatch, options: shuffled, state }} key={index} />
// Otherwise, load default "Choose-only-one"
return <Item {...{ ...item, dispatch, options: shuffled, state }} key={index} />
})}

217
src/vote/BudgetItem.tsx Normal file
View File

@@ -0,0 +1,217 @@
import { Dispatch, Fragment, useEffect, useRef, useState } from 'react'
import { Switch } from 'src/admin/BallotDesign/Switch'
import { Label, TitleDescriptionQuestion } from './Item'
import { Linkify } from './Linkify'
import { Item as ItemType } from './storeElectionInfo'
import { State } from './vote-state'
export const BudgetItem = ({
budget_available = 0,
description,
dispatch,
id = 'vote',
options,
question,
state,
title,
toggleable_2_label,
toggleable_label,
}: ItemType & {
dispatch: Dispatch<Record<string, string>>
election_id?: string
state: State
}): JSX.Element => {
// console.log('state.plaintext:', state.plaintext)
const [showToggleables, setShowToggleables] = useState(false)
const [showToggleable2, setShowToggleable2] = useState(false)
const itemRefs = useRef<HTMLTableRowElement[]>([])
const headerRef = useRef<HTMLDivElement>(null)
/** Find the option currently visible */
function findCurrentTopItemInView() {
const headerHeight = headerRef.current?.offsetHeight ?? 0
return itemRefs.current.find((ref) => {
const rect = ref.getBoundingClientRect()
return rect.top >= headerHeight
})
}
function scrollToCurrentTopItemInView(currentTopItem?: HTMLTableRowElement) {
if (!currentTopItem) return
if (currentTopItem === itemRefs.current[0]) return
const headerHeight = headerRef.current?.offsetHeight ?? 0
// Ensure the component updates before adjusting the scroll
setTimeout(() => {
const offsetTop = currentTopItem.getBoundingClientRect().top - headerHeight
window.scrollTo({ top: window.scrollY + offsetTop })
}, 0)
}
const sum = options.reduce((sum, { value }) => {
const amount = state.plaintext[`${id}_${value}`]
if (amount === 'BLANK') return sum
const number = Number(amount)
if (isNaN(number)) return sum
if (number < 0) return sum
return sum + number
}, 0)
const remaining = budget_available - sum
// On first load, set all scores to 'BLANK'
useEffect(() => {
const update: Record<string, string> = {}
options.forEach(({ name, value }) => {
const val = value || name
const key = `${id}_${val}`
// Stop if we already have a stored value
if (state.plaintext[key]) return
update[key] = `BLANK`
})
dispatch(update)
}, [])
return (
<>
<TitleDescriptionQuestion {...{ description, question, title }} />
<div className="sticky top-0 z-10 bg-white" ref={headerRef}>
{/* Budget Available */}
<div className="py-3 text-center">
<div
className={`inline-block px-1 border-2 ${
remaining >= 0 ? 'border-green-600' : 'border-red-600'
} border-solid rounded`}
>
Budget Available: ${remaining} of ${budget_available}
</div>
{remaining < 0 && <div className="px-1 pt-1 font-semibold text-red-500">You{"'"}ve exceeded the budget.</div>}
</div>
<div className="sm:ml-3">
<div className="text-xs opacity-60">Show:</div>
<div className="flex justify-between py-1.5">
{toggleable_label && (
<div
className="inline-block px-2 border border-solid rounded-lg cursor-pointer border-black/20"
onClick={() => {
const currentTopItem = findCurrentTopItemInView()
setShowToggleables(!showToggleables)
scrollToCurrentTopItemInView(currentTopItem)
}}
>
<Switch checked={showToggleables} label="" onClick={() => {}} />
<span className="text-xs">{toggleable_label}</span>
</div>
)}
{toggleable_2_label && (
<div
className="inline-block px-2 pb-3 border border-solid rounded-lg cursor-pointer sm:ml-3 border-black/20"
onClick={() => {
const currentTopItem = findCurrentTopItemInView()
setShowToggleable2(!showToggleable2)
scrollToCurrentTopItemInView(currentTopItem)
}}
>
<Switch checked={showToggleable2} label="" onClick={() => {}} />
<span className="text-xs">{toggleable_2_label}</span>
</div>
)}
</div>
</div>
</div>
<table className="sm:ml-3">
{/* List one row for each candidate */}
<tbody>
{options.map(({ name, sub, toggleable, toggleable_2, value }, index) => {
const val = value || name
const current = state.plaintext[`${id}_${val}`] || ''
return (
<Fragment key={val}>
<tr key={name} ref={(el: HTMLTableRowElement) => (itemRefs.current[index] = el)}>
<td className="relative pr-4 bottom-0.5 pt-6">
<Label {...{ name, sub }} number={index + 1} />
{showToggleables && toggleable && (
<div className="text-xs">
{toggleable_label ? toggleable_label + ': ' : ''}
{toggleable.slice(0, -3)}
</div>
)}
</td>
{/* Column for input box */}
<td className="relative ml-2">
<div className="absolute pt-1.5 text-xl left-2 opacity-50">$</div>
<input
className="w-20 h-10 px-1 text-lg text-right bg-white border-2 border-gray-300 border-solid rounded appearance-none cursor-pointer hover:border-blue-600"
value={current === 'BLANK' ? '' : current}
onChange={(event) => {
const update: Record<string, string> = {}
const change = event.target.value
const key = `${id}_${val}`
update[key] = `${change}`
// Are they deselecting their existing selection?
if (!change) update[key] = 'BLANK'
dispatch(update)
}}
/>
</td>
</tr>
{showToggleable2 && toggleable_2 && (
<tr className="!pb-10">
<td className="px-2 pt-2 pb-4 text-xs border border-solid rounded border-black/30" colSpan={2}>
{toggleable_2.type && (
<div>
Type:{' '}
<span
className={`px-1 rounded ${
{
Docs: 'bg-purple-200',
Implementation: 'bg-yellow-200',
Protocol: 'bg-blue-600 text-white/90',
}[toggleable_2.type]
}`}
>
{toggleable_2.type}
</span>
</div>
)}
<Linkify>
{toggleable_2.like && (
<div className="mt-1 whitespace-break-spaces">
<div className="font-semibold">In favor:</div>
{toggleable_2.like}
</div>
)}
{toggleable_2.dislike && (
<div className="mt-1 whitespace-break-spaces">
<div className="font-semibold ">Against:</div>
{toggleable_2.dislike}
</div>
)}
</Linkify>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
<br />
</>
)
}

View File

@@ -3,6 +3,8 @@ import router from 'next/router'
import { useRef, useState } from 'react'
import { OnClickButton } from 'src/_shared/Button'
export const exampleAuthToken = '22671df063'
export const EnterAuthToken = () => {
const [error, setError] = useState('')
const [text, setText] = useState('')
@@ -48,7 +50,7 @@ export const EnterAuthToken = () => {
</OnClickButton>
</div>
<p className="opacity-60">
<i>Example:</i> 22671df063
<i>Example:</i> {exampleAuthToken}
<br />
<br />
Auth tokens are 10 characters long, made up of the numbers <i>09</i> and the letters <i>af</i>.

View File

@@ -1,8 +1,8 @@
import { FormControlLabel, Radio, RadioGroup, TextField } from '@mui/material'
import { Dispatch, useState } from 'react'
import Linkify from 'react-linkify'
import { max_string_length } from './Ballot'
import { Linkify } from './Linkify'
import { Item as ItemType } from './storeElectionInfo'
import { State } from './vote-state'
@@ -90,10 +90,23 @@ export const Item = ({
)
}
export const Label = ({ name, nameClassName, sub }: { name: string; nameClassName?: string; sub?: string }) => (
export const Label = ({
name,
nameClassName,
number,
sub,
}: {
name: string
nameClassName?: string
number?: number
sub?: string
}) => (
<div className="my-2">
<Linkify>
<span className={`font-bold opacity-95 ${nameClassName}`}>{name}</span>
<span className={`font-bold opacity-95 ${nameClassName}`}>
{number && <span className="text-xs font-light opacity-50">{number}. </span>}
{name}
</span>
{sub && <p className="m-0 text-[12px] opacity-75">{sub}</p>}
</Linkify>
</div>
@@ -109,14 +122,7 @@ export const TitleDescriptionQuestion = ({
title?: string
}) => (
<>
<Linkify
componentDecorator={(decoratedHref, decoratedText, key) => (
<a href={decoratedHref} key={key} target="blank">
{/* Shorten to domain name only if possible */}
{(decoratedText.match(/\w*?\.(com|org)/) || [decoratedText])[0]}
</a>
)}
>
<Linkify>
{title && <p className="title sm:px-[13px] py-[5px] mb-2.5">{title}</p>}
{description && <p className="whitespace-pre-wrap sm:m-[13px] mb-0">{description}</p>}
{question && <p className="whitespace-pre-wrap sm:m-[13px]">{question}</p>}

21
src/vote/Linkify.tsx Normal file
View File

@@ -0,0 +1,21 @@
import ReactLinkify from 'react-linkify'
export const Linkify = ({ children }: { children: React.ReactNode }) => (
<ReactLinkify
componentDecorator={(decoratedHref, decoratedText, key) =>
onBlockList(decoratedHref) ? (
decoratedText
) : (
<a href={decoratedHref} key={key} rel="noreferrer" target="_blank">
{decoratedText}
</a>
)
}
{...{ children }}
/>
)
/** URLs that _shouldn't_ be Linkified */
function onBlockList(url: string) {
return url.endsWith('.md')
}

View File

@@ -0,0 +1,13 @@
import { State } from './vote-state'
export const PrivacyProtectorsStatements = ({ state }: { state: State }) => {
if (!state.privacy_protectors_statements) return null
return (
<div className="pl-5 mb-3 -mt-3 opacity-50">
<a href={state.privacy_protectors_statements} rel="noreferrer" target="_blank">
Privacy Protectors{"'"} Statements
</a>
</div>
)
}

View File

@@ -27,8 +27,8 @@ export const generateColumnNames = ({ ballot_design }: { ballot_design?: Item[]
return new Array(amount).fill('').map((_, index) => `${id}_${index + 1}`)
}
// Score expects a vote for each of the question's options
if (type === 'score') return options.map(({ name }) => `${id}_${name}`)
// '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")
return id

View File

@@ -4,15 +4,24 @@ import { ElectionInfo } from '../../pages/api/election/[election_id]/info'
import { State } from './vote-state'
export type Item = {
budget_available?: number
description?: string
id?: string
max_score?: number
min_score?: number
multiple_votes_allowed?: number
options: { name: string; sub?: string; value?: string }[]
options: {
name: string
sub?: string
toggleable?: string
toggleable_2?: Partial<Record<string, string>>
value?: string
}[]
question?: string
randomize_order?: boolean
title: string
toggleable_2_label?: string
toggleable_label?: string
type?: string
write_in_allowed: boolean
}
@@ -30,6 +39,7 @@ export function storeElectionInfo(dispatch: Dispatch<Partial<State>>, election_i
ballot_design_finalized,
election_title,
esignature_requested,
privacy_protectors_statements,
submission_confirmation,
threshold_public_key,
}: ElectionInfo = await response.json()
@@ -39,6 +49,7 @@ export function storeElectionInfo(dispatch: Dispatch<Partial<State>>, election_i
ballot_design_finalized,
election_title,
esignature_requested,
privacy_protectors_statements,
public_key: threshold_public_key,
submission_confirmation,
})

View File

@@ -8,7 +8,13 @@ export const useLocalStorageReducer = (
defaultValue: ReducerParams[1],
) => {
const stored = localStorage.getItem(storage_key)
const initial = stored ? JSON.parse(stored) : defaultValue
let initial
try {
initial = stored ? JSON.parse(stored) : defaultValue
} catch (error) {
alert(`Error parsing localStorage: ${storage_key}${JSON.stringify(error)}`)
throw error
}
const [state, dispatch] = useReducer(reducer, initial)
useEffect(() => {

View File

@@ -18,6 +18,7 @@ export type State = {
esignature_requested?: boolean
last_modified_at?: Date
plaintext: Map
privacy_protectors_statements?: string
public_key?: string
randomizer: Map
submission_confirmation?: string