135 lines
5.7 KiB
JavaScript
135 lines
5.7 KiB
JavaScript
/*
|
|
* Poll ledger verifier. Runs in the voter's browser (and in Node for the tests), so checking a
|
|
* poll does not depend on the server's own word. It is inserted into the verification page as is:
|
|
* this file is the code that runs.
|
|
*
|
|
* Format (App\Polls\Ledger, format version 1):
|
|
* hash = SHA-256 of the canonical JSON of
|
|
* {v, poll_id, sequence, entry_type, fingerprint, refers_to_sequence, payload, recorded_at, previous_hash}
|
|
* Canonical JSON: object keys sorted by their UTF-8 bytes, lists in order, no whitespace,
|
|
* unicode and slashes unescaped. The first entry's previous_hash is 64 zeros.
|
|
* Ballots that count: "cast" / "change" entries no later entry refers to (via refers_to_sequence).
|
|
* Tally: per question, how often each distinct value was given, as a list of
|
|
* {question_id, value (canonical JSON), count} sorted by question_id, then value bytes.
|
|
*/
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
const GENESIS = '0'.repeat(64);
|
|
const encoder = new TextEncoder();
|
|
|
|
function byteCompare(a, b) {
|
|
const x = encoder.encode(a);
|
|
const y = encoder.encode(b);
|
|
const length = Math.min(x.length, y.length);
|
|
for (let i = 0; i < length; i++) {
|
|
if (x[i] !== y[i]) {
|
|
return x[i] - y[i];
|
|
}
|
|
}
|
|
return x.length - y.length;
|
|
}
|
|
|
|
function canonical(value) {
|
|
if (value === null || typeof value !== 'object') {
|
|
return JSON.stringify(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return '[' + value.map(canonical).join(',') + ']';
|
|
}
|
|
return '{' + Object.keys(value).sort(byteCompare)
|
|
.map((key) => JSON.stringify(key) + ':' + canonical(value[key]))
|
|
.join(',') + '}';
|
|
}
|
|
|
|
async function sha256(text) {
|
|
const digest = await root.crypto.subtle.digest('SHA-256', encoder.encode(text));
|
|
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function entryHashInput(entry) {
|
|
return {
|
|
v: entry.v,
|
|
poll_id: entry.poll_id,
|
|
sequence: entry.sequence,
|
|
entry_type: entry.entry_type,
|
|
fingerprint: entry.fingerprint,
|
|
refers_to_sequence: entry.refers_to_sequence,
|
|
payload: entry.payload,
|
|
recorded_at: entry.recorded_at,
|
|
previous_hash: entry.previous_hash,
|
|
};
|
|
}
|
|
|
|
function activeBallots(entries) {
|
|
const replaced = new Set(entries.filter((e) => e.refers_to_sequence !== null).map((e) => e.refers_to_sequence));
|
|
return entries.filter((e) => (e.entry_type === 'cast' || e.entry_type === 'change') && !replaced.has(e.sequence));
|
|
}
|
|
|
|
function tally(ballots) {
|
|
const counts = new Map();
|
|
for (const ballot of ballots) {
|
|
for (const answer of (ballot.payload && ballot.payload.answers) || []) {
|
|
if (answer.value === null || answer.value === undefined) {
|
|
continue;
|
|
}
|
|
const key = answer.question_id + '\u0000' + canonical(answer.value);
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
}
|
|
}
|
|
return Array.from(counts, ([key, count]) => {
|
|
const [questionId, value] = key.split('\u0000');
|
|
return { question_id: Number(questionId), value: value, count: count };
|
|
}).sort((a, b) => (a.question_id - b.question_id) || byteCompare(a.value, b.value));
|
|
}
|
|
|
|
/**
|
|
* Check a ledger as published by the polls.ledger route.
|
|
* @returns {Promise<{valid: boolean, checks: Array<{ok: boolean, key: string, detail: string}>}>}
|
|
*/
|
|
async function verify(data) {
|
|
const checks = [];
|
|
const check = (ok, key, detail) => checks.push({ ok: ok, key: key, detail: detail || '' });
|
|
const entries = data.entries || [];
|
|
|
|
// 1. The hash chain
|
|
let previous = GENESIS;
|
|
let chainOk = true;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
const hash = await sha256(canonical(entryHashInput(entry)));
|
|
if (entry.sequence !== i + 1 || entry.previous_hash !== previous || hash !== entry.hash) {
|
|
chainOk = false;
|
|
check(false, 'chain', 'broken at entry ' + entry.sequence);
|
|
break;
|
|
}
|
|
previous = entry.hash;
|
|
}
|
|
if (chainOk) {
|
|
check(true, 'chain', entries.length + ' entries, head ' + previous.slice(0, 16));
|
|
}
|
|
|
|
// 2. The recount against the published result
|
|
const recount = tally(activeBallots(entries));
|
|
check(canonical(recount) === canonical(data.published_tally || []), 'recount',
|
|
activeBallots(entries).length + ' ballots counted');
|
|
|
|
// 3. The seal, if the poll was closed
|
|
const sealEntry = entries.find((e) => e.entry_type === 'seal');
|
|
if (data.seal_hash || sealEntry) {
|
|
let sealOk = !!sealEntry && sealEntry.hash === data.seal_hash;
|
|
if (sealOk) {
|
|
const before = entries.filter((e) => e.sequence < sealEntry.sequence);
|
|
const sealedBallots = activeBallots(before);
|
|
sealOk = sealEntry.payload.active_ballots === sealedBallots.length
|
|
&& sealEntry.payload.tally_sha256 === await sha256(canonical(tally(sealedBallots)));
|
|
}
|
|
check(sealOk, 'seal', data.seal_hash ? data.seal_hash.slice(0, 16) : '');
|
|
}
|
|
|
|
return { valid: checks.every((c) => c.ok), checks: checks };
|
|
}
|
|
|
|
root.PollLedgerVerifier = { verify: verify, canonical: canonical, sha256: sha256, tally: tally };
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|