publish.mjs packs the fifteen served files with one fixed mtime (reproducible; the self-test checks it), hashes, signs
the entry through igneum-ota-sign sign-ui with the key in ~/.config/igneum (never read or printed here), copies the
bundle into the folder's ui/ and hands ui.json to publish-manifest.sh --ui, the one writer of the signed manifest,
which verifies the entry and the bundle's hash before signing; --dry-run writes nothing, --verify reads the live
manifest back against dl/<token>/ui and dl/public/ui; --no-ui withdraws the channel. Both self-tests sit on the one
gate. docs/plans/ui-ota.md: the shape, the engine, the security notes, the operator recipe, the tests, per tier.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e02f5e14d0)
174 lines
12 KiB
JavaScript
174 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// The interface over the air, publisher side (docs/plans/ui-ota.md): packs app/igneum-app/ui into one tar.gz, hashes
|
|
// it, signs the entry with the release key through igneum-ota-sign (the key stays in ~/.config/igneum, never here),
|
|
// copies the bundle into the downloads folder's ui/ and hands the channel to packaging/ota/publish-manifest.sh --ui,
|
|
// the one writer of the signed manifest. Nothing here deploys: --deploy is passed through to publish-manifest.sh,
|
|
// which deploys from the live folder; a cut is staged in a scratch copy (IGNEUM_DLSITE) as every other publish.
|
|
//
|
|
// node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.19 [--notes "one line"] [--dry-run] [--deploy] [--public]
|
|
// node tools/ui-ota/publish.mjs --verify [--url https://dl.igneum.network/dl/<token>/igneum-app-latest.json]
|
|
// the live manifest's ui entry against the bundle in the folder (the read-back)
|
|
// node tools/ui-ota/publish.mjs --self-test pack, hash, sign and verify with a throwaway key in a scratch folder
|
|
//
|
|
// --dry-run packs, hashes and signs into a scratch folder and prints the entry; nothing is written to any downloads
|
|
// folder and publish-manifest.sh is not called. The bundle's VERSION file is written from --version before packing.
|
|
// The version is three-part (the publish rule); min-engine is the lowest app version that may serve it.
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(here, '../..');
|
|
const uiDir = path.join(root, 'app/igneum-app/ui');
|
|
const signer = path.join(root, 'app/igneum-app/target/release/igneum-ota-sign');
|
|
const keyFile = path.join(os.homedir(), '.config/igneum/ota-signing-key');
|
|
const pubFile = path.join(os.homedir(), '.config/igneum/ota-signing-key.pub');
|
|
/// what goes into the bundle: the served files (src/uiota.rs SERVED), never the tests
|
|
export const FILES = ['index.html', 'app.css', 'app.js', 'mark.svg', 'live-dag.js', 'proof-core.js', 'VERSION', 'fonts/IBMPlexMono-400.woff2', 'fonts/IBMPlexMono-500.woff2', 'fonts/IBMPlexSans-400.woff2', 'fonts/IBMPlexSans-500.woff2', 'fonts/IBMPlexSans-600.woff2', 'fonts/Unbounded-500.woff2', 'fonts/Unbounded-700.woff2', 'fonts/Unbounded-900.woff2'];
|
|
|
|
function arg(name, d) { const i = process.argv.indexOf(name); return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : d; }
|
|
const flag = (name) => process.argv.includes(name);
|
|
// the mtime every packed file carries (1 January 2026 UTC), so a pack is a function of the tree alone
|
|
const STAMP = new Date(Date.UTC(2026, 0, 1));
|
|
const threePart = (v) => /^\d+\.\d+\.\d+$/.test(v);
|
|
|
|
export function sha256(file) { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); }
|
|
|
|
// Copies the served files into a clean folder with the VERSION stamped, packs them in sorted order (one tar, the
|
|
// system's; gzip -n keeps the archive free of a timestamp), returns {tar, sha256, size}.
|
|
export function pack(srcDir, version, outDir, files = FILES) {
|
|
const stage = path.join(outDir, 'stage');
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
for (const f of files) {
|
|
const from = path.join(srcDir, f), to = path.join(stage, f);
|
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
if (f === 'VERSION') fs.writeFileSync(to, version + '\n'); else fs.copyFileSync(from, to);
|
|
fs.utimesSync(to, STAMP, STAMP); // one mtime for every file: the same tree packs to the same bytes
|
|
}
|
|
fs.utimesSync(stage, STAMP, STAMP);
|
|
fs.utimesSync(path.join(stage, 'fonts'), STAMP, STAMP);
|
|
const tar = path.join(outDir, `igneum-ui-${version}.tar`);
|
|
const sorted = [...files].sort();
|
|
execFileSync('tar', ['-cf', tar, '-C', stage, ...sorted]);
|
|
fs.rmSync(tar + '.gz', { force: true });
|
|
execFileSync('gzip', ['-n', '-9', tar]);
|
|
const gz = tar + '.gz';
|
|
return { tar: gz, sha256: sha256(gz), size: fs.statSync(gz).size };
|
|
}
|
|
|
|
function run(cmd, args) {
|
|
// the Mac's cargo lives in ~/.cargo/bin (the one publish-manifest.sh uses); node's PATH may hold an older one
|
|
const env = { ...process.env, PATH: path.join(os.homedir(), '.cargo/bin') + path.delimiter + (process.env.PATH || '') };
|
|
const r = spawnSync(cmd, args, { encoding: 'utf8', env });
|
|
if (r.status !== 0) throw new Error(`${path.basename(cmd)} ${args.filter((a) => !a.includes('ota-signing-key')).join(' ')}: ${(r.stderr || r.stdout || '').trim()}`);
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
export function signEntry(signerBin, key, version, sha, minEngine) { return run(signerBin, ['sign-ui', key, version, sha, minEngine]); }
|
|
export function verifyEntry(signerBin, pub, e) { return run(signerBin, ['verify-ui', pub, e.version, e.sha256, e.min_engine, e.signature]) === 'verifies'; }
|
|
|
|
function ensureSigner() {
|
|
if (fs.existsSync(signer)) return signer;
|
|
console.log('building igneum-ota-sign');
|
|
run('cargo', ['build', '--release', '-j', '4', '--bin', 'igneum-ota-sign', '--quiet', '--manifest-path', path.join(root, 'app/igneum-app/Cargo.toml')]);
|
|
return signer;
|
|
}
|
|
|
|
function selfTest() {
|
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ota-selftest-'));
|
|
const fails = [];
|
|
try {
|
|
const a = pack(uiDir, '9.9.9', path.join(scratch, 'a'));
|
|
const b = pack(uiDir, '9.9.9', path.join(scratch, 'b'));
|
|
if (a.sha256 !== b.sha256) fails.push('two packs of the same tree differ (the bundle is not reproducible)');
|
|
if (a.size < 100_000) fails.push('the bundle is too small to hold the fonts: ' + a.size);
|
|
const list = execFileSync('tar', ['-tzf', a.tar], { encoding: 'utf8' }).trim().split('\n').sort();
|
|
for (const f of FILES) if (!list.includes(f)) fails.push('the bundle lacks ' + f);
|
|
if (list.some((f) => f.endsWith('.test.mjs'))) fails.push('a test file went into the bundle');
|
|
const v = fs.readFileSync(path.join(scratch, 'a/stage/VERSION'), 'utf8').trim();
|
|
if (v !== '9.9.9') fails.push('the VERSION file was not stamped: ' + v);
|
|
// the signing half needs the built signer (the Rust tests cover verify_ui_entry; CI has no signer binary)
|
|
if (!fs.existsSync(signer)) { fs.rmSync(scratch, { recursive: true, force: true }); if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); } console.log('self-test passed (pack half): the pack is reproducible and complete; no signer binary here, the sign half is skipped'); return; }
|
|
const s = signer;
|
|
const key = path.join(scratch, 'key'), pub = path.join(scratch, 'key.pub');
|
|
run(s, ['keygen', key, pub]);
|
|
const sig = signEntry(s, key, '9.9.9', a.sha256, '0.3.19');
|
|
const e = { version: '9.9.9', sha256: a.sha256, size: a.size, url: 'https://dl.igneum.network/dl/x/ui/igneum-ui-9.9.9.tar.gz', min_engine: '0.3.19', signature: sig };
|
|
if (!verifyEntry(s, pub, e)) fails.push('a good entry did not verify');
|
|
let bad = false; try { bad = verifyEntry(s, pub, { ...e, sha256: '00'.repeat(32) }); } catch (x) { bad = false; }
|
|
if (bad) fails.push('a tampered hash verified');
|
|
let other = false; const key2 = path.join(scratch, 'key2'), pub2 = path.join(scratch, 'key2.pub'); run(s, ['keygen', key2, pub2]);
|
|
try { other = verifyEntry(s, pub2, e); } catch (x) { other = false; }
|
|
if (other) fails.push('an entry verified against the wrong key');
|
|
} catch (x) { fails.push(String(x.message || x)); }
|
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); }
|
|
console.log('self-test passed: the pack is reproducible and complete, a good entry verifies, a tampered hash and a wrong key do not');
|
|
}
|
|
|
|
function verifyLive() {
|
|
const token = fs.readFileSync(path.join(os.homedir(), '.config/igneum/dl-token'), 'utf8').trim();
|
|
const url = arg('--url', `https://dl.igneum.network/dl/${token}/igneum-app-latest.json`);
|
|
const dlsite = process.env.IGNEUM_DLSITE || fs.readFileSync(path.join(os.homedir(), '.config/igneum/dlsite-dir'), 'utf8').trim();
|
|
const text = run('curl', ['-fsSL', '--max-time', '20', url]);
|
|
const m = JSON.parse(text);
|
|
const mask = (t) => String(t).split(token).join('<token>');
|
|
if (!m.ui) { console.log('live manifest: no ui object (the built-in interface serves everywhere)'); return; }
|
|
const name = path.basename(m.ui.url);
|
|
const local = path.join(dlsite, 'dl', token, 'ui', name), pub = path.join(dlsite, 'dl', 'public', 'ui', name);
|
|
const lines = [`live manifest: interface ${m.ui.version}, min engine ${m.ui.min_engine}, ${mask(m.ui.url)}`];
|
|
let ok = true;
|
|
for (const f of [local, pub]) {
|
|
if (!fs.existsSync(f)) { lines.push(` ${mask(f)}: missing`); if (f === local) ok = false; continue; }
|
|
const sum = sha256(f);
|
|
lines.push(` ${mask(f)}: sha256 ${sum === m.ui.sha256 ? 'matches' : 'DIFFERS (' + sum + ')'}`);
|
|
if (sum !== m.ui.sha256) ok = false;
|
|
}
|
|
const s = ensureSigner();
|
|
const sigOk = verifyEntry(s, pubFile, m.ui);
|
|
lines.push(` signature: ${sigOk ? 'verifies' : 'DOES NOT VERIFY'}`);
|
|
console.log(lines.join('\n'));
|
|
if (!ok || !sigOk) process.exit(1);
|
|
}
|
|
|
|
function main() {
|
|
if (flag('--self-test')) return selfTest();
|
|
if (flag('--verify')) return verifyLive();
|
|
const version = arg('--version'), minEngine = arg('--min-engine'), notes = arg('--notes', '');
|
|
if (!version || !threePart(version)) { console.error('--version major.minor.patch is required (the interface has its own line, 1.0.0 upward)'); process.exit(2); }
|
|
if (!minEngine || !threePart(minEngine)) { console.error('--min-engine major.minor.patch is required (the lowest app version that may serve this bundle)'); process.exit(2); }
|
|
const dry = flag('--dry-run');
|
|
const s = ensureSigner();
|
|
const token = fs.readFileSync(path.join(os.homedir(), '.config/igneum/dl-token'), 'utf8').trim();
|
|
const dlsite = process.env.IGNEUM_DLSITE || fs.readFileSync(path.join(os.homedir(), '.config/igneum/dlsite-dir'), 'utf8').trim();
|
|
const dest = path.join(dlsite, 'dl', token);
|
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ota-'));
|
|
const p = pack(uiDir, version, scratch);
|
|
const name = path.basename(p.tar);
|
|
const url = `https://dl.igneum.network/dl/${token}/ui/${name}`;
|
|
const entry = { version, sha256: p.sha256, size: p.size, url, min_engine: minEngine, signature: dry ? '(signed at publish)' : signEntry(s, keyFile, version, p.sha256, minEngine) };
|
|
const shown = { ...entry, url: url.split(token).join('<token>') };
|
|
console.log('interface bundle: ' + JSON.stringify(shown, null, 1));
|
|
if (dry) { console.log(`dry run: nothing written to ${dest.split(token).join('<token>')}; the bundle is at ${p.tar}`); return; }
|
|
if (!verifyEntry(s, pubFile, entry)) { console.error('the entry does not verify against ' + pubFile); process.exit(1); }
|
|
fs.mkdirSync(path.join(dest, 'ui'), { recursive: true });
|
|
fs.copyFileSync(p.tar, path.join(dest, 'ui', name));
|
|
const uiJson = path.join(scratch, 'ui.json');
|
|
fs.writeFileSync(uiJson, JSON.stringify(entry));
|
|
const pm = path.join(root, 'packaging/ota/publish-manifest.sh');
|
|
const args = ['--version', arg('--app-version', readFolderVersion(dest)), '--ui', uiJson, '--notes', notes || `interface ${version} over the air`];
|
|
if (flag('--deploy')) args.push('--deploy');
|
|
if (flag('--public')) args.push('--public');
|
|
console.log(`publish-manifest.sh ${args.join(' ').split(token).join('<token>')}`);
|
|
const r = spawnSync('bash', [pm, ...args], { stdio: 'inherit', env: { ...process.env, IGNEUM_DLSITE: dlsite } });
|
|
process.exit(r.status || 0);
|
|
}
|
|
|
|
function readFolderVersion(dest) {
|
|
try { return JSON.parse(fs.readFileSync(path.join(dest, 'igneum-app-latest.json'), 'utf8')).version; } catch (e) { console.error('no manifest in the folder: pass --app-version'); process.exit(2); }
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) main();
|