From a073a678f089b1173beea10bc2d56f1ceba96e25 Mon Sep 17 00:00:00 2001 From: David Ernst Date: Tue, 2 May 2023 20:46:10 +0300 Subject: [PATCH] Count holes script: include num successful BLANKs, subsets, emails --- db-data/2023-05-02-count-encryption-holes.ts | 122 ++++++++++++++++--- src/status/tally-votes.ts | 2 + 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/db-data/2023-05-02-count-encryption-holes.ts b/db-data/2023-05-02-count-encryption-holes.ts index 9d62d2e7..e398566d 100644 --- a/db-data/2023-05-02-count-encryption-holes.ts +++ b/db-data/2023-05-02-count-encryption-holes.ts @@ -1,8 +1,15 @@ import './_env' +import { inspect } from 'util' + +import { keyBy, mapValues, pick } from 'lodash' import UAParser from 'ua-parser-js' import { firebase } from '../pages/api/_services' +import { RP, pointToString } from '../src/crypto/curve' +import decrypt from '../src/crypto/decrypt' +import { CipherStrings } from '../src/crypto/stringify-shuffle' +import { tallyVotes } from '../src/status/tally-votes' const election_id = '1680323766282' @@ -12,33 +19,114 @@ const expectedSelections = 4 const db = firebase.firestore() const electionDoc = db.collection('elections').doc(election_id) + const election = electionDoc.get() + const { ballot_design: ballot_design_string } = { ...(await election).data() } as { ballot_design: string } + const ballot_design = JSON.parse(ballot_design_string) as { id: string }[] + + // Get unlocking key + const { ADMIN_EMAIL } = process.env + if (!ADMIN_EMAIL) throw 'Missing process.env.ADMIN_EMAIL' + const admin = electionDoc.collection('trustees').doc(ADMIN_EMAIL).get() + const { private_keyshare: decryption_key } = { ...(await admin).data() } as { private_keyshare: string } + // Download all submitted encrypted votes const votesDocs = await electionDoc.collection('votes').get() + const totalNumBlanks = {} + // Look through each, if it has any holes, mark the auth_token and number of holes - const votesWithHoles: Record = {} - votesDocs.docs.forEach((doc) => { - const { auth, encrypted_vote, headers } = doc.data() - const holes = expectedSelections - Object.keys(encrypted_vote).length - if (holes) { + const votesWithHoles: Record< + number, + Record; device: string; email: string; holes: number }> + > = {} + await Promise.all( + votesDocs.docs.map(async (doc) => { + const { auth, encrypted_vote, headers } = doc.data() + + // Decrypt the vote + const decryptedWithVerification = mapValues(encrypted_vote as Record, (cipher) => + pointToString(decrypt(BigInt(decryption_key), mapValues(cipher, RP.fromHex))), + ) + // Separate Verification # from other fields + let numBlanks = 0 + const decrypted: Record = {} + Object.entries(decryptedWithVerification).forEach(([key, value], index) => { + const [unpadded_tracking, selection] = value.split(':') + const tracking = unpadded_tracking.padStart(14, '0') + + // Count 'BLANK's + if (selection === 'BLANK') numBlanks++ + + // Store tracking if first + if (index === 0) decrypted.tracking = tracking + + decrypted[key] = selection + }) + + const holes = expectedSelections - Object.keys(encrypted_vote).length + + const key = `${numBlanks} BLANK, ${holes} hole` + totalNumBlanks[key] = (totalNumBlanks[key] || 0) + 1 + + if (!holes) return + + const voter = (await electionDoc.collection('voters').where('auth_token', '==', auth).get()).docs[0].data() const ua = UAParser(headers['user-agent']) - votesWithHoles[auth] = { + if (!votesWithHoles[holes]) votesWithHoles[holes] = {} + + votesWithHoles[holes][auth] = { + decrypted, device: `${ua.browser.name} ${ua.browser.version} on ${ua.os.name} ${ua.os.version}`, + email: voter.email, holes, } - } - }) + }), + ) - // Console log the list of all auth token votes with holes - console.log('Votes with holes:') - console.log(votesWithHoles) - - // And how many for each number of holes + // Summarize the hole'd votes const holesSummary = {} - Object.values(votesWithHoles).forEach(({ holes }) => { - const key = `${holes} hole${holes !== 1 ? 's' : ''}` - holesSummary[key] = (holesSummary[key] || 0) + 1 + Object.entries(votesWithHoles).forEach(([numHoles, votes]) => { + const numOfType = Object.keys(votes).length + const key = `${numOfType} with ${numHoles} hole${+numHoles !== 1 ? 's' : ''} (${formatPercentage( + numOfType / votesDocs.docs.length, + )})` + + // Sum up the results from just these votes + const { tallies, totalsCastPerItems } = tallyVotes( + keyBy(ballot_design, 'id'), + Object.values(votes).map((v) => v.decrypted), + ) + const talliesWithPcts: Record> = {} + ballot_design + .map((i) => i.id) + .forEach((contest_id) => { + const contest_results = tallies[contest_id] + talliesWithPcts[contest_id] = mapValues(contest_results, (tally): [number, string] => { + const percentage = ((tally / totalsCastPerItems[contest_id]) * 100).toFixed(1) + '%' + return [tally, percentage] + }) + }) + + holesSummary[key] = { _subset_tally: talliesWithPcts, ...mapValues(votes, (v) => pick(v, ['email', 'device'])) } }) - console.log(holesSummary) + console.log(`Reviewed ${votesDocs.docs.length} encrypted votes`) + console.log('Votes with holes:', inspect(holesSummary, { depth: null })) + + console.log('Num total blanks', totalNumBlanks) })() + +/** Converts a decimal number to a percentage string with up to 2 decimal places. */ +const formatPercentage = (pct: number) => { + const numDecimals = pct.toString().split('.')[1]?.length ?? 0 + const precision = Math.min(numDecimals, 2) + const formattedPct = parseFloat((pct * 100).toFixed(precision)).toString() + return `${formattedPct}%` +} +// testCases(formatPercentage, [ +// [[0.7], '70%'], +// [[0.21], '21%'], +// [[0.063], '6.3%'], +// [[0.0189], '1.89%'], +// [[0.00564], '0.56%'] +// ]) diff --git a/src/status/tally-votes.ts b/src/status/tally-votes.ts index 2631a713..4388b431 100644 --- a/src/status/tally-votes.ts +++ b/src/status/tally-votes.ts @@ -47,5 +47,7 @@ export function tallyVotes(ballot_items_by_id: Record, votes: R ), ) as Record + // const ordered = {} + return { ordered, tallies, totalsCastPerItems } }