aere-quantum/device-operation/operation-cli.mjs

129 lines
12 KiB
JavaScript

#!/usr/bin/env node
// operation-cli.mjs: the command line of AERE Device Operation. The device identity (keys, manufacture credential, status records) is made
// with ../aere-device-identity/device-cli.mjs; this tool writes and judges the operation log.
//
// start --manufacture manufacture.json --out log.json a new log, rooted in the credential of the first key
// record --device device.key.json --log log.json --state S --counter N [--measurement-file f | --measurement 0x..]
// [--after <chainId>:<block>:<hash>] [--at <time>] [--out log.json] the next record, signed with the device key (the log is
// written in place unless --out names another file)
// rotate --device old.key.json --next new.key.json --manufacture new-manufacture.json --log log.json --state S --counter N [--at <time>]
// the old key signs a record that names the new key; the new key's credential joins the log
// anchor --log log.json [--seq N] --out envelope.json the AIP-23 envelope (kind `time`) of a record (default: the last)
// add-anchor --log log.json --envelope notarized.json the log carries the (notarized) envelope
// notarize --envelope envelope.json --rpc URL --key-file f --out notarized.json on AereNotary; refuses chain 2800 unless AERE_CONFIRM_MAINNET=yes
// reading --envelope notarized.json --rpc URL [--chain N] [--verify-proof verify-proof.mjs] --out reading.json
// the VERIFIER's reading of the notarization, through the AIP-23 reference verifier, under a post-quantum certified anchor
// block --rpc URL [--number N] --out block.json the VERIFIER's reading of a block: chain id, number, hash, time
// verify --log log.json --now <time> [--trust <id>]... [--status-list f]... [--status f]... [--no-status] [--reading f]... [--block f]...
// [--other f]... [--equivocations-out f] [--json]
// facts (the options of verify) --out facts.json what the reputation is computed from
// reputation --facts facts.json --from <time> --to <time> --window <seconds> [--canonical]
// equivocation --proof proof.json checks a proof of equivocation on its own
// A file given to --status, --status-list, --reading, --block or --other holds one object, a JSON list of them, or { "records": [...] }.
// --status is required to judge retirements: without it (or with --no-status, which says the same) the registry is not read and the verdict
// is PARTIAL. A time is an RFC 3339 UTC text or milliseconds since 1970.
// Exit codes. verify: 0 VALID, 1 INVALID, 2 PARTIAL, 3 no verdict (wrong usage, an input that cannot be read), 4 an unexpected failure.
// The other commands: 0 done, 1 failed, 2 not measured (notarize, reading, block), 3 wrong usage or an input that cannot be read.
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
import * as D from '../device-identity/device.mjs';
import * as O from './operation.mjs';
import { reputation } from './reputation.mjs';
import { canonical } from '../identity/identity.mjs';
class Folosire extends Error {}
const COD_FOLOSIRE = 3, COD_CADERE = 4;
const spune = (text) => { console.log(D.visible(text)); };
const striga = (text) => { console.error(D.visible(text)); };
const [cmd, ...rest] = process.argv.slice(2);
const get = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
const toate = (f) => rest.flatMap((a, i) => (a === f && rest[i + 1] != null ? [rest[i + 1]] : []));
const cere = (f) => { const v = get(f); if (v == null) throw new Folosire(`${cmd}: ${f} is required`); return v; };
const numar = (f) => { const n = Number(cere(f)); if (!Number.isSafeInteger(n) || n < 0) throw new Folosire(`${cmd}: ${f} is a whole number`); return n; };
const citeste = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${f}: ${e.message}`); } };
const timp = (s, f) => { const t = /^\d+$/.test(String(s)) ? Number(s) : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(String(s)) ? Date.parse(s) : NaN; if (!Number.isFinite(t)) throw new Folosire(`${cmd}: ${f} is an RFC 3339 UTC time or milliseconds since 1970`); return t; };
/** written whole or not at all: a log half written is a log that lost its head */
const scrie = (o, f) => { if (!f) throw new Folosire(`${cmd}: --out is required`); const t = f + '.tmp-' + process.pid; fs.writeFileSync(t, JSON.stringify(o, null, 1) + '\n'); fs.renameSync(t, f); };
const netezeste = (x, n = 0) => (n > 8 ? [x] : Array.isArray(x) ? x.flatMap((y) => netezeste(y, n + 1)) : x && typeof x === 'object' && Array.isArray(x.records) && x.statement === undefined ? netezeste(x.records, n + 1) : [x]);
const elemente = (f) => netezeste(citeste(f));
const dispozitiv = (f) => { try { return D.importDeviceKey(citeste(f)); } catch (e) { throw new Folosire(`${f}: ${e.message}`); } };
function optiuni() {
const o = { now: timp(cere('--now'), '--now') };
const tr = toate('--trust'); if (tr.length) o.trustedManufacturers = tr;
o.statusLists = toate('--status-list').flatMap(elemente);
o.statusRecords = rest.includes('--no-status') || !toate('--status').length ? null : toate('--status').flatMap(elemente);
o.readings = toate('--reading').flatMap(elemente); o.beacons = toate('--block').flatMap(elemente); o.otherRecords = toate('--other').flatMap(elemente);
return o;
}
function judeca() {
const L = citeste(cere('--log'));
try { return O.verifyOperation(L, optiuni()); } catch (e) { if (/^verifyOperation: /.test(String(e && e.message))) throw new Folosire(e.message); throw e; }
}
async function main() {
if (cmd === 'start') { const L = O.newLog({ manufacture: citeste(cere('--manufacture')) }); scrie(L, cere('--out')); spune(`a log of ${O.keyInForce(L)}`); return 0; }
if (cmd === 'record' || cmd === 'rotate') {
const L = citeste(cere('--log')); const device = dispozitiv(cere('--device'));
let after = null;
if (get('--after')) { const m = /^(\d+):(\d+):(0x[0-9a-fA-F]{64})$/.exec(get('--after')); if (!m) throw new Folosire(`${cmd}: --after is <chainId>:<block>:<hash>`); after = { chainId: Number(m[1]), block: Number(m[2]), hash: m[3].toLowerCase() }; }
const measurement = get('--measurement-file') ? fs.readFileSync(get('--measurement-file')) : get('--measurement') || null;
const now = get('--at') ? new Date(timp(get('--at'), '--at')) : new Date();
let next = null, credential = null;
if (cmd === 'rotate') { const n = dispozitiv(cere('--next')); next = { deviceId: n.deviceId, publicKey: n.publicKey }; credential = citeste(cere('--manufacture')); if (credential.statement?.claims?.deviceId !== n.deviceId) throw new Folosire('rotate: the manufacture credential given is not for the next key'); }
const r = O.operationRecord({ device, log: L, state: cere('--state'), counter: numar('--counter'), measurement, after, next, now });
scrie(O.append(L, r, credential), get('--out') || cere('--log'));
spune(`record ${r.statement.seq} at ${r.statement.at}: ${r.statement.state}, counter ${r.statement.counter}` + (next ? `, rotation to ${next.deviceId}` : '')); return 0;
}
if (cmd === 'anchor') {
const L = citeste(cere('--log')); const s = get('--seq') != null ? numar('--seq') : L.records.length - 1;
if (!L.records[s]) throw new Folosire(`anchor: the log has no record ${s}`);
const e = O.anchorEnvelope(L.records[s]); scrie(e, cere('--out')); spune(`record ${s}: ${e.statementHash}`); return 0;
}
if (cmd === 'add-anchor') { const f = cere('--log'); scrie(O.addAnchor(citeste(f), citeste(cere('--envelope'))), f); return 0; }
if (cmd === 'notarize') {
const { notarizeEnvelope } = await import('../agents/agent-ancora.mjs'); const { incarcaEthers } = await import('../agents/x402/wallet.mjs');
let ethers; try { ethers = incarcaEthers(); } catch (e) { striga('NOT MEASURED: ' + e.message); return 2; }
const r = await notarizeEnvelope({ envelope: citeste(cere('--envelope')), kind: O.ANCHOR_KIND, rpc: cere('--rpc'), keyFile: cere('--key-file'), ethers });
if (!r.ok) { striga(r.motiv); return r.cod === 1 ? 1 : 2; }
scrie(r.envelope, cere('--out')); spune(`notarized on chain ${r.envelope.notarization.chainId}: firstSeen ${r.envelope.notarization.firstSeen}`); return 0;
}
if (cmd === 'reading') {
const vp = get('--verify-proof') || process.env.AERE_VERIFY_PROOF; if (!vp || !fs.existsSync(vp)) { striga('NOT MEASURED: the AIP-23 reference verifier is needed (--verify-proof <verify-proof.mjs> or AERE_VERIFY_PROOF)'); return 2; }
const { citesteAncora } = await import('../device-identity/device-cli.mjs');
const r = await citesteAncora(citeste(cere('--envelope')), { verifyProof: vp, rpc: cere('--rpc'), chain: get('--chain') ? Number(get('--chain')) : null });
if (r.stare !== 'PASSED') { striga((r.stare === 'FAILED' ? 'FAILED: ' : 'NOT MEASURED: ') + r.motiv); return r.stare === 'FAILED' ? 1 : 2; }
scrie({ ...r.reading, kind: 'aere-device-operation-anchor-reading' }, cere('--out')); spune(`first seen on chain ${r.reading.chainId} at ${new Date(r.reading.at * 1000).toISOString()}`); return 0;
}
if (cmd === 'block') {
const rpc = cere('--rpc'); const cheama = async (method, params) => { const r = await fetch(rpc, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); const j = await r.json(); if (!r.ok || j.error || j.result == null) throw new Error(`${method}: ${r.status} ${j.error ? j.error.message : 'no result'}`); return j.result; };
let chainId, b; try { chainId = Number(BigInt(await cheama('eth_chainId', []))); b = await cheama('eth_getBlockByNumber', [get('--number') != null ? '0x' + numar('--number').toString(16) : 'latest', false]); }
catch (e) { striga('NOT MEASURED: the endpoint did not answer: ' + String(e.message).slice(0, 200)); return 2; }
const citire = { v: 1, kind: O.BLOCK_READING_KIND, chainId, block: Number(BigInt(b.number)), hash: String(b.hash).toLowerCase(), at: Number(BigInt(b.timestamp)) };
scrie(citire, cere('--out')); spune(`block ${citire.block} of chain ${chainId}: ${citire.hash} at ${new Date(citire.at * 1000).toISOString()}`); return 0;
}
if (cmd === 'verify') {
const r = judeca();
if (get('--equivocations-out')) scrie(r.equivocations, get('--equivocations-out'));
if (rest.includes('--json')) console.log(D.visibleJson(r, 1));
else {
for (const x of r.rows) spune(`${x.pass === true ? 'ok ' : x.pass === false ? 'FAIL' : '-- '} ${x.name}${x.pass === true || !x.detail ? '' : ': ' + x.detail}`);
console.log('');
spune(r.verdict + (r.rules.length ? ` (refused by: ${r.rules.join(', ')})` : '') + (r.verdict === 'PARTIAL' ? ` (not judged: ${r.unjudged.filter((c) => O.PARTIALE.includes(c)).join(', ')})` : ''));
const notate = r.unjudged.filter((c) => O.NOTATE.includes(c)); if (notate.length) spune(`noted, the verdict stands: ${notate.join(', ')}`);
spune(`${r.accepted} record(s) accepted, ${r.counted} counted; ${r.equivocations.length} proof(s) of equivocation kept`);
}
return r.verdict === 'VALID' ? 0 : r.verdict === 'INVALID' ? 1 : 2;
}
if (cmd === 'facts') { const r = judeca(); scrie(r.facts, cere('--out')); spune(`${r.verdict}: ${r.facts.records.length} record(s) counted, ${r.facts.uncounted} not`); return 0; }
if (cmd === 'reputation') {
let rep; try { rep = reputation(citeste(cere('--facts')), { from: timp(cere('--from'), '--from'), to: timp(cere('--to'), '--to'), windowS: numar('--window') }); }
catch (e) { if (/^aere-device-reputation: /.test(String(e.message))) throw new Folosire(e.message); throw e; }
console.log(rest.includes('--canonical') ? canonical(rep) : JSON.stringify(rep, null, 1)); return 0;
}
if (cmd === 'equivocation') { const v = O.verifyEquivocation(citeste(cere('--proof'))); spune(v.ok ? `a proof of equivocation: ${v.deviceId} signed two records for position ${v.seq}` : 'not a proof of equivocation: ' + v.error); return v.ok ? 0 : 1; }
throw new Folosire('usage: operation-cli.mjs start|record|rotate|anchor|add-anchor|notarize|reading|block|verify|facts|reputation|equivocation ... (see README.md)');
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().then((c) => { process.exitCode = c; }, (e) => { striga(`operation-cli: ${e && e.message}`); process.exitCode = e instanceof Folosire ? COD_FOLOSIRE : cmd === 'verify' ? COD_CADERE : 1; });
}