igneum/site/verify/core.js

161 lines
9.8 KiB
JavaScript

// Igneum light client, version zero: the checks, with no I/O and no DOM. verify.js wires this to /api/checkpoint
// and the homepage card; site/verify/test.html and a Node test call it with tampered data.
//
// What it verifies, from the node's own code (vendor/igneum-node/consensus/core/src/finality.rs,
// consensus/core/src/hashing/header.rs, crypto/hashes/src/hashers.rs):
// 1. Every header hash: BLAKE2b-256 keyed "BlockHash" over version_le16, level count_le64, per level
// (count_le64, parents), the three roots, timestamp_le64, bits_le32, nonce_le64, daa_le64, blue_score_le64,
// blue work as (len_le64, big-endian bytes without leading zeros), pruning point, vote_key_hash.
// 2. The chain: headers run from the previous locked checkpoint to the certified one, each one a direct parent
// of the next, the last one the certified checkpoint block.
// 3. Every voter's key: BLAKE2b-256 keyed "IgneumVoteKeyHash" over the 48-byte compressed G1 key equals the
// vote_key_hash the headers name; the list is in canonical order (sorted by key hash) and matches the
// certificate's voter count.
// 4. The certificate: the bitmap's public keys are summed in G1 and the 96-byte G2 aggregate signature is
// checked over `"igneum-vote-v1/" || chain_id || 0x00 || index_le64 || checkpoint_hash` under the tag
// IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_ (blst min-pubkey setting, fast_aggregate_verify).
// 5. The rule (spec 03 Q3): signed weight at least 2/3 of active weight (weight x participation) and at least
// 17/30 of total weight.
// `deps` is { blake2b, bls } from @noble/hashes and @noble/curves (pinned in verify.js).
export const DST_VOTE = 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_';
export const KEY_HASH_DOMAIN = 'IgneumVoteKeyHash';
export const BLOCK_HASH_DOMAIN = 'BlockHash';
const te = new TextEncoder();
export function hexToBytes(h) {
if (typeof h !== 'string' || h.length % 2 || /[^0-9a-f]/i.test(h)) throw new Error('bad hex');
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16);
return out;
}
export function bytesToHex(b) { let s = ''; for (const x of b) s += x.toString(16).padStart(2, '0'); return s; }
const u16 = v => { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, Number(v), true); return b; };
const u32 = v => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(v), true); return b; };
const u64 = v => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v), true); return b; };
function concat(parts) {
const n = parts.reduce((a, p) => a + p.length, 0); const m = new Uint8Array(n); let o = 0;
for (const p of parts) { m.set(p, o); o += p.length; }
return m;
}
// The header hash, byte for byte as hash_override_nonce_time writes it
export function headerHash(h, blake2b) {
const levels = h.parents_by_level || [];
const parts = [u16(h.version), u64(levels.length)];
for (const level of levels) { parts.push(u64(level.length)); for (const p of level) parts.push(hexToBytes(p)); }
parts.push(hexToBytes(h.hash_merkle_root), hexToBytes(h.accepted_id_merkle_root), hexToBytes(h.utxo_commitment),
u64(h.timestamp), u32(h.bits), u64(h.nonce), u64(h.daa_score), u64(h.blue_score));
const bw = String(h.blue_work).replace(/^0+/, '');
const bwBytes = bw.length ? hexToBytes(bw.length % 2 ? '0' + bw : bw) : new Uint8Array(0);
parts.push(u64(bwBytes.length), bwBytes, hexToBytes(h.pruning_point), hexToBytes(h.vote_key_hash));
return bytesToHex(blake2b(concat(parts), { dkLen: 32, key: te.encode(BLOCK_HASH_DOMAIN) }));
}
export function voteKeyHash(pubkey, blake2b) {
return bytesToHex(blake2b(pubkey, { dkLen: 32, key: te.encode(KEY_HASH_DOMAIN) }));
}
export function voteMessage(chainId, index, checkpointHex) {
const head = te.encode('igneum-vote-v1/' + chainId);
return concat([head, new Uint8Array([0]), u64(index), hexToBytes(checkpointHex)]);
}
export function signerPositions(bitmapHex, voterCount) {
const bm = hexToBytes(bitmapHex); const out = [];
for (let p = 0; p < voterCount; p++) if (bm[p >> 3] & (1 << (p & 7))) out.push(p);
return out;
}
const fail = (reason, extra = {}) => ({ verified: false, reason, ...extra });
// data: the /api/checkpoint body. Returns { verified, reason?, index, signers, weight_fraction_active,
// weight_fraction_total, headers_checked, ms, ... }.
export function verifyCheckpoint(data, deps) {
const t0 = (typeof performance !== 'undefined' ? performance : Date).now();
const done = r => ({ ...r, ms: Math.round(((typeof performance !== 'undefined' ? performance : Date).now() - t0) * 10) / 10 });
const { blake2b, bls } = deps;
try {
if (!data || !data.ok) return done(fail(data && data.error ? data.error : 'no checkpoint data'));
const cert = data.certificate || {};
const index = Number(data.index);
const voters = data.voters || [];
const headers = data.headers || [];
// 1 and 2: header hashes and the chain links
if (!headers.length) return done(fail('no headers'));
let checked = 0;
for (let i = 0; i < headers.length; i++) {
const h = headers[i];
const got = headerHash(h, blake2b);
if (got !== h.hash) return done(fail(`header ${i} hash does not recompute (${got.slice(0, 12)} vs ${String(h.hash).slice(0, 12)})`, { headers_checked: checked }));
if (i > 0) {
const direct = (h.parents_by_level && h.parents_by_level[0]) || [];
if (!direct.includes(headers[i - 1].hash)) return done(fail(`header ${i} does not name header ${i - 1} as a parent`, { headers_checked: checked }));
}
checked++;
}
const top = headers[headers.length - 1];
if (top.hash !== data.hash) return done(fail('the last header is not the certified checkpoint block', { headers_checked: checked }));
if (data.previous && headers[0].hash !== data.previous.hash) return done(fail('the first header is not the previous locked checkpoint', { headers_checked: checked }));
if (BigInt(top.blue_score) < 30n * BigInt(index)) return done(fail(`checkpoint ${index} needs blue score at least ${30 * index}, header has ${top.blue_score}`, { headers_checked: checked }));
// 3: the voter list
if (voters.length !== Number(cert.voter_count)) return done(fail(`certificate names ${cert.voter_count} voters, ${voters.length} given`, { headers_checked: checked }));
for (let i = 0; i < voters.length; i++) {
const v = voters[i];
const pk = hexToBytes(v.pubkey_hex);
if (pk.length !== 48) return done(fail(`voter ${i} key is not 48 bytes`, { headers_checked: checked }));
// the key hash is derived from the key; the API may omit it (4 October 2026: the public feed carries no key
// hashes), in which case the verifier computes it and checks the canonical order on its own derivation
const derived = voteKeyHash(pk, blake2b);
if (v.vote_key_hash && derived !== v.vote_key_hash) return done(fail(`voter ${i} key does not hash to its vote_key_hash`, { headers_checked: checked }));
v.vote_key_hash = derived;
if (i > 0 && !(voters[i - 1].vote_key_hash < v.vote_key_hash)) return done(fail('voter list is not in canonical order', { headers_checked: checked }));
if (!(Number(v.weight) >= 0) || !(Number(v.participation) >= 0 && Number(v.participation) <= 1)) return done(fail(`voter ${i} has a bad weight or participation`, { headers_checked: checked }));
}
// 4: the aggregate signature over the exact vote message
const positions = signerPositions(cert.bitmap_hex, voters.length);
if (!positions.length) return done(fail('certificate has no signers', { headers_checked: checked }));
const L = bls.longSignatures;
let aggPk, hm, sigOk;
try {
aggPk = L.aggregatePublicKeys(positions.map(p => hexToBytes(voters[p].pubkey_hex)));
hm = L.hash(voteMessage(data.chain_id, index, data.hash), DST_VOTE);
sigOk = L.verify(hexToBytes(cert.aggregate_signature_hex), hm, aggPk);
} catch (e) {
return done(fail(`signature check failed: ${String(e.message || e).slice(0, 80)}`, { headers_checked: checked, signers: positions.length }));
}
if (!sigOk) return done(fail('aggregate signature does not verify', { headers_checked: checked, signers: positions.length }));
// 5: the rule
let signed = 0n, total = 0n, active = 0;
for (let i = 0; i < voters.length; i++) {
const w = BigInt(Math.round(Number(voters[i].weight)));
total += w; active += Number(w) * Number(voters[i].participation);
}
for (const p of positions) signed += BigInt(Math.round(Number(voters[p].weight)));
if (total === 0n) return done(fail('total weight is zero', { headers_checked: checked, signers: positions.length }));
const fracActive = active > 0 ? Number(signed) / active : 0;
const fracTotal = Number(signed) / Number(total);
const quorum = 3 * Number(signed) >= 2 * active;
const floor = 30n * signed >= 17n * total;
const result = {
index, hash: data.hash, source: data.source, network: data.chain_id,
signers: positions.length, voters: voters.length,
signed_weight: Number(signed), active_weight: active, total_weight: Number(total),
weight_fraction_active: Math.round(fracActive * 10000) / 10000,
weight_fraction_total: Math.round(fracTotal * 10000) / 10000,
headers_checked: checked,
weights_at_index: data.voters_at_index,
weights_exact: Number(data.voters_at_index) === index,
};
if (!quorum) return done({ ...fail(`signed weight is ${(fracActive * 100).toFixed(1)}% of active, below 2/3`), ...result });
if (!floor) return done({ ...fail(`signed weight is ${(fracTotal * 100).toFixed(1)}% of total, below 56.7%`), ...result });
return done({ verified: true, ...result });
} catch (e) {
return done(fail(`error: ${String(e.message || e).slice(0, 100)}`));
}
}