- verify-consistency without --signer now says whether each head is signed and by which key, and that no expected signer was checked (verify-inclusion already did; the README said the output does) - openMessage opens nothing without a replay store (seen: an object with has/add that keeps the ids for at least maxAgeS); before, the same signed and sealed message could be opened any number of times when no store was given Tests: verify-layer tree 20/20, negative control 14/14; sidecar 44/44, 9/9; travel rule 19/19, negative control 19/19.
431 lines
32 KiB
JavaScript
431 lines
32 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
|
|
// 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.
|
|
*/
|
|
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; }
|
|
}
|
|
// 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; });
|
|
}
|