morphit/scripts/verify-download.mjs

250 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* Morphit — verify-download.mjs (cp556)
*
* Verify a downloaded `morphit-vX.Y.Z.tar.gz` against the
* decentralized-distribution anchor that @morphit published on the
* Blurt chain (`morphit_release_v1` → `distribution`).
*
* node scripts/verify-download.mjs morphit-v1.8.15.tar.gz
* node scripts/verify-download.mjs <tarball> --version 1.8.15
* MORPHIT_RPC=https://rpc.beblurt.com node scripts/verify-download.mjs <tarball>
*
* What it does:
* 1. Computes the SHA-256 of YOUR downloaded tarball.
* 2. Fetches @morphit's latest morphit_release_v1 op straight from a
* Blurt RPC node (NOT from any Morphit server — a compromised
* download host therefore cannot fake a match).
* 3. Compares your SHA-256 to the on-chain `source_sha256`.
* 4. Prints the anchor's GPG fingerprint + IPFS CID + mirror list so you
* can `git verify-tag` the signed tag (the tarball is unsigned by
* default — integrity is the on-chain SHA-256) and/or re-fetch from IPFS.
*
* This file is deliberately self-contained (only Node built-ins, no
* Morphit imports) so you can read every line and run it anywhere,
* even outside a repo checkout. The authoritative validator lives in
* packages/release-schema/src/releaseValidate.ts; this is the minimal
* verifier a downloader needs. See docs/VERIFY-YOUR-DOWNLOAD.md.
*
* Exit codes: 0 verified · 1 MISMATCH (do not trust the download) ·
* 2 usage error · 3 chain unreachable · 4 no anchor on chain.
*/
import { createHash } from 'node:crypto';
import { readFileSync, existsSync } from 'node:fs';
const SIGNER = 'morphit';
const OP_ID = 'morphit_release_v1';
// The 6-node canonical Blurt RPC pool — kept in lockstep with
// DEFAULT_BLURT_RPC_ENDPOINTS (@morphit/operator-config) by the
// rpc-endpoint-canon smoke. This is a Node script (no browser CORS), so
// it can use ALL of them, including the CORS-omitted node the browser
// build can't reach. Override with MORPHIT_RPC=<url> for a single node.
const DEFAULT_RPCS = [
'https://rpc.drakernoise.com',
'https://blurtrpc.dagobert.uk',
'https://rpc.blurt.blog',
'https://rpc.beblurt.com',
'https://rpc.blurt.one',
'https://blurt-rpc.saboin.com'
];
const HISTORY_WALK_LIMIT = 10_000;
const BATCH = 1000;
// ─── pure helpers (unit-tested by verify-download-smoke.ts) ──────────
/** SHA-256 of a file's bytes, lowercase hex — matches `sha256sum`. */
export function sha256File(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
const SOURCE_SHA256_RE = /^[0-9a-f]{64}$/;
/**
* Extract the distribution anchor from a parsed release-op payload.
* Minimal shape check only (source_sha256 must be 64-hex) — the full
* validator is in the repo. Returns the block or null.
*/
export function extractDistribution(payload) {
if (!payload || typeof payload !== 'object') return null;
const d = payload.distribution;
if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
if (typeof d.source_sha256 !== 'string' || !SOURCE_SHA256_RE.test(d.source_sha256)) return null;
return d;
}
/**
* Compare a computed tarball hash to an anchor. Pure. Returns:
* { status: 'match' | 'mismatch' | 'no_anchor', ... }
* When a version is requested and the anchor's release version differs,
* status is 'version_mismatch' (the chain's latest anchor is for a
* different release than the file you're checking).
*/
export function compareRelease(sha, payload, wantVersion) {
const dist = extractDistribution(payload);
if (!dist) return { status: 'no_anchor' };
const chainVersion = typeof payload.version === 'string' ? payload.version : null;
if (wantVersion && chainVersion && chainVersion !== wantVersion) {
return { status: 'version_mismatch', chainVersion, wantVersion, dist };
}
return {
status: sha === dist.source_sha256 ? 'match' : 'mismatch',
expected: dist.source_sha256,
got: sha,
chainVersion,
dist
};
}
// ─── chain I/O ───────────────────────────────────────────────────────
async function rpcCall(endpoint, method, params) {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json();
if (j.error) throw new Error(typeof j.error === 'string' ? j.error : JSON.stringify(j.error));
return j.result;
}
/** Tolerant custom_json extraction across condenser history shapes. */
function opToReleaseJson(entry) {
// entry: [seq, { op: [...] | { op: [...] }, ... }]
const tx = entry?.[1];
const op = Array.isArray(tx?.op) ? tx.op : Array.isArray(tx?.op?.op) ? tx.op.op : null;
if (!op || op[0] !== 'custom_json') return null;
const data = op[1];
if (!data || data.id !== OP_ID) return null;
try {
return JSON.parse(data.json);
} catch {
return null;
}
}
/**
* Walk @morphit's account history newest→oldest (in batches, bounded
* to HISTORY_WALK_LIMIT) and return the parsed payload of the most
* recent morphit_release_v1 op, or null. Tries each RPC in turn.
*/
async function fetchLatestReleasePayload(rpcs) {
let lastErr = null;
for (const rpc of rpcs) {
try {
let from = -1;
let walked = 0;
while (walked < HISTORY_WALK_LIMIT) {
const hist = await rpcCall(rpc, 'condenser_api.get_account_history', [SIGNER, from, BATCH]);
if (!Array.isArray(hist) || hist.length === 0) break;
// Newest last — scan descending for our op.
for (let i = hist.length - 1; i >= 0; i--) {
const payload = opToReleaseJson(hist[i]);
if (payload) return { payload, rpc };
}
const lowestSeq = hist[0][0];
if (lowestSeq <= 0) break;
from = lowestSeq - 1;
walked += hist.length;
}
return { payload: null, rpc }; // reachable, but no release op found
} catch (err) {
lastErr = err;
}
}
throw lastErr ?? new Error('all RPC endpoints failed');
}
// ─── CLI ─────────────────────────────────────────────────────────────
function parseArgs(argv) {
const args = argv.slice(2);
const tarball = args.find((a) => !a.startsWith('--'));
const vi = args.indexOf('--version');
const wantVersion = vi >= 0 ? args[vi + 1] : null;
return { tarball, wantVersion };
}
async function main() {
const { tarball, wantVersion } = parseArgs(process.argv);
if (!tarball) {
process.stderr.write('usage: node scripts/verify-download.mjs <tarball.tar.gz> [--version X.Y.Z]\n');
process.exit(2);
}
if (!existsSync(tarball)) {
process.stderr.write(`file not found: ${tarball}\n`);
process.exit(2);
}
const sha = sha256File(tarball);
process.stdout.write(`\nYour download : ${tarball}\n`);
process.stdout.write(`SHA-256 : ${sha}\n`);
const rpcs = process.env.MORPHIT_RPC ? [process.env.MORPHIT_RPC] : DEFAULT_RPCS;
process.stdout.write(`\nFetching @${SIGNER}'s on-chain release anchor…\n`);
let result;
try {
result = await fetchLatestReleasePayload(rpcs);
} catch (err) {
process.stderr.write(`\n✗ could not reach the Blurt chain: ${err instanceof Error ? err.message : err}\n`);
process.stderr.write(' Try another node: MORPHIT_RPC=https://rpc.beblurt.com node scripts/verify-download.mjs <tarball>\n');
process.exit(3);
}
if (!result.payload) {
process.stderr.write(`\n✗ no ${OP_ID} op found in @${SIGNER}'s recent history.\n`);
process.stderr.write(' The release may not be anchored yet, or history was pruned.\n');
process.exit(4);
}
const cmp = compareRelease(sha, result.payload, wantVersion);
if (cmp.status === 'no_anchor') {
process.stderr.write('\n✗ the latest release op carries NO distribution anchor to check against.\n');
process.stderr.write(' This release predates decentralized-distribution anchoring.\n');
process.exit(4);
}
if (cmp.status === 'version_mismatch') {
process.stderr.write(`\n✗ the chain's latest anchor is for v${cmp.chainVersion}, but you asked to verify v${cmp.wantVersion}.\n`);
process.stderr.write(' Download the matching version, or drop --version to check against the latest.\n');
process.exit(1);
}
const d = cmp.dist;
process.stdout.write(`On-chain (v${cmp.chainVersion}) via ${result.rpc}\n`);
process.stdout.write(`Anchor SHA-256: ${cmp.expected}\n\n`);
if (cmp.status === 'match') {
process.stdout.write('✓ SHA-256 MATCHES the on-chain anchor. Your bytes are the published release.\n\n');
process.stdout.write('Next, confirm the GPG signature. The tarball is unsigned by default —\n');
process.stdout.write('its integrity comes from the on-chain SHA-256 you just matched. The\n');
process.stdout.write("project's GPG signature is on the signed git TAG, so verify that (clone\n");
process.stdout.write('any mirror below, then run this inside the clone):\n');
process.stdout.write(` git verify-tag v${cmp.chainVersion}\n`);
process.stdout.write(" → the tag's signing-key fingerprint MUST equal:\n");
process.stdout.write(` ${d.gpg_fingerprint}\n`);
process.stdout.write(` (Only if the release attached a detached signature: gpg --verify ${tarball}.asc ${tarball})\n`);
if (d.ipfs_cid) process.stdout.write(`\nIPFS (content-addressed, tamper-proof by CID): ${d.ipfs_cid}\n ipfs get ${d.ipfs_cid}\n`);
if (Array.isArray(d.mirrors) && d.mirrors.length) {
process.stdout.write('\nMirror repos carrying the same code (clone one and `git verify-tag`):\n');
for (const m of d.mirrors) process.stdout.write(` ${m}\n`);
}
process.stdout.write('\n');
process.exit(0);
}
process.stderr.write('✗ SHA-256 DOES NOT MATCH the on-chain anchor. DO NOT TRUST THIS DOWNLOAD.\n');
process.stderr.write(` expected ${cmp.expected}\n got ${cmp.got}\n`);
process.stderr.write(' Get the canonical tarball from the Forgejo release page and re-check, or\n');
process.stderr.write(' clone a mirror repo and verify the signed tag instead (git verify-tag):\n');
if (Array.isArray(d.mirrors)) for (const m of d.mirrors) process.stderr.write(` ${m}\n`);
process.stderr.write(' See docs/VERIFY-YOUR-DOWNLOAD.md.\n');
process.exit(1);
}
// Only run when invoked directly (so the smoke can import the helpers).
if (import.meta.url === `file://${process.argv[1]}`) {
void main();
}