aere-quantum/verify-layer/sidecar.mjs

682 lines
54 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 scan --source aws-ecs|aws-ec2|aws-lambda|azure-vm|azure-container|azure-containerapp|azure-webapp|gcp-compute|gcp-run
// --input export.json [--task-definitions f] (B15, 2026-10-18: exportul CLI-ului furnizorului, facut de operator; aceeasi
// forma: numai ce RULEAZA, numai NUMELE variabilelor, nicio acreditare si niciun apel la API)
// 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
// sidecar tree-head --log p [--sign-key k] [--out f] -> capul de ARBORE (RFC 9162): marimea si radacina, plic AIP-23
// sidecar prove-inclusion --log p --seq N [--size S] [--out f] -> dovada ca intrarea N e in arbore (log2 n hash-uri)
// sidecar prove-consistency --log p --from M [--to N] [--out f] -> dovada ca arborele de M e un prefix al celui de N
// sidecar verify-inclusion --proof f [--head cap.json [--signer pub]] -> 0 inclusa, 1 nu (FARA jurnal)
// sidecar verify-consistency --proof f --old cap1.json --new cap2.json [--signer pub] -> 0 capul nou il continua pe cel vechi
// 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.
*/
// Every refusal of the checks below NAMES its rule (`regula`); a rule that is not declared cannot be named. Each check of a rule carries
// its marker, so that a conformance suite can take the rule out, in a copy, and see which of its vectors then pass.
export const REGULI = ['intrare', 'seq', 'lant', 'hash', 'cap-forma', 'cap-hash', 'cap-nesemnat', 'cap-schema', 'cap-cheie', 'cap-semnatura', 'cap-semnatar', 'cap-scurt', 'cap-rescris'];
const refuz = (regula, motiv, extra = {}) => { if (!REGULI.includes(regula)) throw new Error('sidecar: undeclared rule ' + regula); return { ok: false, regula, motiv, ...extra }; };
export function verificaJurnal(intrari) {
if (!(Array.isArray(intrari) /*PAZA:intrare*/)) return refuz('intrare', 'the log is not a list of entries', { count: 0, head: null, rupt: 0 });
let prev = GENEZA;
for (let i = 0; i < intrari.length; i++) {
const e = intrari[i]; const la = { count: intrari.length, head: null, rupt: i };
if (!(!!e && typeof e === 'object' && !e.necitibil /*PAZA:intrare*/)) return refuz('intrare', `entry ${i} is not a JSON log entry`, la);
if (!(e.seq === i /*PAZA:seq*/)) return refuz('seq', `wrong seq: ${e.seq} instead of ${i}`, la);
if (!(e.prev === prev /*PAZA:lant*/)) return refuz('lant', `prev does not link entry ${i - 1}`, la);
if (!(hashIntrare(e.seq, e.prev, e.proof) === e.hash /*PAZA:hash*/)) return refuz('hash', `the hash of entry ${i} does not match its content (modified)`, la);
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' /*PAZA:cap-forma*/)) return refuz('cap-forma', 'the file is not an aere-audit-head attestation');
if (!(!!cap && eDigest(cap.statementHash) && sha256(Buffer.from(String(JSON.stringify(st)), 'utf8')) === cap.statementHash.toLowerCase() /*PAZA:cap-hash*/)) return refuz('cap-hash', 'the attestation statementHash does not match its statement (modified attestation)');
const sg = verificaSemnaturaCap(cap, semnatar);
if (!sg.ok) return sg;
if (!(!!st && Number.isInteger(st.count) && st.count >= 0 && eDigest(st.head) /*PAZA:cap-forma*/)) return refuz('cap-forma', 'the attestation has no valid count and head');
const v = verificaJurnal(intrari);
if (!v.ok) return refuz(v.regula, `the log is broken at seq ${v.rupt}: ${v.motiv}`);
if (!(intrari.length >= st.count /*PAZA:cap-scurt*/)) return refuz('cap-scurt', `the log has ${intrari.length} entries and the attested head covers ${st.count}: entries were removed`);
const ultima = intrari[st.count - 1]; const h = st.count === 0 ? GENEZA : (ultima ? ultima.hash : null);
if (!(h === String(st.head).toLowerCase() /*PAZA:cap-rescris*/)) return refuz('cap-rescris', `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, regula?:string}} semnatarPem: cheia publica asteptata (optional) */
export function verificaSemnaturaCap(plic, semnatarPem = null) {
const s = plic && plic.signature;
if (!s) { if (!(!semnatarPem /*PAZA:cap-nesemnat*/)) return refuz('cap-nesemnat', 'the head is not signed, and a signer was required', { semnat: false }); return { ok: true, semnat: false }; }
if (!(String(s.scheme).toLowerCase() === 'ml-dsa-65' && !!s.publicKey && !!s.signature /*PAZA:cap-schema*/)) return refuz('cap-schema', 'the head signature is not an ml-dsa-65 signature', { semnat: true });
let pub = null; try { pub = crypto.createPublicKey({ key: Buffer.from(String(s.publicKey), 'base64'), format: 'der', type: 'spki' }); } catch { pub = null; }
if (!(pub !== null /*PAZA:cap-cheie*/)) return refuz('cap-cheie', 'the head signature public key cannot be read', { semnat: true });
// The key must BE an ML-DSA-65 key. crypto.verify(null, ...) verifies with whatever key it is handed: until 2026-10-03 a head
// "signed" with an Ed25519 (or RSA, or P-256) key that declared the scheme ml-dsa-65 verified here as an ML-DSA-65 signed head.
if (!(!!pub && pub.asymmetricKeyType === 'ml-dsa-65' /*PAZA:cap-schema*/)) return refuz('cap-schema', `the head declares an ml-dsa-65 signature and its public key is ${pub ? pub.asymmetricKeyType : 'unreadable'}`, { semnat: true });
const id = pub ? idCheie(pub) : null;
let ok = false; try { ok = crypto.verify(null, Buffer.from(String(JSON.stringify(plic.statement)), 'utf8'), pub, Buffer.from(String(s.signature), 'base64')); } catch { ok = false; }
if (!(ok /*PAZA:cap-semnatura*/)) return refuz('cap-semnatura', 'the head signature does not verify over the statement', { semnat: true, keyId: id });
if (semnatarPem) {
let asteptat = null; try { asteptat = crypto.createPublicKey(semnatarPem); } catch { asteptat = null; }
if (!(asteptat !== null && idCheie(asteptat) === id /*PAZA:cap-semnatar*/)) return refuz('cap-semnatar', asteptat ? `the head is signed by another key (${String(id).slice(0, 18)}), not the expected signer` : 'the expected signer key cannot be read', { semnat: true, keyId: id });
}
return { ok: true, semnat: true, keyId: id };
}
// ---- 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;
}
// ---- adaptoarele pe furnizori de cloud (B15, 2026-10-18): ACEEASI forma, onesta ------------------------------------------------
// Exportul il face operatorul cu CLI-ul furnizorului, cu acreditarile LUI, pe masina lui; sidecar-ul citeste fisierul si atat: nicio
// acreditare in sidecar, niciun apel la un API. Fiecare adaptor: (1) refuza pe nume un fisier care nu e iesirea CLI-ului numit;
// (2) ia numai incarcaturile pe care exportul le declara ca RULEAZA; (3) scrie identitatea (imagine + digest cand exportul il are,
// altfel id-ul masinii/serviciului + imaginea declarata, `imageIdentity: 'declared'`), regiunea/zona, NUMAI numele variabilelor de
// mediu (si ale etichetelor, setarilor, cheilor de metadata), NICIO valoare de userData/customData/metadata (nu se copiaza deloc).
// Descriptorul e canonic (chei sortate la toate nivelurile) si devine un plic 'data' prin acelasi constructor ca Docker/Kubernetes.
// Masurate pe iesirile DOCUMENTATE ale CLI-urilor (fixturi-cloud/), nu pe un cont real: nu avem conturi la AWS/Azure/GCP.
const canonicAdanc = (v) => (Array.isArray(v) ? '[' + v.map((x) => canonicAdanc(x === undefined ? null : x)).join(',') + ']'
: v && typeof v === 'object' ? '{' + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ':' + canonicAdanc(v[k])).join(',') + '}'
: JSON.stringify(v === undefined ? null : v));
const numeDin = (lista, cheie = 'name') => [...new Set((Array.isArray(lista) ? lista : []).map((e) => (e && typeof e === 'object' ? e[cheie] : e)).filter((n) => typeof n === 'string' && n))].sort();
const ultimul = (url) => (typeof url === 'string' && url ? url.split('/').filter(Boolean).pop() || null : null);
const grupulDin = (id) => { const m = /\/resourceGroups\/([^/]+)\//i.exec(String(id || '')); return m ? m[1] : null; };
const digestDinImagine = (img) => { const m = /@(sha256:[0-9a-fA-F]{64})$/.exec(String(img || '')); return m ? m[1].toLowerCase() : null; };
// az CLI scrie resursele ARM cu `properties` aplatizat; `az rest` si extensia containerapp le lasa imbricate: se citesc amandoua formele
const aplatizat = (o) => (o && typeof o === 'object' && o.properties && typeof o.properties === 'object' ? { ...o.properties, ...o } : o);
const intrare = (nume, version, d) => ({ name: nume, version: version || '?', content: Buffer.from(canonicAdanc(d), 'utf8') });
/** `aws ecs describe-tasks` (+ optional `aws ecs describe-task-definition`, una sau o lista, pentru numele variabilelor si comanda) */
export function descrieAwsEcs(j, definitii = null) {
if (!(!!j && !Array.isArray(j) && Array.isArray(j.tasks) /*FORMA:aws-ecs*/)) throw new Error('aws-ecs: the input is not the output of `aws ecs describe-tasks` (an object with tasks[])');
const defs = new Map();
for (const d of definitii == null ? [] : Array.isArray(definitii) ? definitii : [definitii]) {
const td = d && d.taskDefinition;
if (!(!!td && typeof td.taskDefinitionArn === 'string' && Array.isArray(td.containerDefinitions) /*FORMA:aws-ecs-definitii*/)) throw new Error('aws-ecs: --task-definitions is not the output of `aws ecs describe-task-definition` (taskDefinition.containerDefinitions[])');
defs.set(td.taskDefinitionArn, td);
}
const out = [];
for (const t of j.tasks) {
if (!(!!t && t.lastStatus === 'RUNNING' /*RULEAZA:aws-ecs*/)) continue;
const td = defs.get(t.taskDefinitionArn) || null;
const region = String(t.taskArn || '').split(':')[3] || null;
for (const c of t.containers || []) {
// si containerul trebuie sa ruleze: un task RUNNING poate avea un container neesential deja oprit
if (!(!!c && c.lastStatus === 'RUNNING' /*RULEAZA:aws-ecs-container*/)) continue;
const cd = td ? td.containerDefinitions.find((x) => x && x.name === c.name) || null : null;
const over = (t.overrides?.containerOverrides || []).find((x) => x && x.name === c.name) || null;
const d = {
runtime: 'aws-ecs', region, clusterArn: t.clusterArn || null, taskArn: t.taskArn || null, taskDefinitionArn: t.taskDefinitionArn || null,
container: c.name, image: c.image || cd?.image || null, imageDigest: c.imageDigest || null, imageIdentity: c.imageDigest ? 'digest' : 'declared',
runtimeId: c.runtimeId || null, launchType: t.launchType || null, availabilityZone: t.availabilityZone || null, startedAt: t.startedAt || null,
group: t.group || null, taskVersion: t.version ?? null,
commandSha256: cd ? sha256(Buffer.from(JSON.stringify([...(cd.entryPoint || []), ...(over?.command || cd.command || [])]), 'utf8')) : null,
// numele din definitie (environment + secrets, al caror valueFrom e un ARN, nu o valoare) si din overrides; valorile nu intra
envNames: numeDin([...(cd?.environment || []), ...(cd?.secrets || []), ...(over?.environment || [])] /*NUME:aws-ecs*/),
envSource: td ? (over?.environment ? 'task-definition+overrides' : 'task-definition') : over?.environment ? 'overrides-only' : 'not-in-export',
mounts: (cd?.mountPoints || []).map((m) => m && m.containerPath).filter(Boolean).sort(),
};
out.push(intrare(`ecs:${ultimul(t.clusterArn)}/${ultimul(t.taskArn)}/${c.name}`, c.imageDigest || c.image, d));
}
}
return out;
}
/** `aws ec2 describe-instances` */
export function descrieAwsEc2(j) {
if (!(!!j && !Array.isArray(j) && Array.isArray(j.Reservations) /*FORMA:aws-ec2*/)) throw new Error('aws-ec2: the input is not the output of `aws ec2 describe-instances` (an object with Reservations[])');
const out = [];
for (const r of j.Reservations) for (const i of r?.Instances || []) {
if (!(!!i && i.State?.Name === 'running' /*RULEAZA:aws-ec2*/)) continue;
const zona = i.Placement?.AvailabilityZone || null; const region = zona ? zona.replace(/[a-z]$/, '') : null;
// userData nu e in describe-instances si nu se cere; valorile etichetelor nu intra (o eticheta poate purta orice)
const d = {
runtime: 'aws-ec2', region, availabilityZone: zona, instanceId: i.InstanceId || null, imageId: i.ImageId || null, imageIdentity: 'declared',
instanceType: i.InstanceType || null, architecture: i.Architecture || null, platformDetails: i.PlatformDetails || null, virtualizationType: i.VirtualizationType || null,
bootMode: i.BootMode || null, launchTime: i.LaunchTime || null, iamInstanceProfileArn: i.IamInstanceProfile?.Arn || null, rootDeviceType: i.RootDeviceType || null,
imdsV2Required: i.MetadataOptions ? i.MetadataOptions.HttpTokens === 'required' : null, tagKeys: numeDin(i.Tags, 'Key' /*NUME:aws-ec2*/),
};
out.push(intrare(`ec2:${region}/${i.InstanceId}`, i.ImageId, d));
}
return out;
}
/** `aws lambda list-functions`: list-functions nu intoarce State (nota din documentatia lui); o functie e inregistrata ca desfasurata,
* cu `state: 'not-in-export'`; daca exportul poarta State (get-function-configuration), numai `Active` intra */
export function descrieAwsLambda(j) {
if (!(!!j && !Array.isArray(j) && Array.isArray(j.Functions) /*FORMA:aws-lambda*/)) throw new Error('aws-lambda: the input is not the output of `aws lambda list-functions` (an object with Functions[])');
const out = [];
for (const f of j.Functions) {
if (!(!!f && (f.State === undefined || f.State === 'Active') /*RULEAZA:aws-lambda*/)) continue;
const region = String(f.FunctionArn || '').split(':')[3] || null;
const d = {
runtime: 'aws-lambda', region, functionArn: f.FunctionArn || null, functionName: f.FunctionName || null, packageType: f.PackageType || 'Zip',
functionRuntime: f.Runtime || null, handler: f.Handler || null, codeSha256: f.CodeSha256 || null, imageIdentity: f.CodeSha256 ? 'codeSha256' : 'declared',
codeSize: f.CodeSize ?? null, lastModified: f.LastModified || null, functionVersion: f.Version || null, revisionId: f.RevisionId || null,
architectures: [...(f.Architectures || [])].sort(), layers: numeDin(f.Layers, 'Arn'), state: f.State ?? 'not-in-export',
envNames: Object.keys(f.Environment?.Variables || {}).sort() /*NUME:aws-lambda*/,
};
out.push(intrare(`lambda:${region}/${f.FunctionName}`, f.CodeSha256, d));
}
return out;
}
/** `az vm list -d` (--show-details adauga powerState; fara el exportul nu spune daca masina merge, si e refuzat) */
export function descrieAzureVm(j) {
if (!(Array.isArray(j) && j.every((v) => v && typeof v.id === 'string' && /\/providers\/Microsoft\.Compute\/virtualMachines\//i.test(v.id)) /*FORMA:azure-vm*/)) throw new Error('azure-vm: the input is not the output of `az vm list -d` (an array of Microsoft.Compute/virtualMachines resources)');
const vms = j.map(aplatizat);
if (!(vms.length === 0 || vms.some((v) => typeof v.powerState === 'string') /*FORMA:azure-vm-d*/)) throw new Error('azure-vm: the export has no powerState: run `az vm list -d` (--show-details), which adds the power state of each machine');
const out = [];
for (const v of vms) {
if (!(v.powerState === 'VM running' /*RULEAZA:azure-vm*/)) continue;
const ir = v.storageProfile?.imageReference || {}; const rg = v.resourceGroup || grupulDin(v.id);
// customData, userData, adminUsername, cheile ssh, adresele: nu se copiaza; etichetele intra numai cu numele
const d = {
runtime: 'azure-vm', id: v.id, name: v.name || null, resourceGroup: rg, location: v.location || null, zones: [...(v.zones || [])].sort(), vmId: v.vmId || null,
vmSize: v.hardwareProfile?.vmSize || null,
imageReference: Object.fromEntries(['publisher', 'offer', 'sku', 'version', 'exactVersion', 'id', 'sharedGalleryImageId', 'communityGalleryImageId'].filter((k) => ir[k] != null).map((k) => [k, ir[k]])),
imageIdentity: 'declared', osType: v.storageProfile?.osDisk?.osType || null, computerName: v.osProfile?.computerName || null,
provisioningState: v.provisioningState || null, powerState: v.powerState, timeCreated: v.timeCreated || null, tagKeys: Object.keys(v.tags || {}).sort() /*NUME:azure-vm*/,
};
out.push(intrare(`azvm:${rg}/${v.name}`, ir.exactVersion || ir.version || ir.id, d));
}
return out;
}
/** `az container list` / `az container show` (Azure Container Instances): starea fiecarui container e in instanceView.currentState */
export function descrieAzureContainer(j) {
if (!(Array.isArray(j) && j.every((g) => g && typeof g.id === 'string' && /\/providers\/Microsoft\.ContainerInstance\/containerGroups\//i.test(g.id)) /*FORMA:azure-container*/)) throw new Error('azure-container: the input is not the output of `az container list` or `az container show` (an array of Microsoft.ContainerInstance/containerGroups resources)');
const out = []; let cuStare = 0;
for (const g0 of j) {
const g = aplatizat(g0); const rg = g.resourceGroup || grupulDin(g.id);
for (const c0 of g.containers || []) {
const c = aplatizat(c0); const st = c.instanceView?.currentState || null; if (st) cuStare++;
if (!(!!st && st.state === 'Running' /*RULEAZA:azure-container*/)) continue;
// environmentVariables intra cu numele (value si secureValue nu), imageRegistryCredentials nu intra deloc
const d = {
runtime: 'azure-container', groupId: g.id, group: g.name || null, resourceGroup: rg, location: g.location || null, zones: [...(g.zones || [])].sort(), osType: g.osType || null,
provisioningState: g.provisioningState || null, container: c.name || null, image: c.image || null, imageDigest: digestDinImagine(c.image), imageIdentity: digestDinImagine(c.image) ? 'digest' : 'declared',
commandSha256: sha256(Buffer.from(JSON.stringify(c.command || []), 'utf8')), envNames: numeDin(c.environmentVariables /*NUME:azure-container*/),
mounts: (c.volumeMounts || []).map((m) => m && m.mountPath).filter(Boolean).sort(), startedAt: st.startTime || null, restartCount: c.instanceView?.restartCount ?? null,
};
out.push(intrare(`aci:${rg}/${g.name}/${c.name}`, digestDinImagine(c.image) || c.image, d));
}
}
if (j.length && cuStare === 0) throw new Error('azure-container: no container in the export carries instanceView.currentState (`az container list` may omit it): collect `az container show -g <group> -n <name> -o json` outputs into a JSON array');
return out;
}
/** `az containerapp list` (Azure Container Apps): runningStatus e al aplicatiei; numai `Running` intra (Progressing, Stopped, Suspended
* si `Ready`, pe care specificatia il enumera fara sa il defineasca, nu); initContainers nu intra (nu ruleaza continuu) */
export function descrieAzureContainerApp(j) {
if (!(Array.isArray(j) && j.every((a) => a && typeof a.id === 'string' && /\/providers\/Microsoft\.App\/containerApps\//i.test(a.id)) /*FORMA:azure-containerapp*/)) throw new Error('azure-containerapp: the input is not the output of `az containerapp list` (an array of Microsoft.App/containerApps resources)');
const out = [];
for (const a0 of j) {
const a = aplatizat(a0);
if (!(a.runningStatus === 'Running' /*RULEAZA:azure-containerapp*/)) continue;
const rg = a.resourceGroup || grupulDin(a.id);
for (const c of a.template?.containers || []) {
if (!c) continue;
// env intra cu numele; secretRef e numele unui secret, nu valoarea lui; configuration.secrets nu se citeste deloc
const d = {
runtime: 'azure-containerapp', appId: a.id, app: a.name || null, resourceGroup: rg, location: a.location || null, environmentId: a.environmentId || a.managedEnvironmentId || null,
provisioningState: a.provisioningState || null, runningStatus: a.runningStatus, latestReadyRevisionName: a.latestReadyRevisionName || null, latestRevisionName: a.latestRevisionName || null,
workloadProfileName: a.workloadProfileName || null, container: c.name || null, image: c.image || null, imageDigest: digestDinImagine(c.image), imageIdentity: digestDinImagine(c.image) ? 'digest' : 'declared',
commandSha256: sha256(Buffer.from(JSON.stringify([...(c.command || []), ...(c.args || [])]), 'utf8')), envNames: numeDin(c.env /*NUME:azure-containerapp*/), envSecretRefs: numeDin(c.env, 'secretRef'),
mounts: (c.volumeMounts || []).map((m) => m && m.mountPath).filter(Boolean).sort(),
};
out.push(intrare(`aca:${rg}/${a.name}/${c.name}`, digestDinImagine(c.image) || c.image, d));
}
}
return out;
}
/** `az webapp list --show-details` (App Service): state e al sitului; runtime-ul e linuxFxVersion/windowsFxVersion (DOCKER|imagine
* pentru un container); appSettings si connectionStrings intra numai cu numele (API-ul le ascunde oricum la citirile obisnuite) */
export function descrieAzureWebapp(j) {
if (!(Array.isArray(j) && j.every((s) => s && typeof s.id === 'string' && /\/providers\/Microsoft\.Web\/sites\//i.test(s.id)) /*FORMA:azure-webapp*/)) throw new Error('azure-webapp: the input is not the output of `az webapp list --show-details` (an array of Microsoft.Web/sites resources)');
const out = [];
for (const s0 of j) {
const s = aplatizat(s0);
if (!(s.state === 'Running' /*RULEAZA:azure-webapp*/)) continue;
const rg = s.resourceGroup || grupulDin(s.id); const sc = s.siteConfig && typeof s.siteConfig === 'object' ? s.siteConfig : null;
const fx = sc?.linuxFxVersion || sc?.windowsFxVersion || null; const m = /^DOCKER\|(.+)$/i.exec(fx || ''); const image = m ? m[1] : null;
const d = {
runtime: 'azure-webapp', id: s.id, name: s.name || null, resourceGroup: rg, location: s.location || null, kind: s.kind || null, state: s.state, defaultHostName: s.defaultHostName || null,
fxVersion: fx, image, imageDigest: digestDinImagine(image), imageIdentity: digestDinImagine(image) ? 'digest' : fx ? 'declared' : 'not-in-export',
lastModifiedTimeUtc: s.lastModifiedTimeUtc || null, httpsOnly: s.httpsOnly ?? null, minTlsVersion: sc?.minTlsVersion || null, siteConfigInExport: !!sc,
appSettingNames: numeDin(sc?.appSettings /*NUME:azure-webapp*/), connectionStringNames: numeDin(sc?.connectionStrings),
};
out.push(intrare(`azwebapp:${rg}/${s.name}`, digestDinImagine(image) || image || fx, d));
}
return out;
}
/** `gcloud compute instances list --format=json`: metadata.items intra numai cu cheile (startup-script, user-data, ssh-keys stau acolo) */
export function descrieGcpCompute(j) {
if (!(Array.isArray(j) && j.every((i) => i && i.kind === 'compute#instance') /*FORMA:gcp-compute*/)) throw new Error('gcp-compute: the input is not the output of `gcloud compute instances list --format=json` (an array of compute#instance resources)');
const out = [];
for (const i of j) {
if (!(i.status === 'RUNNING' /*RULEAZA:gcp-compute*/)) continue;
const boot = (i.disks || []).find((d) => d && d.boot) || null; const zona = ultimul(i.zone);
const d = {
runtime: 'gcp-compute', id: i.id != null ? String(i.id) : null, name: i.name || null, zone: zona, machineType: ultimul(i.machineType), status: i.status, cpuPlatform: i.cpuPlatform || null,
creationTimestamp: i.creationTimestamp || null, lastStartTimestamp: i.lastStartTimestamp || null,
bootDisk: boot ? { source: ultimul(boot.source), licenses: (boot.licenses || []).map(ultimul).filter(Boolean).sort(), architecture: boot.architecture || null, diskSizeGb: boot.diskSizeGb ?? null } : null,
imageIdentity: 'declared', serviceAccounts: numeDin(i.serviceAccounts, 'email'), labelKeys: Object.keys(i.labels || {}).sort(), metadataKeys: numeDin(i.metadata?.items, 'key' /*NUME:gcp-compute*/),
selfLink: i.selfLink || null,
};
out.push(intrare(`gce:${zona}/${i.name}`, i.id != null ? String(i.id) : null, d));
}
return out;
}
/** `gcloud run services list --format=json` (Knative Service, Cloud Run Admin API v1): ruleaza = conditia Ready e True */
export function descrieGcpRun(j) {
if (!(Array.isArray(j) && j.every((s) => s && s.apiVersion === 'serving.knative.dev/v1' && s.kind === 'Service') /*FORMA:gcp-run*/)) throw new Error('gcp-run: the input is not the output of `gcloud run services list --format=json` (an array of serving.knative.dev/v1 Service resources)');
const out = [];
for (const s of j) {
const ready = (s.status?.conditions || []).find((c) => c && c.type === 'Ready') || null;
if (!(!!ready && ready.status === 'True' /*RULEAZA:gcp-run*/)) continue;
const md = s.metadata || {}; const region = md.labels?.['cloud.googleapis.com/location'] || null; const spec = s.spec?.template?.spec || {};
(spec.containers || []).forEach((c, idx) => {
if (!c) return;
const nume = c.name || `container-${idx}`;
// env intra cu numele; value si valueFrom (referinta la Secret Manager) nu
const d = {
runtime: 'gcp-run', service: md.name || null, namespace: md.namespace || null, region, uid: md.uid || null, generation: md.generation ?? null, observedGeneration: s.status?.observedGeneration ?? null,
latestReadyRevisionName: s.status?.latestReadyRevisionName || null, latestCreatedRevisionName: s.status?.latestCreatedRevisionName || null, url: s.status?.url || null,
serviceAccountName: spec.serviceAccountName || null, container: nume, image: c.image || null, imageDigest: digestDinImagine(c.image), imageIdentity: digestDinImagine(c.image) ? 'digest' : 'declared',
commandSha256: sha256(Buffer.from(JSON.stringify([...(c.command || []), ...(c.args || [])]), 'utf8')), envNames: numeDin(c.env /*NUME:gcp-run*/),
mounts: (c.volumeMounts || []).map((m) => m && m.mountPath).filter(Boolean).sort(), readySince: ready.lastTransitionTime || null,
};
out.push(intrare(`run:${region}/${md.name}/${nume}`, digestDinImagine(c.image) || s.status?.latestReadyRevisionName || c.image, d));
});
}
return out;
}
/** sursa -> adaptor; `aws-ecs` primeste si definitiile de task (optional) */
export const ADAPTOARE = {
docker: descrieDocker, kubernetes: descrieKubernetes,
'aws-ecs': descrieAwsEcs, 'aws-ec2': descrieAwsEc2, 'aws-lambda': descrieAwsLambda,
'azure-vm': descrieAzureVm, 'azure-container': descrieAzureContainer, 'azure-containerapp': descrieAzureContainerApp, 'azure-webapp': descrieAzureWebapp,
'gcp-compute': descrieGcpCompute, 'gcp-run': descrieGcpRun,
};
// ---- 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 (!ADAPTOARE[src] || !f) { console.error(`scan --source ${Object.keys(ADAPTOARE).join('|')} --input <export.json> [--task-definitions <describe-task-definition.json>]`); return 2; }
let desc;
try {
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
const td = get('--task-definitions');
desc = src === 'aws-ecs' ? descrieAwsEcs(j, td ? JSON.parse(fs.readFileSync(td, 'utf8')) : null) : ADAPTOARE[src](j);
} catch (e) { console.error('scan: ' + e.message); return 2; }
if (desc.length === 0) { console.error('scan: no running workload (container, task, instance, function or service) 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 ${['docker', 'kubernetes'].includes(src) ? 'containers' : 'workloads'}, ${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; }
}
// 2026-09-30 (punctul 34): jurnalul si ca arbore Merkle (RFC 9162, jurnal-arbore.mjs): capul de arbore, dovezile de includere si de
// consistenta, si verificarea lor FARA jurnal
case 'tree-head':
case 'prove-inclusion':
case 'prove-consistency':
case 'verify-inclusion':
case 'verify-consistency': {
const A = await import(pathToFileURL(path.join(AICI, 'jurnal-arbore.mjs')).href);
const citeste = (f, ce) => { if (!f) throw new Error(`${cmd} needs ${ce}`); return JSON.parse(fs.readFileSync(f, 'utf8')); };
const scrie = (o) => { const out = get('--out'); const s = JSON.stringify(o, null, 1); if (out) { fs.writeFileSync(out, s); console.log('written', out); } else console.log(s); };
const semnatar = get('--signer') ? fs.readFileSync(get('--signer'), 'utf8') : null;
try {
if (cmd === 'tree-head') {
const kf = get('--sign-key');
const cap = A.capDeArbore(citesteJurnal(log), { host, createdAt: at, signKeyPem: kf ? fs.readFileSync(kf, 'utf8') : null });
scrie(cap); return 0;
}
if (cmd === 'prove-inclusion') { scrie(A.dovadaIncludere(citesteJurnal(log), Number(get('--seq')), get('--size') ? Number(get('--size')) : undefined)); return 0; }
if (cmd === 'prove-consistency') { scrie(A.dovadaConsistenta(citesteJurnal(log), Number(get('--from')), get('--to') ? Number(get('--to')) : undefined)); return 0; }
if (cmd === 'verify-inclusion') {
const r = A.verificaIncludere(citeste(get('--proof'), '--proof'), { cap: get('--head') ? citeste(get('--head'), '--head') : null, semnatar });
console.log(r.ok ? `entry INCLUDED${get('--head') ? ' in the tree of the given head' + (r.semnatDe ? `, signed by key ${r.semnatDe.slice(0, 18)}${semnatar ? ' (the expected signer)' : ' (not checked against an expected signer: --signer)'}` : ' (unsigned head)') : ' in the tree the proof names (pass --head to bind it to an attested head)'}` : `NOT included: ${r.motiv}`);
return r.ok ? 0 : 1;
}
const r = A.verificaConsistenta(citeste(get('--proof'), '--proof'), { vechi: citeste(get('--old'), '--old'), nou: citeste(get('--new'), '--new'), semnatar });
// B-25 (2026-09-30): ca la verify-inclusion, iesirea spune cine a semnat capetele si daca s-a cerut semnatarul
const cine = (k) => (k ? `signed by key ${k.slice(0, 18)}` : 'unsigned');
const semn = !r.ok ? '' : semnatar ? `; both heads signed by the expected signer (${r.semnatNou.slice(0, 18)})`
: `; old head ${cine(r.semnatVechi)}, new head ${cine(r.semnatNou)}${r.semnatVechi && r.semnatNou && r.semnatVechi !== r.semnatNou ? ' (DIFFERENT keys)' : ''}; no expected signer was checked (--signer): this proves only that the second head you hold extends the first one you hold`;
console.log(r.ok ? `the new head EXTENDS the old one (the history up to the old head was not rewritten)${semn}` : `NOT consistent: ${r.motiv}`);
return r.ok ? 0 : 1;
} catch (e) { console.log(`${cmd}: ${String(e.message).replace(/[0-9a-fA-F]{32,}/g, '<hex>').slice(0, 200)}`); return 2; }
}
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 | tree-head | prove-inclusion | prove-consistency | verify-inclusion | verify-consistency');
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; });
}