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