#!/usr/bin/env node // Checks an Evidence Log export without trusting the app or Atlassian. // Usage: node verify.mjs evidence-log.json (Node 18+, no packages) // Exit code 0 = every record checks out, 1 = a problem was found, 2 = bad input. import { readFileSync } from 'node:fs'; import { createHash, createPublicKey, verify } from 'node:crypto'; function canonicalize(v) { if (v === null) return 'null'; if (Array.isArray(v)) return '[' + v.map((x) => (x === undefined ? 'null' : canonicalize(x))).join(',') + ']'; if (typeof v === 'object') { return '{' + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}'; } return JSON.stringify(v); } const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex'); const file = process.argv[2]; if (!file) { console.error('Usage: node verify.mjs evidence-log.json'); process.exit(2); } let doc; try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { console.error('Could not read the export:', e.message); process.exit(2); } if (doc.format !== 'evidence-log-for-rovo/v1' || !Array.isArray(doc.records) || !doc.publicKeys) { console.error('Not an Evidence Log export (format, records or publicKeys missing).'); process.exit(2); } const keys = {}; for (const [id, b64] of Object.entries(doc.publicKeys)) { const der = Buffer.from(b64, 'base64'); if (createHash('sha256').update(der).digest('hex').slice(0, 16) !== id) { console.error(`Public key ${id} does not match its id.`); process.exit(2); } keys[id] = createPublicKey({ key: der, format: 'der', type: 'spki' }); } const records = [...doc.records].sort((a, b) => a.seq - b.seq); const problems = []; let expected = 1; let prev = doc.genesis || '0'.repeat(64); for (const r of records) { const notes = []; let gap = false; if (r.seq > expected) { gap = true; problems.push(expected); console.log(`${String(expected).padStart(5)} MISSING records ${expected}..${r.seq - 1} are not in the export`); } const h = sha256(canonicalize({ v: r.v, seq: r.seq, recordedAt: r.recordedAt, prevHash: r.prevHash, keyId: r.keyId, body: r.body })); if (h !== r.hash) notes.push('content changed after it was recorded'); else if (!gap && r.prevHash !== prev) notes.push('does not point to the record before it'); const key = keys[r.keyId]; if (!key) notes.push('unknown signing key'); else if (!verify(null, Buffer.from(r.hash, 'utf8'), key, Buffer.from(String(r.sig), 'base64'))) notes.push('signature does not verify'); if (notes.length) problems.push(r.seq); const b = r.body || {}; console.log(`${String(r.seq).padStart(5)} ${notes.length ? 'FAIL ' + notes.join('; ') : 'ok ' + [b.agent || 'person', b.action, b.target && b.target.key, b.outcome && b.outcome.status].filter(Boolean).join(' ')}`); prev = r.hash; expected = r.seq + 1; } if (doc.anchor && doc.anchor.seq) { const last = records.length ? records[records.length - 1].seq : 0; const at = records.find((r) => r.seq === doc.anchor.seq); if (last < doc.anchor.seq) { problems.push(last + 1); console.log(`\nHead checkpoint says record ${doc.anchor.seq} exists, but the export ends at ${last}: newest records were removed.`); } else if (at && at.hash !== doc.anchor.hash) { problems.push(doc.anchor.seq); console.log(`\nRecord ${doc.anchor.seq} does not match the head checkpoint.`); } } if (problems.length) { console.log(`\nFAILED. First problem at record ${Math.min(...problems)}. ${records.length} records checked.`); process.exit(1); } console.log(`\nAll ${records.length} records check out: hashes, chain links, Ed25519 signatures and the head checkpoint.`);