Files
timebank-cc-public/resources/js/poll-ledger-verifier.js
T

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);