From 082b862d78c1bae8b949ca04c42acfc3c1f4e0b0 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Wed, 30 Sep 2026 10:13:10 +0300 Subject: [PATCH] verify-layer: the audit log as a Merkle tree (RFC 9162) - signed tree heads, and inclusion and consistency proofs anyone checks without the log --- README.md | 6 +- verify-layer/README.md | 22 +++ verify-layer/arbore-merkle.mjs | 104 +++++++++++++++ verify-layer/control-negativ-arbore.mjs | 64 +++++++++ verify-layer/jurnal-arbore.mjs | 89 +++++++++++++ verify-layer/proba-arbore.mjs | 170 ++++++++++++++++++++++++ verify-layer/sidecar.mjs | 36 ++++- 7 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 verify-layer/arbore-merkle.mjs create mode 100644 verify-layer/control-negativ-arbore.mjs create mode 100644 verify-layer/jurnal-arbore.mjs create mode 100644 verify-layer/proba-arbore.mjs diff --git a/README.md b/README.md index e9c4ec3..592e82b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP | [`pq-kms/`](pq-kms/) | a transit-style key management service where every key is hybrid: X25519 + ML-KEM-768 for encryption, Ed25519 + ML-DSA-65 for signatures (both halves required); versions, rotation, rewrap, data keys, a chained audit log; the root key from the environment or sealed by an HSM through PKCS#11 | | [`pq-pki/`](pq-pki/) | a private certificate authority for ML-DSA (X.509 v3, RFC 9881): root and issuing CAs, leaf certificates, revocation lists, and a strict chain verifier compared against OpenSSL | | [`crypto-inventory/`](crypto-inventory/) | a cryptographic inventory of source code (JavaScript/TypeScript, Python, Java, Go, PEM blocks, dependency manifests): every use classified by its exposure to a quantum computer, with a migration target, written as a CycloneDX 1.6 CBOM; nothing from the scanned tree is executed, and its cost stays linear on input built to be slow | -| [`verify-layer/`](verify-layer/) | an audit-log sidecar for any deployment: entries are AIP-23 envelopes in a hash chain, the runtime adapter records every running Docker or Kubernetes container without any secret value, and the head of the chain can be signed by the operator (ML-DSA-65) and notarized on Aere Network for post-quantum finality; it says plainly what that proves (the history before a published head) and what it does not (that the host told the truth) | +| [`verify-layer/`](verify-layer/) | an audit-log sidecar for any deployment: entries are AIP-23 envelopes in a hash chain, the runtime adapter records every running Docker or Kubernetes container without any secret value, and the head of the chain can be signed by the operator (ML-DSA-65) and notarized on Aere Network for post-quantum finality; it says plainly what that proves (the history before a published head) and what it does not (that the host told the truth); the same log as a Merkle tree (RFC 9162): signed tree heads, and inclusion and consistency proofs of a few hashes that anyone checks without the log | | [`proof-kinds/`](proof-kinds/) | the AIP-23 envelope builder the verification layer uses: fourteen proof kinds, one envelope format, digests instead of raw content | | [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass | | [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again | @@ -31,7 +31,7 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | pq-kms | 62/62 (`node test/proba.mjs`); HSM root on SoftHSM2 + OpenSC 20/20 (`test/proba-hsm.mjs`, Linux); sealed-file trust rules 7/7 (`test/proba-hsm-incredere.mjs`) | 16/16 (`node test/control-negativ.mjs`); sealed-file rules 2/2 in this repository (`test/control-negativ-hsm-incredere.mjs`) | | pq-pki | 27/27 (`node test/proba.mjs`), each verdict compared with OpenSSL 3.5 | 22/22 (`node test/control-negativ.mjs`) | | crypto-inventory | 37/37 (`node test/proba.mjs`); cost on hostile input 8/8 linear (`node test/proba-timp.mjs`) | 18/18 (`node test/control-negativ.mjs`); cost 3/3 in this repository (`node test/control-negativ-timp.mjs`; its fourth case compares with version 0.1.0 from the development history and is skipped here) | -| verify-layer | 44/44 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF= node proba-sidecar.mjs`); without it 39 run, 5 are reported as skipped and the exit code is 2 | 9/9 in this repository (`node control-negativ-sidecar.mjs`; its tenth case compares with the version from the development history and is skipped here) | +| verify-layer | 44/44 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF= node proba-sidecar.mjs`); without it 39 run, 5 are reported as skipped and the exit code is 2; the Merkle tree 19/19 (`node proba-arbore.mjs`: the reference roots of Certificate Transparency, every proof up to 64 leaves, each attack), measured 2026-09-30 | 9/9 in this repository (`node control-negativ-sidecar.mjs`; its tenth case compares with the version from the development history and is skipped here); the tree 13/13 (`node control-negativ-arbore.mjs`) | | proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test | | readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) | | control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; the console viewer in a real Chromium, phone and desktop, 26/26 (`node proba-consola-web.mjs`, measured 2026-09-30; needs `playwright-core` and a Chromium, otherwise it exits 2) | remediation 7/7, compliance report 3/3 in this repository; viewer 8/8 (`node control-negativ-consola-web.mjs`) | @@ -45,4 +45,4 @@ interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ... ## Licence -MIT, see [LICENSE](LICENSE). Files: 150 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 19, agents 39, identity 11, readiness 4). +MIT, see [LICENSE](LICENSE). Files: 154 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 11, readiness 4). diff --git a/verify-layer/README.md b/verify-layer/README.md index 8a1b39b..6642c01 100644 --- a/verify-layer/README.md +++ b/verify-layer/README.md @@ -85,10 +85,32 @@ head, notarized in block 3,699,939 on 2026-09-27. Anyone can check both halves: The first says the log is the one that was notarized; the second, measured on 2026-09-29, reports `finality: PASSED post-quantum` under a certified anchor of the testnet, and `VALID`. +## The log as a Merkle tree (RFC 9162) + +The same entries, seen as a Merkle tree in the manner of Certificate Transparency 2.0 (RFC 9162, section 2.1): leaf i is the hash +the chain already gives entry i, with the RFC's separation between leaves (`0x00`) and nodes (`0x01`). With the chain alone, checking +that one entry is in the log, or that a new head extends an old one, means receiving and replaying the whole log; with the tree, a +proof of about log2(n) hashes is enough and shows no other entry. + + node sidecar.mjs tree-head --log audit.log --sign-key head.key.pem --out tree-head.json # size and root, an AIP-23 envelope + node sidecar.mjs prove-inclusion --log audit.log --seq 7 --out inclusion.json + node sidecar.mjs verify-inclusion --proof inclusion.json --head tree-head.json --signer head.pub.pem # no log needed + node sidecar.mjs prove-consistency --log audit.log --from 12 --out consistency.json + node sidecar.mjs verify-consistency --proof consistency.json --old head-12.json --new head-20.json --signer head.pub.pem + +The tree head is signed with the operator's key in the same form as the chain head (so the AIP-23 verifier checks its signature), +and can be notarized. An inclusion proof carries the entry itself: the verifier recomputes its hash from its content and checks its +envelope, so an internal node cannot be passed off as an entry. A proof names a tree by its size and root; bind it to a head you +received (`--head`), not to the numbers in the proof. Two heads of the same log with no consistency proof between them mean the +history was rewritten. What it does not prove, as for the chain: that the entries are true (the host writes them), or that no +other log with other heads was shown to someone else; whoever receives heads from both, or notarized heads, sees it. + ## Tests node proba-sidecar.mjs # 44 checks node control-negativ-sidecar.mjs # puts each guard back to its absent form and requires the named check to fail + node proba-arbore.mjs # 19: the tree (the reference roots of Certificate Transparency for 1..8 leaves, every proof up to 64 leaves, each attack) + node control-negativ-arbore.mjs # 13 guards removed in a copy, each turns its named test red `proba-sidecar.mjs` records runtime, deployment and envelope entries; checks that a modified entry breaks the chain at its seq, a changed hash is caught, and nothing is appended to a broken chain; that a chain **rebuilt from genesis** passes diff --git a/verify-layer/arbore-merkle.mjs b/verify-layer/arbore-merkle.mjs new file mode 100644 index 0000000..ea03717 --- /dev/null +++ b/verify-layer/arbore-merkle.mjs @@ -0,0 +1,104 @@ +// arbore-merkle.mjs: arborele Merkle al jurnalului de audit, dupa RFC 9162 (Certificate Transparency 2.0), sectiunea 2.1: hash-ul +// arborelui (MTH), dovada de INCLUDERE a unei intrari si dovada de CONSISTENTA intre doua capete (roadmap master punctul 34, pista B, +// 2026-09-30). Ce adauga fata de lantul de hash-uri al sidecar-ului: cu lantul, cine vrea sa stie ca o intrare e in jurnal, sau ca un +// cap nou continua unul vechi, trebuie sa primeasca si sa refaca TOT jurnalul; cu arborele, o dovada de log2(n) hash-uri ajunge, iar +// verificatorul nu vede nicio alta intrare. Doua capete semnate (sau notarizate) ale aceluiasi jurnal care nu au o dovada de +// consistenta intre ele sunt dovada ca istoria a fost rescrisa (o ramura). +// +// Frunza: SHA-256(0x00 || date); nod: SHA-256(0x01 || stanga || dreapta) (separarea frunza/nod de la RFC 9162, care impiedica o +// frunza sa fie luata drept nod). Arborele gol are hash-ul SHA-256 al sirului gol. Hash-urile se scriu 0x + 64 hex. +// Numai node:crypto, fara dependinte. +import crypto from 'node:crypto'; + +const sha = (...b) => crypto.createHash('sha256').update(Buffer.concat(b)).digest(); +const Z = Buffer.from([0x00]), U = Buffer.from([0x01]); +const hx = (b) => '0x' + b.toString('hex'); +const H = /^0x[0-9a-f]{64}$/; +const dinHex = (s, ce) => { if (typeof s !== 'string' || !H.test(s)) throw new Error(`merkle: ${ce} is not a 32-byte hash (0x + 64 hex)`); return Buffer.from(s.slice(2), 'hex'); }; + +export const leafHash = (date) => hx(sha(Z, Buffer.from(date))); +const nod = (a, b) => sha(U, a, b); +// cea mai mare putere a lui 2 strict mai mica decat n (n >= 2) +const k2 = (n) => { let k = 1; while (k * 2 < n) k *= 2; return k; }; + +// MTH peste un interval de frunze [a, b), cu memorie pe interval (fiecare subarbore se calculeaza o data) +function mth(frunze, a, b, mem) { + const cheie = a + ':' + b; if (mem.has(cheie)) return mem.get(cheie); + let r; + if (b - a === 0) r = sha(Buffer.alloc(0)); + else if (b - a === 1) r = frunze[a]; + else { const k = k2(b - a); r = nod(mth(frunze, a, a + k, mem), mth(frunze, a + k, b, mem)); } + mem.set(cheie, r); return r; +} +/** Arborele unor frunze date ca hash-uri de frunza (0x + 64 hex, fiecare = leafHash(date)). */ +export function merkleTree(leafHashes) { + const frunze = leafHashes.map((h, i) => dinHex(h, `leaf ${i}`)); const mem = new Map(); + const root = (n = frunze.length) => { if (!Number.isInteger(n) || n < 0 || n > frunze.length) throw new Error(`merkle: tree size ${n} outside 0..${frunze.length}`); return hx(mth(frunze, 0, n, mem)); }; + // PATH(m, D[a:b]) din RFC 9162, 2.1.3 (generarea) + const drum = (m, a, b) => { if (b - a <= 1) return []; const k = k2(b - a); return m < k ? [...drum(m, a, a + k), mth(frunze, a + k, b, mem)] : [...drum(m - k, a + k, b), mth(frunze, a, a + k, mem)]; }; + // SUBPROOF(m, D[a:b], b) din RFC 9162, 2.1.4 (generarea) + const sub = (m, a, b, compl) => { + const n = b - a; + if (m === n) return compl ? [] : [mth(frunze, a, b, mem)]; + const k = k2(n); + return m <= k ? [...sub(m, a, a + k, compl), mth(frunze, a + k, b, mem)] : [...sub(m - k, a + k, b, false), mth(frunze, a, a + k, mem)]; + }; + return { + size: frunze.length, + root, + /** dovada ca frunza `index` e in arborele de marime `treeSize` */ + inclusionProof(index, treeSize = frunze.length) { + if (!Number.isInteger(treeSize) || treeSize < 1 || treeSize > frunze.length) throw new Error(`merkle: tree size ${treeSize} outside 1..${frunze.length}`); + if (!Number.isInteger(index) || index < 0 || index >= treeSize) throw new Error(`merkle: index ${index} outside the tree of ${treeSize}`); + return { index, treeSize, leafHash: hx(frunze[index]), path: drum(index, 0, treeSize).map(hx), rootHash: root(treeSize) }; + }, + /** dovada ca arborele de marime `firstSize` e un prefix al celui de marime `secondSize` */ + consistencyProof(firstSize, secondSize = frunze.length) { + if (!Number.isInteger(secondSize) || secondSize < 1 || secondSize > frunze.length) throw new Error(`merkle: tree size ${secondSize} outside 1..${frunze.length}`); + if (!Number.isInteger(firstSize) || firstSize < 1 || firstSize > secondSize) throw new Error(`merkle: first size ${firstSize} outside 1..${secondSize}`); + return { firstSize, secondSize, firstRoot: root(firstSize), secondRoot: root(secondSize), path: sub(firstSize, 0, secondSize, true).map(hx) }; + }, + }; +} + +const lsb = (x) => (x & 1) === 1; +/** Verificarea unei dovezi de includere (RFC 9162, 2.1.3, verificarea), fara jurnal: numai frunza, indexul, marimea, drumul si radacina. */ +export function verifyInclusion({ leafHash: frunza, index, treeSize, path, rootHash }) { + try { + if (!Number.isInteger(index) || !Number.isInteger(treeSize) || index < 0 || index >= treeSize || !Array.isArray(path)) return false; + let fn = index, sn = treeSize - 1, r = dinHex(frunza, 'leaf hash'); + for (const p of path) { + const pb = dinHex(p, 'path element'); + if (sn === 0) return false; + if (lsb(fn) || fn === sn) { + r = nod(pb, r); + if (!lsb(fn)) while (!lsb(fn) && fn !== 0) { fn >>= 1; sn >>= 1; } + } else r = nod(r, pb); + fn >>= 1; sn >>= 1; + } + return sn === 0 && hx(r) === String(rootHash); + } catch { return false; } +} +/** Verificarea unei dovezi de consistenta (RFC 9162, 2.1.4, verificarea): arborele vechi e un prefix al celui nou. */ +export function verifyConsistency({ firstSize, secondSize, firstRoot, secondRoot, path }) { + try { + if (!Number.isInteger(firstSize) || !Number.isInteger(secondSize) || firstSize < 1 || firstSize > secondSize || !Array.isArray(path)) return false; + dinHex(firstRoot, 'first root'); dinHex(secondRoot, 'second root'); + if (firstSize === secondSize) return path.length === 0 && firstRoot === secondRoot; + if (!path.length) return false; + const pc = path.map((p) => dinHex(p, 'path element')); + if ((firstSize & (firstSize - 1)) === 0) pc.unshift(dinHex(firstRoot, 'first root')); // marimea veche e o putere a lui 2 + let fn = firstSize - 1, sn = secondSize - 1; + while (lsb(fn)) { fn >>= 1; sn >>= 1; } + let fr = pc[0], sr = pc[0]; + for (const c of pc.slice(1)) { + if (sn === 0) return false; + if (lsb(fn) || fn === sn) { + fr = nod(c, fr); sr = nod(c, sr); + if (!lsb(fn)) while (!lsb(fn) && fn !== 0) { fn >>= 1; sn >>= 1; } + } else sr = nod(sr, c); + fn >>= 1; sn >>= 1; + } + return sn === 0 && hx(fr) === firstRoot && hx(sr) === secondRoot; + } catch { return false; } +} diff --git a/verify-layer/control-negativ-arbore.mjs b/verify-layer/control-negativ-arbore.mjs new file mode 100644 index 0000000..269231e --- /dev/null +++ b/verify-layer/control-negativ-arbore.mjs @@ -0,0 +1,64 @@ +// Controlul negativ al arborelui Merkle al jurnalului (arbore-merkle.mjs, jurnal-arbore.mjs, proba-arbore.mjs): fiecare paznic +// scos intr-o COPIE trebuie sa inroseasca proba NUMITA, cu proba chiar rulata; pe copia neatinsa, verde. Trei stari: un tipar care +// nu apare exact o data sau o proba care nu ajunge la rezumat e STRICAT si se numara esec. +// node control-negativ-arbore.mjs iesire 0 = martorul verde si toate plantarile rosii pe proba lor +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const M = 'arbore-merkle.mjs', J = 'jurnal-arbore.mjs'; +const PLANTARI = [ + // [nume, fisier, [[tipar, inlocuire], ...], proba (inceputul numelui ei)] + ['fara separarea frunza / nod', M, [['const Z = Buffer.from([0x00]), U = Buffer.from([0x01]);', 'const Z = Buffer.alloc(0), U = Buffer.alloc(0);']], 'CONTROL: o frunza nu poate fi data drept nod'], + ['includerea nu mai cere ca drumul sa se termine la varf (sn == 0)', M, [[" return sn === 0 && hx(r) === String(rootHash);", " return hx(r) === String(rootHash);"]], 'CONTROL: un nod intern dat drept frunza'], + ['includerea nu mai coboara pe ramura dreapta (forma gresita a algoritmului)', M, [[" r = nod(pb, r);\n if (!lsb(fn)) while (!lsb(fn) && fn !== 0) { fn >>= 1; sn >>= 1; }", ' r = nod(pb, r);']], 'fiecare dovada de includere'], + ['consistenta nu mai cere radacina veche', M, [["return sn === 0 && hx(fr) === firstRoot && hx(sr) === secondRoot;", 'return sn === 0 && hx(sr) === secondRoot;']], 'ATAC: istoria rescrisa'], + ['consistenta fara radacina veche pusa in fata la o putere a lui 2', M, [["if ((firstSize & (firstSize - 1)) === 0) pc.unshift(dinHex(firstRoot, 'first root'));", '']], 'fiecare dovada de consistenta'], + ['marimi egale acceptate cu orice drum', M, [['if (firstSize === secondSize) return path.length === 0 && firstRoot === secondRoot;', 'if (firstSize === secondSize) return true;']], 'CONTROL: marimi egale'], + ['intrarea din dovada nu isi mai reface hash-ul', J, [["if (hashIntrare(e.seq, e.prev, e.proof) !== e.hash) return", 'if (false) return']], 'ATAC: intrarea din dovada schimbata'], + ['plicul intrarii nu mai trebuie sa fie intreg', J, [["if (!e.proof || !e.proof.statement || sha256(Buffer.from(JSON.stringify(e.proof.statement), 'utf8')) !== String(e.proof.statementHash).toLowerCase()) return", 'if (false) return']], 'ATAC: un plic stricat scris de gazda'], + ['marimea si radacina capului nu se mai compara cu dovada', J, [['if (cap.statement.treeSize !== d.treeSize || cap.statement.rootHash !== d.rootHash) return', 'if (false) return']], 'ATAC: dovada de includere pentru un arbore de alta marime'], + ['capul atins primit (statementHash nerefacut)', J, [["if (!H.test(String(cap.statementHash)) || sha256(Buffer.from(JSON.stringify(st), 'utf8')) !== cap.statementHash) return", 'if (false) return']], 'ATAC: capul de arbore atins'], + ['semnatarul cerut nu mai trece in verificare', J, [['const s = verificaSemnaturaCap(cap, semnatar);', 'const s = verificaSemnaturaCap(cap, null);']], 'includere: intrarea 7'], + ['dovada de consistenta nu mai trebuie sa porneasca de la capul vechi', J, [['if (vechi.statement.treeSize !== d.firstSize || vechi.statement.rootHash !== d.firstRoot) return', 'if (false) return']], 'ATAC: capetele inversate'], + ['un jurnal rupt primeste cap', J, [['if (!v.ok) throw new Error(`tree: the log is broken', 'if (false) throw new Error(`tree: the log is broken']], 'un jurnal rupt'], +]; +const FISIERE = [M, J, 'sidecar.mjs', 'proba-arbore.mjs']; +function copie() { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-arb-ctl-')); + fs.mkdirSync(path.join(t, 'aere-verify-layer')); fs.mkdirSync(path.join(t, 'proof-kinds')); + for (const f of FISIERE) fs.copyFileSync(path.join(AICI, f), path.join(t, 'aere-verify-layer', f)); + fs.copyFileSync(path.join(AICI, '..', 'proof-kinds', 'proof-kinds.mjs'), path.join(t, 'proof-kinds', 'proof-kinds.mjs')); + return t; +} +function ruleaza(t) { + return new Promise((resolve) => { + const c = spawn(process.execPath, [path.join(t, 'aere-verify-layer', 'proba-arbore.mjs')]); let out = ''; + const ceas = setTimeout(() => c.kill(), 180000); + c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', (x) => { out += x; }); + c.on('close', (cod) => { clearTimeout(ceas); resolve({ cod, rulat: /aere-arbore: \d+\/\d+/.test(out), rosii: out.split('\n').filter((l) => l.startsWith(' RAU ')) }); }); + }); +} +async function planteaza([nume, fisier, perechi, tinta]) { + const t = copie(); + try { + const f = path.join(t, 'aere-verify-layer', fisier); let src = fs.readFileSync(f, 'utf8'); + for (const [din, inl] of perechi) { + if (src.split(din).length !== 2) return [false, ` STRICAT ${nume}: tiparul "${din.slice(0, 40)}" apare de ${src.split(din).length - 1} ori`]; + src = src.replace(din, inl); + } + fs.writeFileSync(f, src); + const r = await ruleaza(t); + if (!r.rulat) return [false, ` STRICAT ${nume}: proba nu a ajuns la rezumat (cod ${r.cod})`]; + if (r.rosii.some((l) => l.startsWith(' RAU ' + tinta))) return [true, ` ROSU cum trebuia ${nume} (proba '${tinta}' pica)`]; + return [false, ` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde (${r.rosii.length} rosii altundeva)`]; + } finally { fs.rmSync(t, { recursive: true, force: true }); } +} +let rele = 0; +const t0 = copie(); const m = await ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true }); +if (m.rulat && m.cod === 0 && !m.rosii.length) console.log(' OK martorul: copia neatinsa verde'); else { rele++; console.log(` STRICAT martorul nu e verde (cod ${m.cod}, ${m.rosii.length} rosii)`); } +const rez = new Array(PLANTARI.length); let i = 0; +await Promise.all(Array.from({ length: 4 }, async () => { while (i < PLANTARI.length) { const k = i++; rez[k] = await planteaza(PLANTARI[k]); } })); +for (const [bun, linie] of rez) { console.log(linie); if (!bun) rele++; } +console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `DOVEDIT: martorul verde, ${PLANTARI.length} din ${PLANTARI.length} paznici scosi -> proba lor rosie`); +process.exitCode = rele ? 1 : 0; diff --git a/verify-layer/jurnal-arbore.mjs b/verify-layer/jurnal-arbore.mjs new file mode 100644 index 0000000..38bbbba --- /dev/null +++ b/verify-layer/jurnal-arbore.mjs @@ -0,0 +1,89 @@ +// 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; +} +// 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) return { ok: false, motiv: `not an ${TREE_HEAD} attestation` }; + if (!H.test(String(cap.statementHash)) || sha256(Buffer.from(JSON.stringify(st), 'utf8')) !== cap.statementHash) return { ok: false, motiv: 'the tree head statementHash does not match its statement (modified head)' }; + if (!Number.isInteger(st.treeSize) || st.treeSize < 1 || !H.test(String(st.rootHash))) return { ok: false, motiv: 'the tree head has no valid size and root' }; + const s = verificaSemnaturaCap(cap, semnatar); + if (!s.ok) return { ok: false, 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) return { ok: false, motiv: 'not an aere-audit-inclusion-proof' }; + const e = d.entry; + if (e.seq !== d.index) return { ok: false, motiv: `the entry is seq ${e.seq}, the proof is for index ${d.index}` }; + if (hashIntrare(e.seq, e.prev, e.proof) !== e.hash) return { ok: false, motiv: '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()) return { ok: false, motiv: 'the entry\'s envelope statementHash does not match its statement' }; + if (d.leafHash !== frunza(e.hash)) return { ok: false, motiv: 'the leaf is not this entry' }; + if (!verifyInclusion(d)) return { ok: false, motiv: `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) return { ok: false, motiv: `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 { ok: false, motiv: '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') return { ok: false, motiv: 'not an aere-audit-consistency-proof' }; + const a = capBun(vechi, semnatar); if (!a.ok) return { ok: false, motiv: 'old head: ' + a.motiv }; + const b = capBun(nou, semnatar); if (!b.ok) return { ok: false, motiv: 'new head: ' + b.motiv }; + if (vechi.statement.treeSize !== d.firstSize || vechi.statement.rootHash !== d.firstRoot) return { ok: false, motiv: 'the proof does not start at the old head' }; + if (nou.statement.treeSize !== d.secondSize || nou.statement.rootHash !== d.secondRoot) return { ok: false, motiv: 'the proof does not end at the new head' }; + if (!verifyConsistency(d)) return { ok: false, motiv: `the new head (${d.secondSize}) does not extend the old one (${d.firstSize}): the history was rewritten` }; + return { ok: true }; + } catch (x) { return { ok: false, motiv: 'the proof cannot be read: ' + x.message }; } +} diff --git a/verify-layer/proba-arbore.mjs b/verify-layer/proba-arbore.mjs new file mode 100644 index 0000000..614f646 --- /dev/null +++ b/verify-layer/proba-arbore.mjs @@ -0,0 +1,170 @@ +// Proba arborelui Merkle al jurnalului de audit (arbore-merkle.mjs, jurnal-arbore.mjs, comenzile sidecar-ului): vectorii de referinta +// ai Certificate Transparency, toate dovezile pana la 64 de frunze refacute si verificate, fiecare atac ca proba numita, drumul prin +// linia de comanda. Offline. +// node proba-arbore.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { leafHash, merkleTree, verifyInclusion, verifyConsistency } from './arbore-merkle.mjs'; +import { hashIntrare } from './sidecar.mjs'; +import * as J from './jurnal-arbore.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const { buildProof } = await import(pathToFileURL(path.join(AICI, '..', 'proof-kinds', 'proof-kinds.mjs')).href); +let treceri = 0; const esecuri = []; +function test(nume, fn) { try { fn(); treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' RAU ' + nume + ' -- ' + (e.message || e)); } } +const cere = (c, m) => { if (!c) throw new Error(m); }; +const clon = (o) => JSON.parse(JSON.stringify(o)); + +// ---------------------------------------------------------------- arborele insusi +// vectorii din suita de probe a implementarii de referinta Certificate Transparency: opt frunze si radacinile arborilor de 1..8 +const DATE = ['', '00', '10', '2021', '3031', '40414243', '5051525354555657', '606162636465666768696a6b6c6d6e6f'].map((h) => Buffer.from(h, 'hex')); +const RADACINI = ['6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d', 'fac54203e7cc696cf0dfcb42c92a1d9dbaf70ad9e621f4bd8d98662f00e3c125', 'aeb6bcfe274b70a14fb067a5e5578264db0fa9b51af5e0ba159158f329e06e77', 'd37ee418976dd95753c1c73862b9398fa2a2cf9b4ff0fdfe8b30cd95209614b7', '4e3bbb1f7b478dcfe71fb631631519a3bca12c9aefca1612bfce4c13a86264d4', '76e67dadbcdf1e10e1b74ddc608abd2f98dfb16fbce75277b5232a127f2087ef', 'ddb89be403809e325750d3d263cd78929c2942b7942a34b77e122c9594a74c8c', '5dc9da79a70659a9ad559cb701ded9a2ab9d823aad2f4960cfe370eff4604328']; +test('radacinile arborilor de 1..8 frunze sunt cele ale implementarii de referinta Certificate Transparency (8/8); arborele gol = SHA-256("")', () => { + const t = merkleTree(DATE.map(leafHash)); + for (let n = 1; n <= 8; n++) cere(t.root(n) === '0x' + RADACINI[n - 1], `n=${n}: ${t.root(n)}`); + cere(t.root(0) === '0x' + crypto.createHash('sha256').update('').digest('hex'), 'arborele gol'); +}); +const MARE = Array.from({ length: 64 }, (_, i) => leafHash(Buffer.from('frunza ' + i))); +const TM = merkleTree(MARE); +test('fiecare dovada de includere, pentru fiecare frunza a fiecarui arbore de 1..64, verifica (2.080 de dovezi)', () => { + let n0 = 0; for (let n = 1; n <= 64; n++) for (let m = 0; m < n; m++) { cere(verifyInclusion(TM.inclusionProof(m, n)), `m=${m} n=${n}`); n0++; } cere(n0 === 2080, String(n0)); +}); +test('fiecare dovada de consistenta intre doi arbori 1 <= m <= n <= 64 verifica (2.080 de dovezi)', () => { + let n0 = 0; for (let n = 1; n <= 64; n++) for (let m = 1; m <= n; m++) { cere(verifyConsistency(TM.consistencyProof(m, n)), `m=${m} n=${n}`); n0++; } cere(n0 === 2080, String(n0)); +}); +test('CONTROL: orice element al drumului schimbat, alt index, alta marime sau alta radacina -> dovada de includere cade (toate cazurile pana la 32)', () => { + const alt = '0x' + 'ab'.repeat(32); + for (let n = 2; n <= 32; n++) for (let m = 0; m < n; m++) { + const d = TM.inclusionProof(m, n); + d.path.forEach((_, i) => { const x = clon(d); x.path[i] = alt; cere(!verifyInclusion(x), `drum ${i} m=${m} n=${n}`); }); + cere(!verifyInclusion({ ...d, index: (m + 1) % n }), `index m=${m} n=${n}`); + // marimea se schimba impreuna cu radacina ADEVARATA a noii marimi (o pereche marime-radacina care nu e a niciunui arbore nu e un cap) + cere(!verifyInclusion({ ...d, treeSize: n + 1, rootHash: TM.root(n + 1) }) && !verifyInclusion({ ...d, rootHash: alt }) && !verifyInclusion({ ...d, path: [...d.path, alt] }), `marime/radacina/prelungire m=${m} n=${n}`); + } +}); +test('CONTROL: orice element schimbat, alta radacina veche sau noua, sau marimi schimbate -> dovada de consistenta cade (toate cazurile pana la 32)', () => { + const alt = '0x' + 'cd'.repeat(32); + for (let n = 2; n <= 32; n++) for (let m = 1; m < n; m++) { + const d = TM.consistencyProof(m, n); + d.path.forEach((_, i) => { const x = clon(d); x.path[i] = alt; cere(!verifyConsistency(x), `drum ${i} m=${m} n=${n}`); }); + cere(!verifyConsistency({ ...d, firstRoot: alt }) && !verifyConsistency({ ...d, secondRoot: alt }), `radacini m=${m} n=${n}`); + if (m + 1 < n) cere(!verifyConsistency({ ...d, firstSize: m + 1, firstRoot: TM.root(m + 1) }), `marime m=${m} n=${n}`); + } +}); +test('CONTROL: un nod intern dat drept frunza, cu un drum mai scurt decat adancimea arborelui (drumul nu ajunge la varf), -> refuzat', () => { + const t4 = merkleTree(MARE.slice(0, 4)); const d = t4.inclusionProof(0, 4); + // H(L0, L1) dat drept frunza 0 a arborelui de 4, cu drumul [H(L2, L3)]: duce la radacina, dar se opreste la adancimea 1 + const intern = merkleTree(MARE.slice(0, 2)).root(); + cere(!verifyInclusion({ leafHash: intern, index: 0, treeSize: 4, path: [d.path[1]], rootHash: t4.root() }), 'nodul intern a trecut drept frunza'); +}); +test('CONTROL: marimi egale: numai cu drum gol si aceeasi radacina', () => { + const r = TM.root(9); + cere(verifyConsistency({ firstSize: 9, secondSize: 9, firstRoot: r, secondRoot: r, path: [] }), 'aceeasi marime si radacina, drum gol: trebuia sa treaca'); + cere(!verifyConsistency({ firstSize: 9, secondSize: 9, firstRoot: r, secondRoot: TM.root(10), path: [] }) && !verifyConsistency({ firstSize: 9, secondSize: 9, firstRoot: r, secondRoot: r, path: [r] }), 'marimi egale cu alta radacina sau cu drum a trecut'); +}); +test('CONTROL: o frunza nu poate fi data drept nod (separarea 0x00/0x01): frunza facuta din cele doua hash-uri ale unui arbore de 2 nu are radacina lui', () => { + const la = leafHash(Buffer.from('a')), lb = leafHash(Buffer.from('b')); + const t2 = merkleTree([la, lb]); + // a doua preimagine clasica: fara separare, frunza cu datele la || lb ar avea exact radacina arborelui [a, b] + const alt = merkleTree([leafHash(Buffer.concat([Buffer.from(la.slice(2), 'hex'), Buffer.from(lb.slice(2), 'hex')]))]); + cere(alt.root() !== t2.root(), 'aceeasi radacina: un jurnal de o intrare ar trece drept jurnalul de doua'); +}); + +// ---------------------------------------------------------------- jurnalul sidecar-ului ca arbore +function jurnal(n, sare = '') { + const e = []; let prev = '0x' + '00'.repeat(32); + for (let i = 0; i < n; i++) { + const proof = buildProof('data', { name: `app-${i}${sare}`, content: `continut ${i}${sare}`, createdAt: '2026-09-30T08:00:00Z' }); + const hash = hashIntrare(i, prev, proof); e.push({ seq: i, prev, proof, hash }); prev = hash; + } + return e; +} +const L = jurnal(20); +const k1 = crypto.generateKeyPairSync('ml-dsa-65'), k2 = crypto.generateKeyPairSync('ml-dsa-65'); +const pem = (k) => k.privateKey.export({ type: 'pkcs8', format: 'pem' }), pub = (k) => k.publicKey.export({ type: 'spki', format: 'pem' }); +const cap12 = J.capDeArbore(L.slice(0, 12), { host: 'h1', createdAt: '2026-09-30T08:10:00Z', signKeyPem: pem(k1) }); +const cap20 = J.capDeArbore(L, { host: 'h1', createdAt: '2026-09-30T08:20:00Z', signKeyPem: pem(k1) }); +test('capul de arbore: marimea si radacina jurnalului, plic AIP-23 semnat ML-DSA-65 (aceeasi forma ca la capul de lant)', () => { + cere(cap20.statement.treeSize === 20 && cap20.statement.rootHash === merkleTree(L.map((e) => J.frunza(e.hash))).root() && cap20.signature.scheme === 'ml-dsa-65', JSON.stringify(cap20.statement)); +}); +test('includere: intrarea 7 e in arborele capului semnat, verificat FARA jurnal si cu semnatarul cerut; cu alt semnatar cerut -> refuzat', () => { + const d = J.dovadaIncludere(L, 7); + const r = J.verificaIncludere(d, { cap: cap20, semnatar: pub(k1) }); cere(r.ok && r.semnatDe, r.motiv); + const r2 = J.verificaIncludere(d, { cap: cap20, semnatar: pub(k2) }); cere(!r2.ok && /another key/.test(r2.motiv), JSON.stringify(r2)); +}); +test('ATAC: intrarea din dovada schimbata (plicul ei, sau hash-ul refacut peste alt plic) -> refuzata', () => { + const d = J.dovadaIncludere(L, 7); + const a = clon(d); a.entry.proof.statement.name = 'alta'; cere(!J.verificaIncludere(a, { cap: cap20 }).ok, 'plicul schimbat a trecut'); + const b = clon(d); b.entry.proof = L[8].proof; b.entry.hash = hashIntrare(7, b.entry.prev, b.entry.proof); cere(!J.verificaIncludere(b, { cap: cap20 }).ok, 'intrarea refacuta a trecut'); + const c = clon(d); c.entry.proof = L[8].proof; cere(/entry hash does not match/.test(J.verificaIncludere(c, { cap: cap20 }).motiv || ''), 'alt plic sub hash-ul vechi a trecut'); +}); +test('ATAC: un plic stricat scris de gazda in lant (lantul consecvent peste el) e inclus, dar verificarea spune ca plicul nu e intreg', () => { + const R = clon(L); R[3].proof.statement.name = 'rescris fara statementHash'; + let prev = R[2].hash; for (let i = 3; i < R.length; i++) { R[i].prev = prev; R[i].hash = hashIntrare(i, prev, R[i].proof); prev = R[i].hash; } + const capR = J.capDeArbore(R, { createdAt: '2026-09-30T08:30:00Z' }); + const r = J.verificaIncludere(J.dovadaIncludere(R, 3), { cap: capR }); + cere(!r.ok && /envelope statementHash/.test(r.motiv), JSON.stringify(r)); +}); +test('ATAC: dovada de includere pentru un arbore de alta marime decat capul (sau alta radacina) -> refuzata', () => { + const d = J.dovadaIncludere(L, 7, 12); + cere(J.verificaIncludere(d, { cap: cap12 }).ok, 'fata de capul de 12 trebuia sa treaca'); + cere(!J.verificaIncludere(d, { cap: cap20 }).ok, 'fata de capul de 20 a trecut'); +}); +test('ATAC: capul de arbore atins (radacina schimbata, semnatura veche) -> refuzat', () => { + const nes = J.capDeArbore(L, { createdAt: '2026-09-30T08:20:00Z' }); const x = clon(nes); x.statement.rootHash = J.dovadaIncludere(L, 7, 12).rootHash; x.statement.treeSize = 12; + cere(/modified head/.test(J.verificaIncludere(J.dovadaIncludere(L, 7, 12), { cap: x }).motiv || ''), 'capul nesemnat atins (statementHash nerefacut) a trecut'); + const c = clon(cap20); c.statement.rootHash = '0x' + '11'.repeat(32); c.statementHash = '0x' + crypto.createHash('sha256').update(JSON.stringify(c.statement)).digest('hex'); + cere(!J.verificaIncludere(J.dovadaIncludere(L, 7), { cap: c }).ok, 'capul atins a trecut'); +}); +test('consistenta: capul de 20 continua capul de 12, verificat FARA jurnal', () => { + const r = J.verificaConsistenta(J.dovadaConsistenta(L, 12), { vechi: cap12, nou: cap20, semnatar: pub(k1) }); cere(r.ok, r.motiv); +}); +test('ATAC: istoria rescrisa (intrarea 5 schimbata, lantul refacut, capul nou semnat de operator) nu continua capul vechi', () => { + const R = jurnal(20); const alt = jurnal(20, '-rescris'); R.splice(5, 15, ...alt.slice(5)); + let prev = R[4].hash; for (let i = 5; i < 20; i++) { R[i] = { ...R[i], prev }; R[i].hash = hashIntrare(i, prev, R[i].proof); prev = R[i].hash; } + const capR = J.capDeArbore(R, { host: 'h1', createdAt: '2026-09-30T08:20:00Z', signKeyPem: pem(k1) }); + const d = J.dovadaConsistenta(R, 12); + const r = J.verificaConsistenta(d, { vechi: cap12, nou: capR, semnatar: pub(k1) }); cere(!r.ok, 'rescrierea a trecut: ' + JSON.stringify(r)); + // si cu o dovada facuta pentru capul vechi adevarat: nu se potriveste cu capul nou rescris + const r2 = J.verificaConsistenta(J.dovadaConsistenta(L, 12), { vechi: cap12, nou: capR, semnatar: pub(k1) }); cere(!r2.ok, 'dovada veche a trecut pe capul rescris'); + // drumul istoriei rescrise, cu radacinile capetelor scrise in dovada: numai calculul radacinii vechi il prinde + const r3 = J.verificaConsistenta({ ...d, firstRoot: cap12.statement.rootHash }, { vechi: cap12, nou: capR, semnatar: pub(k1) }); + cere(!r3.ok && /rewritten/.test(r3.motiv), 'drumul rescris cu radacina veche declarata a trecut: ' + JSON.stringify(r3)); +}); +test('ATAC: capetele inversate (capul nou dat drept vechi) sau o dovada care nu porneste de la capul vechi -> refuzat', () => { + const d = J.dovadaConsistenta(L, 12); + cere(!J.verificaConsistenta(d, { vechi: cap20, nou: cap12 }).ok, 'inversate a trecut'); + const cap10 = J.capDeArbore(L.slice(0, 10), { createdAt: '2026-09-30T08:05:00Z' }); + cere(!J.verificaConsistenta(d, { vechi: cap10, nou: cap20 }).ok, 'alta pornire a trecut'); +}); +test('un jurnal rupt nu primeste cap de arbore si nici dovezi', () => { + const R = clon(L); R[3].proof.statement.name = 'x'; + let m = null; try { J.capDeArbore(R); } catch (e) { m = e.message; } cere(/broken at seq 3/.test(m || ''), String(m)); +}); + +// ---------------------------------------------------------------- linia de comanda +test('linia de comanda: tree-head, prove-inclusion, verify-inclusion (0; alt cap 1), prove-consistency, verify-consistency (0; inversat 1)', () => { + const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-arbore-')); const S = path.join(AICI, 'sidecar.mjs'); + const run = (...a) => { const r = spawnSync(process.execPath, [S, ...a], { cwd: T, encoding: 'utf8' }); return { cod: r.status, out: (r.stdout || '') + (r.stderr || '') }; }; + try { + const lg = path.join(T, 'audit.log'); + fs.writeFileSync(lg, L.slice(0, 12).map((e) => JSON.stringify(e)).join('\n') + '\n'); + fs.writeFileSync(path.join(T, 'k.pem'), pem(k1)); fs.writeFileSync(path.join(T, 'p.pem'), pub(k1)); + cere(run('tree-head', '--log', lg, '--sign-key', 'k.pem', '--out', 'h12.json').cod === 0, 'tree-head 12'); + fs.appendFileSync(lg, L.slice(12).map((e) => JSON.stringify(e)).join('\n') + '\n'); + cere(run('tree-head', '--log', lg, '--sign-key', 'k.pem', '--out', 'h20.json').cod === 0, 'tree-head 20'); + cere(run('prove-inclusion', '--log', lg, '--seq', '3', '--out', 'i.json').cod === 0, 'prove-inclusion'); + const v = run('verify-inclusion', '--proof', 'i.json', '--head', 'h20.json', '--signer', 'p.pem'); cere(v.cod === 0 && /INCLUDED/.test(v.out), v.out); + const v2 = run('verify-inclusion', '--proof', 'i.json', '--head', 'h12.json'); cere(v2.cod === 1 && /NOT included/.test(v2.out), v2.out); + cere(run('prove-consistency', '--log', lg, '--from', '12', '--out', 'c.json').cod === 0, 'prove-consistency'); + const w = run('verify-consistency', '--proof', 'c.json', '--old', 'h12.json', '--new', 'h20.json', '--signer', 'p.pem'); cere(w.cod === 0 && /EXTENDS/.test(w.out), w.out); + const w2 = run('verify-consistency', '--proof', 'c.json', '--old', 'h20.json', '--new', 'h12.json'); cere(w2.cod === 1 && /NOT consistent/.test(w2.out), w2.out); + } finally { fs.rmSync(T, { recursive: true, force: true }); } +}); + +console.log(`\naere-arbore: ${treceri}/${treceri + esecuri.length} cum trebuia`); +process.exitCode = esecuri.length ? 1 : 0; diff --git a/verify-layer/sidecar.mjs b/verify-layer/sidecar.mjs index 050c96a..d19c75c 100644 --- a/verify-layer/sidecar.mjs +++ b/verify-layer/sidecar.mjs @@ -23,6 +23,11 @@ // sidecar keygen --out -> 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'; @@ -372,6 +377,35 @@ async function main() { 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 }); + console.log(r.ok ? 'the new head EXTENDS the old one (the history up to the old head was not rewritten)' : `NOT consistent: ${r.motiv}`); + return r.ok ? 0 : 1; + } catch (e) { console.log(`${cmd}: ${String(e.message).replace(/[0-9a-fA-F]{32,}/g, '').slice(0, 200)}`); return 2; } + } case 'bundle': { const intrari = citesteJurnal(log); const v = verificaJurnal(intrari); @@ -381,7 +415,7 @@ async function main() { return v.ok ? 0 : 1; } default: - console.log('AERE Verification Layer (sidecar). Commands: record | scan | verify-log | attest-head | notarize-head | bundle'); + 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; }