#!/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}`); } }; const scrie = (o, f, { secret = false } = {}) => { if (!f) throw new Folosire(`${cmd}: --out is required`); fs.writeFileSync(f, JSON.stringify(o, null, 1) + '\n', secret ? { mode: 0o600 } : {}); }; 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; ID.publicKeyObjects(p); 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 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(JSON.stringify(r, null, 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; }); }