From 35d711fa551d39398d03d104822d858a24d73e9a Mon Sep 17 00:00:00 2001 From: Aere Network Date: Tue, 29 Sep 2026 22:58:20 +0300 Subject: [PATCH] verify-layer: signed heads. keygen makes the operator's ML-DSA-65 head key; attest-head --sign-key signs the head statement in the form the AIP-23 reference verifier checks at its signature level (outside the statement, so the statementHash and its notarization are unchanged); verify-log --attested --signer requires the expected key, and without it says who signed and that no expected signer was compared. A signature says who vouches for the head, not when and not that the history is true. Tests 44/44 with the reference verifier (39 run and 5 skipped without it); negative control 9/9 here. --- README.md | 4 +- verify-layer/README.md | 33 +++++++----- verify-layer/control-negativ-sidecar.mjs | 9 ++++ verify-layer/proba-sidecar.mjs | 32 +++++++++++ verify-layer/sidecar.mjs | 68 ++++++++++++++++++++---- 5 files changed, 122 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 515d28c..e56875d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ command of the verification layer, which needs `ethers`. | [`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 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) | | [`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 | @@ -30,7 +30,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 | 34/34 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF= node proba-sidecar.mjs`); without it 30 run, 4 are reported as skipped and the exit code is 2 | 6/6 in this repository (`node control-negativ-sidecar.mjs`; its seventh 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 | 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) | | 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 | remediation 7/7, compliance report 3/3 in this repository | diff --git a/verify-layer/README.md b/verify-layer/README.md index 153fdac..8a1b39b 100644 --- a/verify-layer/README.md +++ b/verify-layer/README.md @@ -22,8 +22,14 @@ Node.js 24, no dependencies; `notarize-head` alone needs `ethers` (`npm install` proof of `firstSeen[statementHash]`). `verify-log --attested ` requires that today's log **continues** that head: the same first `count` entries, the entry `count - 1` hashing to the attested head. A rewritten or shortened history fails. -So: publish heads often (every deployment, or on a timer), and judge a log against the latest head you hold from outside the -host, never on its own. +- **A head can be signed (2026-09-29).** `keygen` makes an ML-DSA-65 key for the operator; `attest-head --sign-key` signs the + head statement in the form the AIP-23 reference verifier checks at its `signature` level; `verify-log --attested head.json + --signer head.pub.pem` then requires that the head was signed by that key. A signature says **who** vouches for the head, not + **when** (the notarization says when) and not that the history is true: whoever holds the key can sign the head of a rewritten + history, and without `--signer` a head signed by anyone passes, which the output says. + +So: publish heads often (every deployment, or on a timer), sign them with the operator's key, and judge a log against the latest +head you hold from outside the host, with the signer you expect, never on its own. ## Commands @@ -31,8 +37,9 @@ host, never on its own. node sidecar.mjs record --kind deployment --name --version --content-file [--host h] [--log p] [--at T] node sidecar.mjs record --kind proof --proof-file # any AIP-23 envelope, after checking its form and hash node sidecar.mjs scan --source docker|kubernetes --input [--host h] [--log p] - node sidecar.mjs verify-log --log p [--attested head.json] # 0 intact (and continues the head), 1 broken or not continued - node sidecar.mjs attest-head --log p [--host h] [--out head.json] # the head of the chain as an aere-audit-head envelope + node sidecar.mjs verify-log --log p [--attested head.json [--signer head.pub.pem]] # 0 intact (and continues the head), 1 broken or not continued + node sidecar.mjs attest-head --log p [--host h] [--out head.json] [--sign-key head.key.pem] # the head as an aere-audit-head envelope + node sidecar.mjs keygen --out # the operator's ML-DSA-65 head key (head.key.pem 0600, head.pub.pem) node sidecar.mjs notarize-head --head head.json --rpc --key-file [--notary 0x..] [--out notarized.json] node sidecar.mjs bundle --log p [--out bundle.json] # {host, count, head, chainOk, entries[]} for a console @@ -80,26 +87,28 @@ post-quantum` under a certified anchor of the testnet, and `VALID`. ## Tests - node proba-sidecar.mjs # 34 checks + 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 `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 `verify-log` alone (the stated limit) but fails against the attested head, as do a shortened log and a modified attestation, -while a log that only grew passes; that two writers appending 150 entries each at the same time leave an intact chain of 300; +while a log that only grew passes; that a signed head passes the verifier's signature level and `--signer`, while a head +signed by another key, an unsigned head with `--signer`, and a damaged signature are refused; that two writers appending 150 entries each at the same time leave an intact chain of 300; that a line that is not JSON is reported as broken at its seq; that the Docker and Kubernetes adapters record only running containers and no secret value; and that every envelope is `VALID` for the AIP-23 reference verifier. The verifier is looked for at `AERE_VERIFY_PROOF` (for example `verify-proof.mjs` from the `aere-node` repository, after `npm install` there); without -it, the four checks that need it are reported as skipped and the exit code is 2, not 0. +it, the five checks that need it are reported as skipped and the exit code is 2, not 0. -`control-negativ-sidecar.mjs` measured 7 of 7 on 2026-09-29: the version before the review (taken from the development -history, skipped where that history is absent) and six plantings, each disabling one guard (the writer lock, the head -comparison, the length check, the attestation hash check, the handling of an unreadable line, the digest check of `--attested`). +`control-negativ-sidecar.mjs` measured 10 of 10 on 2026-09-29: the version before the review (taken from the development +history, skipped where that history is absent) and nine plantings, each disabling one guard (the writer lock, the head +comparison, the length check, the attestation hash check, the handling of an unreadable line, the digest check of `--attested`, +the head signature check, the comparison with `--signer`, the refusal of an unsigned head when `--signer` is given). `proba-notarizare-testnet.mjs --key-file ` repeats the on-chain run above (9 checks, it needs a funded testnet key). ## What it is not (yet) It does not report to a live console over the network, and it has no per-cloud adapters that read deployments from the AWS, -Azure or GCP APIs with credentials: the runtime adapter above, without credentials, is the first one. It does not sign entries -with a host key, so it cannot tell two hosts apart by itself; the host name in an entry is also a declaration. No third party +Azure or GCP APIs with credentials: the runtime adapter above, without credentials, is the first one. It signs heads, not +each entry, and the host name in an entry is still a declaration. No third party has reviewed it. diff --git a/verify-layer/control-negativ-sidecar.mjs b/verify-layer/control-negativ-sidecar.mjs index b142119..678ba80 100644 --- a/verify-layer/control-negativ-sidecar.mjs +++ b/verify-layer/control-negativ-sidecar.mjs @@ -8,6 +8,9 @@ // P4 statementHash-ul capului atestat nu se mai reface -> R CONTROL (cap manipulat) rosu // P5 un rand care nu e JSON arunca din nou (cadere) -> N rosu // P6 --attested nu se mai cere digest -> N CONTROL rosu +// P7 semnatura capului nu se mai verifica (punctul 34, 2026-09-29) -> S CONTROL (semnatura stricata) rosu +// P8 semnatarul capului nu se mai compara cu --signer -> S CONTROL (alta cheie) rosu +// P9 un cap nesemnat trece cand --signer il cere semnat -> S CONTROL (nesemnat) rosu // Copiile stau LANGA original (importul lui proof-kinds e relativ) si se sterg; originalul trebuie sa ramana octet cu octet. // node control-negativ-sidecar.mjs -> 0 toate prinse, 1 una scapata, 2 STRICAT import crypto from 'node:crypto'; @@ -31,6 +34,9 @@ const T = { necitibil: 'N: rand care nu e JSON', digest: 'N CONTROL: --attested care nu e digest', concurent: 'C: doi scriitori concurenti', + semnStricata: 'S CONTROL: o semnatura de cap stricata', + altaCheie: 'S CONTROL: capul semnat de alta cheie', + nesemnat: 'S CONTROL: un cap nesemnat', }; function caz(id, text, tinte) { @@ -65,6 +71,9 @@ const P = [ ['P4', "if (!eDigest(cap.statementHash) || sha256(Buffer.from(JSON.stringify(st), 'utf8')) !== cap.statementHash.toLowerCase()) return", `if (!eDigest(cap.statementHash) || (sha256(Buffer.from(JSON.stringify(st), 'utf8')) !== cap.statementHash.toLowerCase() && ${NU})) return`, [T.capMan]], ['P5', "try { return JSON.parse(l); } catch { return { necitibil: true }; }", `try { return JSON.parse(l); } catch (e) { if (${NU}) return { necitibil: true }; throw e; }`, [T.necitibil]], ['P6', 'if (!eDigest(attested)) {', `if (!eDigest(attested) && ${NU}) {`, [T.digest]], + ['P7', "if (!ok) return { ok: false, semnat: true, keyId: idCheie(pub), motiv: 'the head signature does not verify over the statement' };", `if (!ok && ${NU}) return { ok: false, semnat: true, keyId: idCheie(pub), motiv: 'the head signature does not verify over the statement' };`, [T.semnStricata]], + ['P8', 'if (idCheie(asteptat) !== idCheie(pub)) return', `if (idCheie(asteptat) !== idCheie(pub) && ${NU}) return`, [T.altaCheie]], + ['P9', 'if (!s) return semnatarPem ? { ok: false,', `if (!s) return (semnatarPem && ${NU}) ? { ok: false,`, [T.nesemnat]], ]; for (const [id, a, b, tinte] of P) { let t = planta(nou, a, b); diff --git a/verify-layer/proba-sidecar.mjs b/verify-layer/proba-sidecar.mjs index ca62733..2d94879 100644 --- a/verify-layer/proba-sidecar.mjs +++ b/verify-layer/proba-sidecar.mjs @@ -6,6 +6,8 @@ // atestat (verify-log --attested); la fel un jurnal TAIAT si un cap atestat manipulat; un jurnal care continua capul trece // C doi scriitori concurenti nu rup lantul (lacatul) // N un rand care nu e JSON iese RUPT la seq-ul lui, nu cadere; --attested care nu e digest e refuzat +// S capul SEMNAT ML-DSA-65 (punctul 34): semnatura verificata de verificatorul AIP-23 de referinta; --signer cere cheia +// operatorului; un cap rescris si re-semnat de alta cheie, unul nesemnat cerut semnat, o semnatura stricata: refuzate // node proba-sidecar.mjs -> 0 toate cum trebuia, 1 cel putin una rea, 2 cel putin una SARITA (verificatorul AIP-23 lipseste) // Mediu: AERE_SIDECAR_MODUL (copia masurata, pentru controlul negativ), AERE_VERIFY_PROOF (verificatorul AIP-23; implicit cel din // depozitul de dezvoltare, tools/aere-proof-protocol/verify.mjs, sau verify-proof.mjs din aere-node/tools intr-o copie publica). @@ -95,6 +97,36 @@ try { const rM = side(['verify-log', '--log', log, '--attested', capMf]); cer('R CONTROL: cap atestat manipulat (count schimbat, statementHash vechi) -> refuzat (cod 1)', rM.cod === 1 && /modified attestation/.test(rM.out)); + // ---- S: capul semnat (2026-09-29, punctul 34) ------------------------------------------------------------------------------ + // citirile de mai jos nu arunca: un fisier nescris (o versiune fara capete semnate) inroseste verificarea lui, nu opreste proba + // (masurat 2026-09-29: pe versiunea veche proba se oprea aici si verificarile N de dupa nu mai rulau) + const jr = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } }; + const kOp = path.join(T, 'op'), kAt = path.join(T, 'atacator'); + const rk = side(['keygen', '--out', kOp]); side(['keygen', '--out', kAt]); + cer('S: keygen scrie cheia capetelor (ML-DSA-65) si tipareste numai amprenta', rk.cod === 0 && /key 0x[0-9a-f]{64}/.test(rk.out) && !/PRIVATE KEY/.test(rk.out) && fs.existsSync(path.join(kOp, 'head.key.pem'))); + cer('S CONTROL: keygen nu suprascrie o cheie existenta (cod 2)', side(['keygen', '--out', kOp]).cod === 2); + const hs = path.join(T, 'head-semnat.json'); + cer('S: attest-head --sign-key -> cap semnat, acelasi statementHash ca cel nesemnat (semnatura e in afara declaratiei)', side(['attest-head', '--log', log, '--out', hs, '--host', 'h1', '--at', '2026-09-26T09:04:00Z', '--sign-key', path.join(kOp, 'head.key.pem')]).cod === 0 && jr(hs).statementHash === jr(hout).statementHash && (jr(hs).signature || {}).scheme === 'ml-dsa-65'); + if (areVerificator) { + const vs = (() => { try { return execFileSync(process.execPath, [VERIFY, hs], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return String(e.stdout || ''); } })(); + cer('S: semnatura capului e PASSED la nivelul signature al verificatorului AIP-23', /PASSED\s*\]\s*signature/.test(vs)); + } else sari('S: semnatura capului e PASSED la nivelul signature al verificatorului AIP-23'); + const rs = side(['verify-log', '--log', log, '--attested', hs, '--signer', path.join(kOp, 'head.pub.pem')]); + cer('S: jurnalul continua capul semnat de operator, cu --signer = cheia lui (cod 0)', rs.cod === 0 && /the expected signer/.test(rs.out)); + cer('S: fara --signer, verify-log spune cine a semnat si ca nu s-a comparat cu o cheie asteptata', /not checked against an expected signer/.test(side(['verify-log', '--log', log, '--attested', hs]).out)); + // atacatorul reface lantul de la geneza (ca la R), isi face capul si il semneaza cu cheia LUI + const hAt = path.join(T, 'head-atacator.json'); + side(['attest-head', '--log', logRef, '--out', hAt, '--host', 'h1', '--at', '2026-09-26T09:04:00Z', '--sign-key', path.join(kAt, 'head.key.pem')]); + cer('S: fara --signer, lantul refacut si capul lui semnat de atacator trec (limita: o semnatura spune cine, nu care)', side(['verify-log', '--log', logRef, '--attested', hAt]).cod === 0); + const rAt = side(['verify-log', '--log', logRef, '--attested', hAt, '--signer', path.join(kOp, 'head.pub.pem')]); + cer('S CONTROL: capul semnat de alta cheie, cu --signer = operatorul -> refuzat (cod 1)', rAt.cod === 1 && /another key/.test(rAt.out)); + const rNe = side(['verify-log', '--log', log, '--attested', hout, '--signer', path.join(kOp, 'head.pub.pem')]); + cer('S CONTROL: un cap nesemnat, cu --signer -> refuzat (cod 1)', rNe.cod === 1 && /not signed/.test(rNe.out)); + const cs = jr(hs); if (cs.signature) { const sg = Buffer.from(cs.signature.signature, 'base64'); sg[100] ^= 1; cs.signature.signature = sg.toString('base64'); } + const hsr = path.join(T, 'head-semnat-rau.json'); fs.writeFileSync(hsr, JSON.stringify(cs)); + const rSr = side(['verify-log', '--log', log, '--attested', hsr]); + cer('S CONTROL: o semnatura de cap stricata -> refuzat chiar fara --signer (cod 1)', rSr.cod === 1 && /does not verify/.test(rSr.out)); + // ---- N: intrari nevalide ------------------------------------------------------------------------------------------------- const logN = path.join(T, 'necitibil.log'); fs.writeFileSync(logN, JSON.stringify(intrari[0]) + '\n{nu e json\n'); const rN = side(['verify-log', '--log', logN]); diff --git a/verify-layer/sidecar.mjs b/verify-layer/sidecar.mjs index a447bcc..050c96a 100644 --- a/verify-layer/sidecar.mjs +++ b/verify-layer/sidecar.mjs @@ -18,8 +18,9 @@ // 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] -> 0 intreg (si continua capul atestat), 1 rupt sau necontinuat -// sidecar attest-head --log p [--out f] -> capul lantului ca plic AIP-23 aere-audit-head +// 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 -> cheia ML-DSA-65 cu care operatorul semneaza capetele (head.key.pem 0600, head.pub.pem) // sidecar notarize-head --head cap.json --rpc URL --key-file f [--notary 0x..] [--out f] // sidecar bundle --log p [--out f] -> buraf {host, count, head, entries[]} pentru consola planului de control // Mesajele catre utilizator sunt in engleza. Numai Node 24 (ethers numai pentru notarize-head). @@ -69,17 +70,47 @@ export function verificaJurnal(intrari) { * 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) { +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 }; + 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 ------------------------------ @@ -252,12 +283,15 @@ async function main() { } case 'verify-log': { const intrari = citesteJurnal(log); - const af = get('--attested'); + const af = get('--attested'); const sf = get('--signer'); + if (sf && !af) { console.log('--signer needs --attested : the signature is on the attested head'); return 2; } if (af) { - let cap; + 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; } - const r = verificaFataDeCap(intrari, cap); - if (r.ok) { console.log(`log CONTINUES the attested head: its first ${r.count} entries are the attested ones, ${r.extra} written after`); return 0; } + 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); @@ -271,11 +305,25 @@ async function main() { 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 }; - const plic = { v: 1, kind: 'aere-audit-head-attestation', statement, statementHash: sha256(Buffer.from(JSON.stringify(statement), 'utf8')) }; + 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, '').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 '); 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 @@ -334,7 +382,7 @@ async function main() { } default: console.log('AERE Verification Layer (sidecar). Commands: record | scan | verify-log | attest-head | notarize-head | bundle'); - console.log(' sidecar record --kind runtime --artifact f --attested 0x.. sidecar verify-log --log p [--attested cap.json] sidecar attest-head --log p --out f'); + 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; } }