96 lines
8.4 KiB
JavaScript
96 lines
8.4 KiB
JavaScript
// jurnal-arbore.mjs: jurnalul de audit al sidecar-ului, si ca ARBORE Merkle (RFC 9162, arbore-merkle.mjs), langa lantul de hash-uri
|
|
// (roadmap master punctul 34, pista B, 2026-09-30). Frunza i = hash-ul intrarii i (cel din lant, care acopera seq, prev si plicul),
|
|
// deci arborele nu schimba nimic din jurnal: e o a doua vedere peste aceleasi intrari.
|
|
// - capul de arbore (`aere-audit-tree-head`): marimea si radacina, plic AIP-23, semnabil ML-DSA-65 cu cheia operatorului (aceeasi
|
|
// forma ca capul de lant, deci verificatorul AIP-23 ii judeca semnatura) si notarizabil;
|
|
// - dovada de INCLUDERE: o intrare, cu log2(n) hash-uri, e in arborele unui cap, fara nicio alta intrare a jurnalului;
|
|
// - dovada de CONSISTENTA: arborele unui cap vechi e un prefix al celui nou; doua capete ale aceluiasi jurnal fara o asemenea dovada
|
|
// inseamna o istorie rescrisa.
|
|
// Ce NU dovedeste, ca si lantul: ca intrarile spun adevarul (le scrie gazda) si ca nu exista un alt jurnal, cu alte capete, aratat
|
|
// altcuiva; o vede cine primeste capete de la amandoi (sau capete notarizate).
|
|
import crypto from 'node:crypto';
|
|
import { leafHash, merkleTree, verifyInclusion, verifyConsistency } from './arbore-merkle.mjs';
|
|
import { hashIntrare, verificaJurnal, verificaSemnaturaCap, semneazaCap } from './sidecar.mjs';
|
|
|
|
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
|
const H = /^0x[0-9a-f]{64}$/;
|
|
export const TREE_HEAD = 'aere-audit-tree-head';
|
|
/** Frunza unei intrari: leafHash peste cei 32 de octeti ai hash-ului ei din lant. */
|
|
export const frunza = (entryHash) => { if (!H.test(String(entryHash))) throw new Error('tree: an entry hash is not 0x + 64 hex'); return leafHash(Buffer.from(entryHash.slice(2), 'hex')); };
|
|
|
|
/** Arborele jurnalului; refuza un lant rupt (arborele unui jurnal stricat nu dovedeste nimic). */
|
|
export function arboreleJurnalului(intrari) {
|
|
const v = verificaJurnal(intrari);
|
|
if (!v.ok) throw new Error(`tree: the log is broken at seq ${v.rupt}: ${v.motiv}`);
|
|
return merkleTree(intrari.map((e) => frunza(e.hash)));
|
|
}
|
|
|
|
/** Capul de arbore ca plic AIP-23, optional semnat cu cheia operatorului (PEM ML-DSA-65). */
|
|
export function capDeArbore(intrari, { host = 'sidecar', createdAt = new Date().toISOString(), signKeyPem = null } = {}) {
|
|
const t = arboreleJurnalului(intrari);
|
|
if (!t.size) throw new Error('tree: an empty log has no head to attest');
|
|
const statement = { v: 1, kind: TREE_HEAD, host, treeSize: t.size, rootHash: t.root(), createdAt };
|
|
const plic = { v: 1, kind: TREE_HEAD + '-attestation', statement, statementHash: sha256(Buffer.from(JSON.stringify(statement), 'utf8')) };
|
|
return signKeyPem ? semneazaCap(plic, signKeyPem) : plic;
|
|
}
|
|
// Every refusal of the checks below NAMES its rule (`regula`); the refusals of a head's signature carry the rules of the sidecar
|
|
// (cap-*). Each check of a rule carries its marker (see sidecar.mjs).
|
|
export const REGULI = ['dovada-forma', 'arbore-forma', 'arbore-hash', 'includere-index', 'includere-intrare', 'includere-plic', 'includere-frunza', 'includere-drum', 'includere-cap', 'consistenta-inceput', 'consistenta-sfarsit', 'consistenta'];
|
|
const refuz = (regula, motiv) => { if (!REGULI.includes(regula)) throw new Error('tree: undeclared rule ' + regula); return { ok: false, regula, motiv }; };
|
|
// un cap de arbore intreg (statementHash se reface), semnat de cine se cere (daca se cere)
|
|
function capBun(cap, semnatar) {
|
|
const st = cap && cap.statement;
|
|
if (!(!!cap && cap.kind === TREE_HEAD + '-attestation' && !!st && st.kind === TREE_HEAD /*PAZA:arbore-forma*/)) return refuz('arbore-forma', `not an ${TREE_HEAD} attestation`);
|
|
if (!(!!cap && H.test(String(cap.statementHash)) && sha256(Buffer.from(String(JSON.stringify(st)), 'utf8')) === cap.statementHash /*PAZA:arbore-hash*/)) return refuz('arbore-hash', 'the tree head statementHash does not match its statement (modified head)');
|
|
if (!(!!st && Number.isInteger(st.treeSize) && st.treeSize >= 1 && H.test(String(st.rootHash)) /*PAZA:arbore-forma*/)) return refuz('arbore-forma', 'the tree head has no valid size and root');
|
|
const s = verificaSemnaturaCap(cap, semnatar);
|
|
if (!s.ok) return { ok: false, regula: s.regula, motiv: s.motiv };
|
|
return { ok: true, semnatDe: s.semnat ? s.keyId : null };
|
|
}
|
|
|
|
/** Dovada ca intrarea `seq` e in arborele de marime `treeSize` (implicit tot jurnalul); poarta si intrarea insasi. */
|
|
export function dovadaIncludere(intrari, seq, treeSize = intrari.length) {
|
|
const t = arboreleJurnalului(intrari);
|
|
return { v: 1, kind: 'aere-audit-inclusion-proof', entry: intrari[seq], ...t.inclusionProof(seq, treeSize) };
|
|
}
|
|
/**
|
|
* Verifica o dovada de includere FARA jurnal: intrarea isi reface hash-ul, frunza e a ei, drumul duce la radacina; cu `cap`, radacina
|
|
* si marimea sunt ale capului (intreg, semnat de `semnatar` daca e dat). Intoarce { ok, motiv, semnatDe }.
|
|
*/
|
|
export function verificaIncludere(d, { cap = null, semnatar = null } = {}) {
|
|
try {
|
|
if (!(!!d && d.kind === 'aere-audit-inclusion-proof' && !!d.entry && typeof d.entry === 'object' /*PAZA:dovada-forma*/)) return refuz('dovada-forma', 'not an aere-audit-inclusion-proof');
|
|
const e = d.entry;
|
|
if (!(e.seq === d.index /*PAZA:includere-index*/)) return refuz('includere-index', `the entry is seq ${e.seq}, the proof is for index ${d.index}`);
|
|
if (!(hashIntrare(e.seq, e.prev, e.proof) === e.hash /*PAZA:includere-intrare*/)) return refuz('includere-intrare', 'the entry hash does not match its content (modified entry)');
|
|
if (!(!!e.proof && !!e.proof.statement && sha256(Buffer.from(JSON.stringify(e.proof.statement), 'utf8')) === String(e.proof.statementHash).toLowerCase() /*PAZA:includere-plic*/)) return refuz('includere-plic', 'the entry\'s envelope statementHash does not match its statement');
|
|
if (!(d.leafHash === frunza(e.hash) /*PAZA:includere-frunza*/)) return refuz('includere-frunza', 'the leaf is not this entry');
|
|
if (!(verifyInclusion(d) /*PAZA:includere-drum*/)) return refuz('includere-drum', `the path does not lead from the entry to root ${String(d.rootHash).slice(0, 18)}.. of a tree of ${d.treeSize}`);
|
|
if (cap) {
|
|
const c = capBun(cap, semnatar); if (!c.ok) return c;
|
|
if (!(cap.statement.treeSize === d.treeSize && cap.statement.rootHash === d.rootHash /*PAZA:includere-cap*/)) return refuz('includere-cap', `the proof is for a tree of ${d.treeSize} with another root than the head's (${cap.statement.treeSize})`);
|
|
return { ok: true, semnatDe: c.semnatDe };
|
|
}
|
|
return { ok: true, semnatDe: null };
|
|
} catch (x) { return refuz('dovada-forma', 'the proof cannot be read: ' + x.message); }
|
|
}
|
|
|
|
/** Dovada ca arborele de marime `firstSize` e un prefix al celui de marime `secondSize` (implicit tot jurnalul). */
|
|
export function dovadaConsistenta(intrari, firstSize, secondSize = intrari.length) {
|
|
return { v: 1, kind: 'aere-audit-consistency-proof', ...arboreleJurnalului(intrari).consistencyProof(firstSize, secondSize) };
|
|
}
|
|
/** Verifica, FARA jurnal, ca un cap nou continua unul vechi: ambele capete intregi (si semnate de `semnatar`), dovada intre ele. */
|
|
export function verificaConsistenta(d, { vechi, nou, semnatar = null }) {
|
|
try {
|
|
if (!(!!d && d.kind === 'aere-audit-consistency-proof' /*PAZA:dovada-forma*/)) return refuz('dovada-forma', 'not an aere-audit-consistency-proof');
|
|
const a = capBun(vechi, semnatar); if (!a.ok) return { ok: false, regula: a.regula, motiv: 'old head: ' + a.motiv };
|
|
const b = capBun(nou, semnatar); if (!b.ok) return { ok: false, regula: b.regula, motiv: 'new head: ' + b.motiv };
|
|
if (!(vechi.statement.treeSize === d.firstSize && vechi.statement.rootHash === d.firstRoot /*PAZA:consistenta-inceput*/)) return refuz('consistenta-inceput', 'the proof does not start at the old head');
|
|
if (!(nou.statement.treeSize === d.secondSize && nou.statement.rootHash === d.secondRoot /*PAZA:consistenta-sfarsit*/)) return refuz('consistenta-sfarsit', 'the proof does not end at the new head');
|
|
if (!(verifyConsistency(d) /*PAZA:consistenta*/)) return refuz('consistenta', `the new head (${d.secondSize}) does not extend the old one (${d.firstSize}): the history was rewritten`);
|
|
// B-25 (2026-09-30): cine semneaza fiecare cap se intoarce si se spune; fara `semnatar`, doua capete nesemnate (sau semnate de
|
|
// oricine) care se continua trec, si cititorul trebuie sa vada asta, nu doar "EXTENDS"
|
|
return { ok: true, semnatVechi: a.semnatDe, semnatNou: b.semnatDe };
|
|
} catch (x) { return refuz('dovada-forma', 'the proof cannot be read: ' + x.message); }
|
|
}
|