aere-quantum/verify-layer/sidecar.mjs

393 lines
28 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// B3, AERE Verification Layer: un SIDECAR care ruleaza langa desfasurarea unui client (orice cloud sau on-prem) si tine un JURNAL
// DE AUDIT al ei, facut din plicuri AERE Proof Protocol (AIP-23), construite de ACELASI tools/proof-kinds si verificabile de ACELASI
// verificator. Fiecare intrare acopera hash-ul celei dinainte (lant de hash-uri, append-only).
//
// CE DOVEDESTE SI CE NU (revizuirea adversariala 2026-09-29, pista B; forma de dinainte spunea "fara sa aiba incredere in gazda"):
// - CONTINUTUL intrarilor e ce spune gazda: sidecar-ul ruleaza pe ea si citeste exportul facut de operator. Nimic de aici nu
// dovedeste ca un container a rulat, doar ca gazda a declarat asta, la ora pe care a declarat-o (`--at` e tot o declaratie).
// - Lantul NU are cheie: cine poate scrie fisierul poate reface tot lantul de la geneza, iar `verify-log` singur iese INTREG.
// Manipularea se vede numai FATA DE UN CAP publicat in afara gazdei: `attest-head` scoate capul ca plic, `notarize-head` il pune
// pe AereNotary (finalitate post-cuantica prin verificatorul AIP-23), iar `verify-log --attested <cap>` cere ca jurnalul de acum
// sa CONTINUE acel cap. Deci: integritatea istoriei de dinainte de un cap notarizat se verifica fara incredere in gazda;
// adevarul ei, nu.
//
// sidecar record --kind runtime --artifact f --attested 0x.. [--host h] [--log p] [--at T]
// sidecar record --kind deployment --name X --version V --content-file f [--host h] [--log p] [--at T]
// sidecar record --kind proof --proof-file f.json (leaga orice plic AIP-23 gata facut, dupa ce ii verifica forma si integritatea)
// sidecar scan --source docker|kubernetes --input export.json (fiecare container care RULEAZA; NUMAI numele variabilelor de mediu,
// linia de comanda ca hash; exportul il face operatorul)
// sidecar verify-log --log p [--attested cap.json [--signer head.pub.pem]] -> 0 intreg (si continua capul atestat), 1 rupt sau necontinuat
// sidecar attest-head --log p [--out f] [--sign-key head.key.pem] -> capul lantului ca plic AIP-23 aere-audit-head, semnat ML-DSA-65
// sidecar keygen --out <dosar> -> cheia ML-DSA-65 cu care operatorul semneaza capetele (head.key.pem 0600, head.pub.pem)
// sidecar notarize-head --head cap.json --rpc URL --key-file f [--notary 0x..] [--out f]
// sidecar bundle --log p [--out f] -> buraf {host, count, head, entries[]} pentru consola planului de control
// Mesajele catre utilizator sunt in engleza. Numai Node 24 (ethers numai pentru notarize-head).
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const TOOLS = path.resolve(AICI, '..');
const GENEZA = '0x' + '00'.repeat(32);
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
const sha256File = (p) => sha256(fs.readFileSync(p));
const eDigest = (s) => typeof s === 'string' && /^0x[0-9a-fA-F]{64}$/.test(s);
// hash-ul unei intrari acopera: seq, hash-ul precedent, si plicul canonic. Un singur loc, ca sa nu diverga scriitor de cititor.
export function hashIntrare(seq, prev, proof) {
return sha256(Buffer.from(`${seq}|${prev}|${JSON.stringify(proof)}`, 'utf8'));
}
// un rand care nu e JSON devine o intrare NECITIBILA, pe care verificaJurnal o raporteaza la locul ei (forma veche cadea intreaga)
function citesteJurnal(p) {
if (!fs.existsSync(p)) return [];
return fs.readFileSync(p, 'utf8').split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return { necitibil: true }; } });
}
/**
* Verifica lantul de hash-uri end-to-end. Returneaza {ok, count, head, rupt, motiv} - rupt = primul seq stricat sau null.
* NU spune nimic despre un lant refacut in intregime: pentru asta, verificaFataDeCap.
*/
export function verificaJurnal(intrari) {
let prev = GENEZA;
for (let i = 0; i < intrari.length; i++) {
const e = intrari[i];
if (!e || typeof e !== 'object' || e.necitibil) return { ok: false, count: intrari.length, head: null, rupt: i, motiv: `entry ${i} is not a JSON log entry` };
if (e.seq !== i) return { ok: false, count: intrari.length, head: null, rupt: i, motiv: `wrong seq: ${e.seq} instead of ${i}` };
if (e.prev !== prev) return { ok: false, count: intrari.length, head: null, rupt: i, motiv: `prev does not link entry ${i - 1}` };
if (hashIntrare(e.seq, e.prev, e.proof) !== e.hash) return { ok: false, count: intrari.length, head: null, rupt: i, motiv: `the hash of entry ${i} does not match its content (modified)` };
prev = e.hash;
}
return { ok: true, count: intrari.length, head: prev, rupt: null };
}
/**
* Jurnalul de acum CONTINUA un cap atestat (plicul aere-audit-head scos de attest-head, notarizat sau nu)?
* Cere: plicul intreg (statementHash se reface), lantul intreg, cel putin `count` intrari, si hash-ul intrarii count-1 == head.
* Returneaza {ok, motiv, count, extra} - extra = intrarile scrise dupa cap.
*/
export function verificaFataDeCap(intrari, cap, { semnatar = null } = {}) {
const st = cap && cap.statement;
if (!cap || cap.kind !== 'aere-audit-head-attestation' || !st || st.kind !== 'aere-audit-head') return { ok: false, motiv: 'the file is not an aere-audit-head attestation' };
if (!eDigest(cap.statementHash) || sha256(Buffer.from(JSON.stringify(st), 'utf8')) !== cap.statementHash.toLowerCase()) return { ok: false, motiv: 'the attestation statementHash does not match its statement (modified attestation)' };
const sg = verificaSemnaturaCap(cap, semnatar);
if (!sg.ok) return { ok: false, motiv: sg.motiv };
if (!Number.isInteger(st.count) || st.count < 0 || !eDigest(st.head)) return { ok: false, motiv: 'the attestation has no valid count and head' };
const v = verificaJurnal(intrari);
if (!v.ok) return { ok: false, motiv: `the log is broken at seq ${v.rupt}: ${v.motiv}` };
if (intrari.length < st.count) return { ok: false, motiv: `the log has ${intrari.length} entries and the attested head covers ${st.count}: entries were removed` };
const h = st.count === 0 ? GENEZA : intrari[st.count - 1].hash;
if (h !== st.head.toLowerCase()) return { ok: false, motiv: `entry ${st.count - 1} is not the attested head: the history before the attested point was rewritten` };
return { ok: true, count: st.count, extra: intrari.length - st.count, semnatDe: sg.semnat ? sg.keyId : null };
}
// ---- capul SEMNAT (2026-09-29, roadmap punctul 34, "jurnale semnate") ---------------------------------------------------------
// Capul poate purta semnatura ML-DSA-65 a operatorului, in forma pe care o verifica verificatorul AIP-23 de referinta (nivelul
// `signature`): { scheme: 'ml-dsa-65', publicKey: SPKI DER base64, signature: base64 peste textul canonic al declaratiei }. Semnatura
// spune CINE garanteaza capul, nu CAND (asta o da notarizarea) si nu ca istoria e adevarata. Cine tine cheia poate semna si un cap
// al unei istorii rescrise: semnatura leaga capul de operator, notarizarea il leaga de un moment; `--signer` cere cheia asteptata.
const idCheie = (pub) => sha256(pub.export({ type: 'spki', format: 'der' }));
export function semneazaCap(plic, cheiePem) {
const priv = crypto.createPrivateKey(cheiePem);
if (priv.asymmetricKeyType !== 'ml-dsa-65') throw new Error('the head signing key must be ML-DSA-65 (sidecar keygen)');
const pub = crypto.createPublicKey(priv);
const sig = crypto.sign(null, Buffer.from(JSON.stringify(plic.statement), 'utf8'), priv);
return { ...plic, signature: { scheme: 'ml-dsa-65', publicKey: pub.export({ type: 'spki', format: 'der' }).toString('base64'), signature: sig.toString('base64') } };
}
/** @returns {{ok:boolean, semnat:boolean, keyId?:string, motiv?:string}} semnatarPem: cheia publica asteptata (optional) */
export function verificaSemnaturaCap(plic, semnatarPem = null) {
const s = plic && plic.signature;
if (!s) return semnatarPem ? { ok: false, semnat: false, motiv: 'the head is not signed, and a signer was required' } : { ok: true, semnat: false };
if (String(s.scheme).toLowerCase() !== 'ml-dsa-65' || !s.publicKey || !s.signature) return { ok: false, semnat: true, motiv: 'the head signature is not an ml-dsa-65 signature' };
let pub; try { pub = crypto.createPublicKey({ key: Buffer.from(String(s.publicKey), 'base64'), format: 'der', type: 'spki' }); } catch { return { ok: false, semnat: true, motiv: 'the head signature public key cannot be read' }; }
let ok = false; try { ok = crypto.verify(null, Buffer.from(JSON.stringify(plic.statement), 'utf8'), pub, Buffer.from(String(s.signature), 'base64')); } catch { ok = false; }
if (!ok) return { ok: false, semnat: true, keyId: idCheie(pub), motiv: 'the head signature does not verify over the statement' };
if (semnatarPem) {
let asteptat; try { asteptat = crypto.createPublicKey(semnatarPem); } catch { return { ok: false, semnat: true, motiv: 'the expected signer key cannot be read' }; }
if (idCheie(asteptat) !== idCheie(pub)) return { ok: false, semnat: true, keyId: idCheie(pub), motiv: `the head is signed by another key (${idCheie(pub).slice(0, 18)}), not the expected signer` };
}
return { ok: true, semnat: true, keyId: idCheie(pub) };
}
// ---- adaptorul de runtime: exportul pe care operatorul il face el insusi, fara acreditari de cloud ------------------------------
// Intrarea e `docker inspect $(docker ps -q)` sau `kubectl get pods -A -o json`. Pentru fiecare container care RULEAZA se scrie un
// descriptor canonic, si el devine un plic AIP-23 de desfasurare. NICIO VALOARE de mediu nu intra: numai NUMELE variabilelor, iar
// linia de comanda intra ca hash (un hash NU ascunde un secret ghicibil din argumente, spus in README). Montarile intra numai cu
// destinatia din container, nu cu calea de pe gazda.
const canonic = (o) => JSON.stringify(o, Object.keys(o).sort());
const numeEnv = (env) => (Array.isArray(env) ? env : []).map((e) => String(e).split('=')[0]).filter(Boolean).sort();
export function descrieDocker(inspect) {
if (!Array.isArray(inspect)) throw new Error('docker: the input is not the output of `docker inspect` (an array)');
const out = [];
for (const c of inspect) {
if (!c || !c.State || c.State.Running !== true) continue;
const d = {
runtime: 'docker',
name: String(c.Name || '').replace(/^\//, ''),
image: c.Config?.Image || null,
imageId: c.Image || null,
commandSha256: sha256(Buffer.from(JSON.stringify([c.Path || null, ...(c.Args || [])]), 'utf8')),
envNames: numeEnv(c.Config?.Env),
mounts: (c.Mounts || []).map((m) => m.Destination).filter(Boolean).sort(),
startedAt: c.State.StartedAt || null,
restartCount: c.RestartCount ?? null,
};
out.push({ name: `docker:${d.name}`, version: d.imageId, content: Buffer.from(canonic(d), 'utf8') });
}
return out;
}
export function descrieKubernetes(lista) {
if (!lista || !Array.isArray(lista.items)) throw new Error('kubernetes: the input is not the output of `kubectl get pods -o json` (items[])');
const out = [];
for (const pod of lista.items) {
const spec = new Map((pod.spec?.containers || []).map((c) => [c.name, c]));
for (const st of pod.status?.containerStatuses || []) {
if (!st.state || !st.state.running) continue;
const c = spec.get(st.name) || {};
const d = {
runtime: 'kubernetes',
namespace: pod.metadata?.namespace || null,
pod: pod.metadata?.name || null,
name: st.name,
image: st.image || c.image || null,
imageId: st.imageID || null,
commandSha256: sha256(Buffer.from(JSON.stringify([...(c.command || []), ...(c.args || [])]), 'utf8')),
envNames: (c.env || []).map((e) => e.name).filter(Boolean).sort(),
mounts: (c.volumeMounts || []).map((m) => m.mountPath).filter(Boolean).sort(),
startedAt: st.state.running.startedAt || null,
restartCount: st.restartCount ?? null,
};
out.push({ name: `k8s:${d.namespace}/${d.pod}/${d.name}`, version: d.imageId, content: Buffer.from(canonic(d), 'utf8') });
}
}
return out;
}
// ---- scrierea: un singur scriitor odata ----------------------------------------------------------------------------------------
// Doi scriitori fara lacat citeau aceeasi lungime si scriau acelasi seq: lantul se rupea, si de atunci ORICE adaugare era refuzata
// (revizuirea adversariala 2026-09-29, masurat: doi scriitori concurenti rup lantul). Lacatul e un fisier creat exclusiv langa
// jurnal, cu PID-ul scriitorului; unul lasat de un proces care nu mai exista (si mai vechi de 10 s) se ridica singur.
const traieste = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
const asteapta = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
function cuLacat(p, fn) {
const lacat = p + '.lock';
const pana = Date.now() + 15000;
for (;;) {
try { const fd = fs.openSync(lacat, 'wx'); fs.writeSync(fd, String(process.pid)); fs.closeSync(fd); break; } catch (e) {
if (e.code !== 'EEXIST') throw e;
let pid = NaN; let varsta = 0;
try { pid = Number(fs.readFileSync(lacat, 'utf8')); varsta = Date.now() - fs.statSync(lacat).mtimeMs; } catch { continue; }
if (Number.isInteger(pid) && pid > 0 && !traieste(pid) && varsta > 10000) { try { fs.unlinkSync(lacat); } catch { /* altul l-a ridicat */ } continue; }
if (Date.now() > pana) throw new Error(`the log is locked by another writer (${lacat}); if no writer is running, remove that file`);
asteapta(20 + Math.floor(Math.random() * 30));
}
}
try { return fn(); } finally { try { fs.unlinkSync(lacat); } catch { /* deja ridicat */ } }
}
// adauga plicurile in ordine, sub lacat, peste un lant verificat O SINGURA DATA; `dupa(r, i)` ruleaza tot sub lacat (descriptorii)
function adauga(p, plicuri, dupa) {
return cuLacat(p, () => {
const intrari = citesteJurnal(p);
const v = verificaJurnal(intrari);
if (!v.ok) throw new Error(`the existing log is broken at seq ${v.rupt} (${v.motiv}); refusing to append to a broken chain`);
let seq = intrari.length;
let prev = seq === 0 ? GENEZA : intrari[seq - 1].hash;
const rez = [];
for (let i = 0; i < plicuri.length; i++) {
const hash = hashIntrare(seq, prev, plicuri[i]);
fs.appendFileSync(p, JSON.stringify({ seq, prev, proof: plicuri[i], hash }) + '\n');
const r = { seq, hash };
rez.push(r);
if (dupa) dupa(r, i);
prev = hash; seq++;
}
return rez;
});
}
/** Adauga plicuri AIP-23 gata facute in jurnal, in ordine, sub lacat (pentru cine foloseste sidecar-ul ca modul). */
export function adaugaPlicuri(p, plicuri) { return adauga(p, plicuri); }
async function incarcaEthers() {
const { createRequire } = await import('node:module');
const req = createRequire(import.meta.url);
try { return req('ethers'); } catch { /* nu e langa sidecar */ }
try { return req(path.resolve(TOOLS, '..', 'contracts', 'node_modules', 'ethers')); } catch { /* nici in depozitul de dezvoltare */ }
return null;
}
async function main() {
const [cmd, ...rest] = process.argv.slice(2);
const get = (f, d = null) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : d; };
const pk = await import(pathToFileURL(path.join(TOOLS, 'proof-kinds', 'proof-kinds.mjs')).href);
const log = get('--log', 'aere-audit.log');
const host = get('--host', 'sidecar');
const at = get('--at') || new Date().toISOString();
switch (cmd) {
case 'record': {
const kind = get('--kind');
let proof;
if (kind === 'runtime') {
const artifact = get('--artifact'); const attested = get('--attested');
if (!artifact || !attested) { console.error('record --kind runtime needs --artifact and --attested'); return 2; }
if (!eDigest(attested)) { console.error('record --kind runtime: --attested must be a sha256 digest, 0x followed by 64 hex characters'); return 2; }
const artifactSha256 = sha256File(artifact);
proof = pk.buildProof('runtime', { host, artifactSha256, attestedSha256: attested, matches: artifactSha256 === attested.toLowerCase(), unit: get('--unit') || undefined, createdAt: at });
} else if (kind === 'deployment') {
const name = get('--name'); const version = get('--version'); const cf = get('--content-file');
if (!name || !cf) { console.error('record --kind deployment needs --name and --content-file'); return 2; }
// desfasurarea = un plic 'data' peste artefactul desfasurat (digest, nu continut brut), cu numele+versiunea
proof = pk.buildProof('data', { name: `${name}@${version || '?'}`, content: fs.readFileSync(cf), createdAt: at });
} else if (kind === 'proof') {
// leaga ORICE plic AIP-23 gata facut, dupa ce ii verifica forma si integritatea; un plic manipulat nu intra in lant
const pf = get('--proof-file');
if (!pf) { console.error('record --kind proof needs --proof-file'); return 2; }
proof = JSON.parse(fs.readFileSync(pf, 'utf8'));
if (!(proof && proof.statement && eDigest(proof.statementHash))) { console.error('record --kind proof: the file is not an AIP-23 envelope {statement, statementHash}'); return 2; }
if (sha256(Buffer.from(JSON.stringify(proof.statement), 'utf8')) !== proof.statementHash.toLowerCase()) {
console.error('record --kind proof: statementHash does not match the statement (modified envelope); not recorded'); return 2;
}
} else { console.error('record: --kind runtime | deployment | proof'); return 2; }
const [r] = adauga(log, [proof]);
console.log(`recorded seq=${r.seq} kind=${kind} hash=${r.hash} in ${log}`);
return 0;
}
case 'scan': {
const src = get('--source'); const f = get('--input');
if (!['docker', 'kubernetes'].includes(src) || !f) { console.error('scan --source docker|kubernetes --input <export.json>'); return 2; }
let desc;
try {
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
desc = src === 'docker' ? descrieDocker(j) : descrieKubernetes(j);
} catch (e) { console.error('scan: ' + e.message); return 2; }
if (desc.length === 0) { console.error('scan: no running container in the export; a zero is not recorded'); return 2; }
// plicul 'data' poarta numai digestul descriptorului; descriptorul insusi (fara valori de mediu, prin constructie) sta
// alaturi, ca un auditor sa il poata re-hasha si compara cu plicul din lant; scris sub acelasi lacat
const desFile = get('--descriptors', log + '.descriptori.jsonl');
const plicuri = desc.map((d) => pk.buildProof('data', { name: `${host}/${d.name}@${d.version || '?'}`, content: d.content, createdAt: at }));
adauga(log, plicuri, (r, i) => {
const d = desc[i];
fs.appendFileSync(desFile, JSON.stringify({ seq: r.seq, name: d.name, sha256: sha256(d.content), descriptor: JSON.parse(d.content.toString('utf8')) }) + '\n');
console.log(`recorded seq=${r.seq} ${d.name} ${String(d.version || '?').slice(0, 19)}`);
});
console.log(`scan ${src}: ${desc.length} running containers, ${desc.length} envelopes in ${log}, descriptors in ${desFile}`);
return 0;
}
case 'verify-log': {
const intrari = citesteJurnal(log);
const af = get('--attested'); const sf = get('--signer');
if (sf && !af) { console.log('--signer needs --attested <head>: the signature is on the attested head'); return 2; }
if (af) {
let cap, semnatar = null;
try { cap = JSON.parse(fs.readFileSync(af, 'utf8')); } catch (e) { console.log(`cannot read the attestation ${af}: ${e.message}`); return 2; }
if (sf) { try { semnatar = fs.readFileSync(sf, 'utf8'); } catch (e) { console.log(`cannot read the signer key ${sf}: ${e.message}`); return 2; } }
const r = verificaFataDeCap(intrari, cap, { semnatar });
const cine = r.semnatDe ? `the head is signed by key ${r.semnatDe.slice(0, 18)}${semnatar ? ', the expected signer' : ' (not checked against an expected signer: --signer)'}` : 'the head is not signed';
if (r.ok) { console.log(`log CONTINUES the attested head: its first ${r.count} entries are the attested ones, ${r.extra} written after; ${cine}`); return 0; }
console.log(`log does NOT continue the attested head: ${r.motiv}`); return 1;
}
const v = verificaJurnal(intrari);
if (v.ok) { console.log(`log INTACT: ${v.count} entries, head ${v.head} (a rewrite of the whole chain is detected only against an attested head: --attested)`); return 0; }
console.log(`log BROKEN at seq ${v.rupt}: ${v.motiv}`); return 1;
}
case 'attest-head': {
// capul lantului devine un plic AIP-23 (aere-audit-head), notarizabil pe AereNotary -> integritatea istoriei de pana la el
// capata finalitate post-cuantica prin ACELASI verificator, fara cod nou
const intrari = citesteJurnal(log);
const v = verificaJurnal(intrari);
if (!v.ok) { console.log(`refusing to attest a broken chain (seq ${v.rupt})`); return 1; }
const statement = { v: 1, kind: 'aere-audit-head', host, count: v.count, head: v.head, createdAt: at };
let plic = { v: 1, kind: 'aere-audit-head-attestation', statement, statementHash: sha256(Buffer.from(JSON.stringify(statement), 'utf8')) };
const kf = get('--sign-key');
if (kf) { try { plic = semneazaCap(plic, fs.readFileSync(kf, 'utf8')); } catch (e) { console.log(`cannot sign the head: ${String(e.message).replace(/[0-9a-fA-F]{32,}/g, '<hex>').slice(0, 160)}`); return 2; } }
const out = get('--out'); const s = JSON.stringify(plic, null, 1);
if (out) { fs.writeFileSync(out, s); console.log('written', out, plic.statementHash); } else console.log(s);
return 0;
}
case 'keygen': {
// cheia ML-DSA-65 a operatorului pentru capete; cheia privata ramane in fisierul ei (0600), nu se tipareste nimic din ea
const out = get('--out'); if (!out) { console.error('keygen --out <dir>'); return 2; }
fs.mkdirSync(out, { recursive: true });
const kf = path.join(out, 'head.key.pem');
if (fs.existsSync(kf)) { console.error(`${kf} exists; refusing to overwrite a key`); return 2; }
const { publicKey, privateKey } = crypto.generateKeyPairSync('ml-dsa-65');
fs.writeFileSync(kf, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
fs.writeFileSync(path.join(out, 'head.pub.pem'), publicKey.export({ type: 'spki', format: 'pem' }), { mode: 0o644 });
console.log(`head signing key written to ${out} (head.key.pem, head.pub.pem): key ${idCheie(publicKey)}`);
return 0;
}
case 'notarize-head': {
// capul jurnalului pe AereNotary, ca verificatorul AIP-23 sa ii dea finalitate post-cuantica (ancora certificata -> radacina de
// stare -> dovada Merkle a lui firstSeen[statementHash]). Garzi: lantul se CITESTE de pe RPC (eth_chainId), nu se crede din
// argument, si pe mainnet (2800) se cere confirmare explicita; cheia se citeste din fisier, NU se tipareste, si orice eroare e
// taiata de sirurile hexa lungi inainte de afisare.
// sidecar notarize-head --head plic.json --rpc URL --key-file f [--notary 0x..] [--out plic-notarizat.json]
const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 300);
const headF = get('--head'); const rpc = get('--rpc'); const keyF = get('--key-file');
if (!headF || !rpc || !keyF) { console.error('notarize-head --head cap.json --rpc URL --key-file f [--notary 0x..] [--out f]'); return 2; }
const plic = JSON.parse(fs.readFileSync(headF, 'utf8'));
if (plic.kind !== 'aere-audit-head-attestation' || !/^0x[0-9a-f]{64}$/.test(plic.statementHash || '')) { console.error('REFUSED: not an aere-audit-head attestation with a 32-byte statementHash'); return 2; }
const recalc = sha256(Buffer.from(JSON.stringify(plic.statement), 'utf8'));
if (recalc !== plic.statementHash) { console.error('REFUSED: statementHash does not match the statement (modified attestation)'); return 1; }
const ethers = await incarcaEthers();
if (!ethers) { console.error('notarize-head needs the ethers package: run `npm install` in this directory'); return 2; }
// aceleasi adrese ca NOTARY din verificatorul AIP-23 (verify-proof.mjs)
const NOTARI = { 2800: '0x4aB392c4Aca7D9D4C16c0b60a9514c5025bd58c7', 28001: '0x70099E62735500AA2F85B60C518551a57B202d54' };
try {
const provider = new ethers.JsonRpcProvider(rpc);
const chainId = Number((await provider.getNetwork()).chainId);
if (chainId === 2800 && process.env.AERE_CONFIRM_MAINNET !== 'yes') { console.error('REFUSED: this RPC serves chain 2800 (Aere Network mainnet); a notarization there is a real transaction paid by the key\'s account. Set AERE_CONFIRM_MAINNET=yes to send it.'); return 3; }
const notary = get('--notary') || NOTARI[chainId];
if (!notary || !/^0x[0-9a-fA-F]{40}$/.test(notary)) { console.error(`REFUSED: no known notary on chain ${chainId}; pass --notary`); return 2; }
const cod = await provider.getCode(notary);
if (!cod || cod === '0x') { console.error(`REFUSED: there is no contract at ${notary} on chain ${chainId}`); return 1; }
const brut = fs.readFileSync(keyF, 'utf8').split(/\r?\n/).map((l) => l.trim());
const d = (brut.find((l) => l.startsWith('d=')) || brut.find((l) => /^PRIVATE_KEY=/.test(l)) || '').replace(/^(d|PRIVATE_KEY)=/, '').replace(/^0x/, '').trim();
// un rand d= poate veni fara zerourile de la inceput (asa il scriu unele unelte), deci se completeaza la 32 de octeti
if (!/^[0-9a-fA-F]{1,64}$/.test(d)) { console.error('REFUSED: the key file has no line d=<hex> or PRIVATE_KEY=0x<hex>'); return 2; }
const wallet = new ethers.Wallet('0x' + d.padStart(64, '0'), provider);
const c = new ethers.Contract(notary, ['function notarize(bytes32 h) external', 'function firstSeen(bytes32) view returns (uint64)'], wallet);
const inainte = Number(await c.firstSeen(plic.statementHash));
let txHash = null, block = null;
if (inainte === 0) {
const tx = await c.notarize(plic.statementHash);
const rc = await tx.wait(1, 120000);
if (!rc || rc.status !== 1) { console.error('the transaction failed'); return 1; }
txHash = rc.hash; block = rc.blockNumber;
}
const dupa = Number(await c.firstSeen(plic.statementHash));
if (!(dupa > 0)) { console.error('firstSeen is still 0 after the notarization'); return 1; }
const iesire = { ...plic, notarization: { chainId, notary, firstSeen: dupa, ...(txHash ? { txHash, block } : { already: true }) } };
const out = get('--out'); const s = JSON.stringify(iesire, null, 1);
if (out) fs.writeFileSync(out, s); else console.log(s);
console.log(`notarized on chain ${chainId} at notary ${notary}: firstSeen ${dupa}${txHash ? `, tx ${txHash}, block ${block}` : ' (already notarized)'} by ${wallet.address}`);
return 0;
} catch (e) { console.error('the notarization failed: ' + taie(e.shortMessage || e.message)); return 1; }
}
case 'bundle': {
const intrari = citesteJurnal(log);
const v = verificaJurnal(intrari);
const buraf = { v: 1, kind: 'aere-verify-layer-bundle', host, count: intrari.length, head: v.ok ? v.head : null, chainOk: v.ok, entries: intrari, createdAt: at };
const out = get('--out'); const s = JSON.stringify(buraf, null, 1);
if (out) { fs.writeFileSync(out, s); console.log('written', out, 'chainOk=' + v.ok); } else console.log(s);
return v.ok ? 0 : 1;
}
default:
console.log('AERE Verification Layer (sidecar). Commands: record | scan | verify-log | attest-head | notarize-head | bundle');
console.log(' sidecar record --kind runtime --artifact f --attested 0x.. sidecar verify-log --log p [--attested cap.json [--signer head.pub.pem]] sidecar attest-head --log p --out f [--sign-key head.key.pem] sidecar keygen --out dir');
return cmd ? 1 : 0;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
// process.exitCode, nu process.exit(): dupa apeluri de retea (notarize-head), exit() cade in libuv pe Windows (capcana 2026-09-11)
main().then((c) => { process.exitCode = c; }).catch((e) => { console.error(String(e.message).replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>')); process.exitCode = 1; });
}