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

142 lines
16 KiB
JavaScript

#!/usr/bin/env node
// device-cli.mjs: AERE Device Identity from the command line, through files only. Key files are written with mode 0600 and never
// printed; every other file is a signed link that can be published.
//
// new-identity --out keys.json a hybrid identity (Ed25519 + ML-DSA-65): a manufacturer or an owner
// new-device --out device.key.json a device key K (ML-DSA-65)
// manufacture --manufacturer keys.json --device device.key.json --model M --serial S --days N
// [--status-list ID --status-index N] --out manufacture.json
// release --signer keys.json --model M --version V --sequence N (--image file | --digest 0x..) [--software attestation.json] --out fw.json
// policy --manufacturer keys.json --model M [--min-sequence N] [--retract 0x..]... --hours N --out policy.json
// transfer --from keys.json --to keys-or-public.json --manufacture manufacture.json [--transfer t.json]... --out tN.json
// authorize --owner keys.json --manufacture manufacture.json [--transfer t.json]... --action a [--action b]... --hours N --out auth.json
// status --by keys.json --manufacture manufacture.json [--transfer t.json]... --state active|suspended|retired --seq N --hours N --out status.json
// with the manufacturer's keys: a record of the manufacturer (no ownership head); with any other keys: a record under the
// ownership head of the chain given (give every transfer, in order)
// attest --device device.key.json --nonce X --audience A --firmware fw.json --manufacture manufacture.json [--transfer t.json]... --action a --out att.json
// bundle [--manufacture f] [--firmware f] [--transfer f]... [--authorization f] [--attestation f] [--anchor envelope.json] --out bundle.json
// envelope --attestation att.json --out envelope.json the AIP-23 envelope (kind device) of an attestation
// notarize --envelope envelope.json --rpc URL --key-file f --out notarized.json
// refuses chain 2800 (mainnet: a real transaction paid by the key's account) 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
// verify --bundle bundle.json [--now T] [--nonce X] [--audience A] [--trust id-or-keys.json]... [--status-list f]... [--policy f]...
// [--status f]... [--software attestation.json] [--reading reading.json]... [--max-age S] [--json]
// a file given to --status, --policy, --status-list or --reading holds ONE object, a JSON list of them, or { "records": [...] }:
// every element is handed to the verifier. What is in none of these forms is handed as it is, and the verifier names it
// prints every row, the verdict, what is named without changing it (`noted, the verdict stands: ...`) and what the status
// records of a previous owner say
// Everything printed that came from a file passes through visible(): control characters are shown, never sent to the terminal
// (--json prints JSON, whose strings escape them).
// Exit codes. verify: 0 VALID, 1 INVALID, 2 PARTIAL, and these three numbers mean nothing else. The other commands: 0 done, 1 failed,
// 2 not measured (notarize, reading). Every command: 3 no verdict and nothing done, because the usage is wrong or an input cannot be
// read (a file that is missing or is not JSON, a clock that is not a time); verify alone: 4 an unexpected failure (never 1, which is INVALID).
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url';
import * as ID from '../identity/identity.mjs';
import { buildProof } from '../proof-kinds/proof-kinds.mjs';
import * as D from './device.mjs';
class Folosire extends Error {}
const COD_FOLOSIRE = 3, COD_CADERE = 4;
/** the ONE place where the command line prints a line: whatever came from a file is made visible first (device.mjs, visible) */
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, imp = null) => { const v = get(f); if (v == null) { if (imp === null) throw new Folosire(`${cmd}: ${f} is required`); return imp; } const n = Number(v); if (!Number.isInteger(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}`); } };
// A key file is CREATED, never written over: the mode 0600 is given only to a file that is created (an existing file keeps its own), and
// the key a file already holds would be lost, with nothing to rotate or recover it.
const scrie = (o, f, { secret = false } = {}) => {
if (!f) throw new Folosire(`${cmd}: --out is required`);
try { fs.writeFileSync(f, JSON.stringify(o, null, 1) + '\n', secret ? { mode: 0o600, flag: 'wx' } : {}); }
catch (e) { if (secret && e && e.code === 'EEXIST') throw new Folosire(`${cmd}: ${f} exists; a key file is never written over (the key it holds would be lost)`); throw e; }
};
const identitate = (f) => ID.importKeys(citeste(f));
/** what a file given to verify holds: one object, a list of them, or a registry { records: [...] }; flattened, every element is handed over */
const netezeste = (x, adancime = 0) => (adancime > 8 ? [x] : Array.isArray(x) ? x.flatMap((y) => netezeste(y, adancime + 1)) : x && typeof x === 'object' && Array.isArray(x.records) && x.statement === undefined ? netezeste(x.records, adancime + 1) : [x]);
const elemente = (f) => netezeste(citeste(f));
/** the public keys of an identity: a keys file, a { id, public } file, or the public keys object itself */
const publice = (f) => { const j = citeste(f); const p = j && j.public ? j.public : j; try { ID.publicKeyObjects(p); } catch (e) { throw new Folosire(`${f} holds no public keys: ${e.message}`); } return p; };
const inOre = (h) => new Date(Date.now() + h * 3600000).toISOString();
const lant = () => ({ m: citeste(cere('--manufacture')), t: toate('--transfer').map(citeste) });
async function main() {
if (cmd === 'new-identity') { const k = ID.generateKeys(); scrie(ID.exportKeys(k), get('--out'), { secret: true }); spune(k.id); return 0; }
if (cmd === 'new-device') { const d = D.generateDeviceKey(); scrie(D.exportDeviceKey(d), get('--out'), { secret: true }); spune(d.deviceId); return 0; }
if (cmd === 'manufacture') {
const dk = citeste(cere('--device')); const status = get('--status-list') ? { list: get('--status-list'), index: numar('--status-index') } : null;
const c = D.manufacture({ manufacturer: identitate(cere('--manufacturer')), deviceKey: dk.publicKey, model: cere('--model'), serial: cere('--serial'), validUntil: inOre(24 * numar('--days')), status });
scrie(c, get('--out')); spune(`${c.statement.claims.deviceId} made by ${c.statement.issuer.id}`); return 0;
}
if (cmd === 'release') {
const r = D.releaseFirmware({ signer: identitate(cere('--signer')), model: cere('--model'), version: cere('--version'), sequence: numar('--sequence'), image: get('--image') ? fs.readFileSync(get('--image')) : null, digest: get('--digest') || null,
softwareAttestation: get('--software') ? citeste(get('--software')) : null });
scrie(r, get('--out')); spune(`${r.statement.model} ${r.statement.version} (sequence ${r.statement.sequence}): ${r.statement.digest}`); return 0;
}
if (cmd === 'policy') { const p = D.firmwarePolicy({ manufacturer: identitate(cere('--manufacturer')), model: cere('--model'), minSequence: numar('--min-sequence', 0), retracted: toate('--retract'), validUntil: inOre(numar('--hours')) }); scrie(p, get('--out')); spune(`floor ${p.statement.minSequence}, ${p.statement.retracted.length} retracted, until ${p.statement.validUntil}`); return 0; }
if (cmd === 'transfer') { const { m, t } = lant(); const x = D.transferOwnership({ from: identitate(cere('--from')), to: publice(cere('--to')), manufacture: m, transfers: t }); scrie(x, get('--out')); spune(`transfer ${x.statement.seq}: ${x.statement.from.id} -> ${x.statement.to.id}`); return 0; }
if (cmd === 'authorize') { const { m, t } = lant(); const a = D.authorize({ owner: identitate(cere('--owner')), deviceId: m.statement.claims.deviceId, ownership: D.ownershipHead(m, t), actions: toate('--action'), notAfter: inOre(numar('--hours')) }); scrie(a, get('--out')); spune(`${a.statement.actions.join(', ')} until ${a.statement.notAfter}`); return 0; }
if (cmd === 'status') {
const { m, t } = lant(); const by = identitate(cere('--by')); const comun = { by, state: cere('--state'), seq: numar('--seq'), validUntil: inOre(numar('--hours')), reason: get('--reason') || null };
// the manufacturer signs as the manufacturer of this credential and names no ownership head; anybody else names the head of the chain given (--transfer)
const s = by.id === m.statement.issuer.id ? D.statusRecord({ ...comun, manufacture: m }) : D.statusRecord({ ...comun, deviceId: m.statement.claims.deviceId, ownership: D.ownershipHead(m, t) });
scrie(s, get('--out')); spune(`${s.statement.state} (record ${s.statement.seq}) by ${s.statement.by.id}` + (s.statement.ownership ? ` under ownership head ${s.statement.ownership}` : ' (the manufacturer)')); return 0;
}
if (cmd === 'attest') { const { m, t } = lant(); const x = D.attest({ device: D.importDeviceKey(citeste(cere('--device'))), nonce: cere('--nonce'), audience: cere('--audience'), firmware: citeste(cere('--firmware')), ownership: D.ownershipHead(m, t), action: cere('--action') }); scrie(x, get('--out')); spune(`${x.statement.deviceId} attests at ${x.statement.at}`); return 0; }
if (cmd === 'bundle') { const f = (n) => (get(n) ? citeste(get(n)) : null); scrie(D.bundle({ manufacture: f('--manufacture'), firmware: f('--firmware'), transfers: toate('--transfer').map(citeste), authorization: f('--authorization'), attestation: f('--attestation'), anchor: f('--anchor') }), get('--out')); return 0; }
if (cmd === 'envelope') { const e = D.proofOfDevice(citeste(cere('--attestation')), buildProof); scrie(e, get('--out')); spune(e.statementHash); 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: 'aere-proof-of-device', rpc: cere('--rpc'), keyFile: cere('--key-file'), ethers });
if (!r.ok) { striga(r.motiv); return r.cod === 1 ? 1 : 2; }
scrie(r.envelope, get('--out')); const n = r.envelope.notarization; spune(`notarized on chain ${n.chainId}: firstSeen ${n.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 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, get('--out')); spune(`first seen on chain ${r.reading.chainId} at ${new Date(r.reading.at * 1000).toISOString()}`); return 0;
}
if (cmd === 'verify') {
const o = {}; if (get('--now')) o.now = get('--now'); if (get('--nonce') != null) o.nonce = get('--nonce'); if (get('--audience') != null) o.audience = get('--audience'); if (get('--max-age')) o.maxAgeS = numar('--max-age');
const tr = toate('--trust'); if (tr.length) o.trustedManufacturers = tr.map((x) => (/^aere-id:[0-9a-f]{40}$/.test(x) ? x : publice(x)));
o.statusLists = toate('--status-list').flatMap(elemente); o.firmwarePolicies = toate('--policy').flatMap(elemente); o.statusRecords = toate('--status').flatMap(elemente); o.anchors = toate('--reading').flatMap(elemente);
if (get('--software')) o.softwareAttestation = citeste(get('--software'));
const pachet = citeste(cere('--bundle')); let r;
try { r = D.verifyDevice(pachet, o); } catch (e) { if (/^verifyDevice: /.test(String(e && e.message))) throw new Folosire(e.message); throw e; } // the verifier's own configuration cannot be read: no verdict
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) => D.PARTIALE.includes(c)).join(', ')})` : ''));
// what is named and does not change the verdict: said on its own line, never left to the rows alone
const notate = r.unjudged.filter((c) => D.NOTATE.includes(c)); if (notate.length) spune(`noted, the verdict stands: ${notate.join(', ')}`);
for (const p of (r.subject && r.subject.priorOwnership) || []) spune(`a previous owner (${p.owner}) signed ${p.records} status record(s) that say: ${p.state}. They do not bind the owner of now.`); }
return r.verdict === 'VALID' ? 0 : r.verdict === 'INVALID' ? 1 : 2;
}
throw new Folosire('usage: device-cli.mjs new-identity|new-device|manufacture|release|policy|transfer|authorize|status|attest|bundle|envelope|notarize|reading|verify ... (see README.md)');
}
/**
* The verifier's reading of a notarized device envelope: the AIP-23 reference verifier is run on it with the chain's endpoint, and
* the first-seen time it states under a certified anchor becomes { statementHash, at, chainId }. FAILED when the verifier refuses,
* NEMASURAT when finality is pending or the verifier did not answer.
*/
export async function citesteAncora(envelope, { verifyProof, rpc, chain = null }) {
const { ruleazaVerificatorul, firstSeenFromVerdict } = await import('../agents/agent-ancora.mjs');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-device-ancora-')); const f = path.join(dir, 'e.json'); fs.writeFileSync(f, JSON.stringify(envelope));
try {
const lantul = chain || (envelope.notarization && Number(envelope.notarization.chainId)) || null;
const v = await ruleazaVerificatorul(verifyProof, f, { rpc, chain: lantul });
if (!v) return { stare: 'NEMASURAT', motiv: 'the AIP-23 verifier did not answer' };
if (v.statementHash && String(v.statementHash).toLowerCase() !== envelope.statementHash) return { stare: 'FAILED', motiv: 'the verifier judged another statementHash' };
const fsn = firstSeenFromVerdict(v); if (fsn.stare !== 'PASSED') return { stare: fsn.stare, motiv: fsn.motiv };
return { stare: 'PASSED', reading: { v: 1, kind: 'aere-device-anchor-reading', statementHash: envelope.statementHash, at: fsn.at, chainId: lantul, witness: 'AereNotary first-seen under a post-quantum certified anchor' } };
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().then((c) => { process.exitCode = c; }, (e) => { striga(`device-cli: ${e && e.message}`); process.exitCode = e instanceof Folosire ? COD_FOLOSIRE : cmd === 'verify' ? COD_CADERE : 1; });
}