mirror of
https://github.com/siv-org/siv.git
synced 2026-01-09 10:27:57 -05:00
Merge branch 'main' into HEAD
This commit is contained in:
@@ -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
10
.github/ISSUE_TEMPLATE/new-issue.md
vendored
Normal 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. -->
|
||||
42
README.md
42
README.md
@@ -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
39
cloud-services.md
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
123
db-data/2024-07-26-duplicate-election-subset.ts
Normal file
123
db-data/2024-07-26-duplicate-election-subset.ts
Normal 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',
|
||||
]
|
||||
@@ -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": {
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
202
pages/api/election/[election_id]/trustees/update-admin.ts
Normal file
202
pages/api/election/[election_id]/trustees/update-admin.ts
Normal 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 })
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
55
pages/api/hack-siv/register.ts
Normal file
55
pages/api/hack-siv/register.ts
Normal 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' })
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
39
src/admin/Voters/StopAcceptingVotes.tsx
Normal file
39
src/admin/Voters/StopAcceptingVotes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
184
src/status/tallyBudgetLogic.tsx
Normal file
184
src/status/tallyBudgetLogic.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
src/trustee/decrypt/PrivateKeyshare.tsx
Normal file
29
src/trustee/decrypt/PrivateKeyshare.tsx
Normal 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)
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} 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)
|
||||
})
|
||||
}
|
||||
|
||||
34
src/trustee/decrypt/useTruncatedTable.tsx
Normal file
34
src/trustee/decrypt/useTruncatedTable.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
217
src/vote/BudgetItem.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>0–9</i> and the letters <i>a–f</i>.
|
||||
|
||||
@@ -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
21
src/vote/Linkify.tsx
Normal 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')
|
||||
}
|
||||
13
src/vote/PrivacyProtectorsStatements.tsx
Normal file
13
src/vote/PrivacyProtectorsStatements.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user