Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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);
|
||||
Reference in new issue
Block a user