#!/usr/bin/env node // Aere Proof of Software: an attestation of WHAT was built, FROM WHAT, and WHEN, that anyone can verify without us. // // node pos.mjs attest --out attestation.json [--name N --version V] [--sbom sbom.json] [--keys keys.json] // [--cloud-key-file FILE] // node pos.mjs keygen --out keys.json [--alg secp256k1+ml-dsa-65] // node pos.mjs verify attestation.json [--cloud-key-file FILE] [--rebuild-from CLONE] [--signer pub.json] // node pos.mjs pack-npm --source-path DIR [--commit C] [--out-dir D] (npm pack of the COMMITTED tree, prints the file) // node pos.mjs sbom-go --source-path DIR --version V [--commit C] [--out sbom.cdx.json] (1.4.0: SBOM of a Go module from the // COMMITTED go.mod and go.sum, deterministic; verify --rebuild-from re-derives and compares it) // node pos.mjs sbom-nuget --source-path DIR --version V [--commit C] [--out sbom.cdx.json] (1.5.0: .NET, from the COMMITTED // packages.lock.json and the project file next to it; deterministic, re-derived by verify --rebuild-from) // node pos.mjs sbom-gradle --source-path DIR --version V [--commit C] [--out sbom.cdx.json] (1.5.0: Gradle, from the COMMITTED // gradle/verification-metadata.xml and gradle.lockfile; deterministic, re-derived by verify --rebuild-from) // node pos.mjs model-bom --model-dir DIR --out mlbom.json [--name N --version V] [--task T] [--dataset NAME=FILE ...] // node pos.mjs pubkey --keys keys.json [--out pub.json] (the public part, to hand out) // node pos.mjs credential issue --issuer-keys org.json --issuer-name O --subject-pub dev.pub.json --subject-name D [--valid-days 365] // node pos.mjs credential revoke --issuer-keys org.json --credential cred.json [--at ISO] [--reason R] // attest ... --credential cred.json verify ... --trust-issuer org.pub.json [--revocations r1.json,r2.json] (builder identity, 1.3.0) // // AI provenance (1.2.0): model-bom writes a CycloneDX 1.6 ML-BOM for a model directory (a machine-learning-model component, // every file of the directory with its SHA-256, datasets you name with theirs, the architecture read from config.json). // Attest it with the files: attest --base DIR --sbom mlbom.json and verify with the same --base. // --base names artifacts by their path relative to DIR (a model often has two config.json in two folders); files larger // than 64 MiB are hashed in a stream, so multi-gigabyte weights never have to fit in memory. // // attest --source-path DIR --build npm-pack also records the git TREE of DIR and the build; `verify --rebuild-from` // then repeats the build from a clone the verifier chose and requires the attested digest, byte for byte. // // The statement lists every artifact with its SHA-256 and size, the SBOM's digest (CycloneDX/SPDX, any format: it is // hashed, not interpreted), the git commit and remote of the source tree, the builder and the time. Its canonical // text is JSON.stringify(statement) as written; statementHash = sha256 of that text. // --keys signs the statement text with a HYBRID signature (@aere/pq-sign: classical + ML-DSA, both required) // --cloud-key notarizes statementHash on chain 2800 through Aere Cloud (POST /v1/notarize); the proof of it, with // the covering post-quantum anchor, is then GET /v1/proof/{statementHash} // `verify` recomputes every digest from the files it is given, checks the statement text against statementHash, // verifies the hybrid signature, and (with a Cloud key) asks the chain for the proof and its finality. It exits 0 only // when everything that is present holds; what is absent is reported as absent, never as valid. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import * as pq from '../../sdk-pq-sign/index.mjs'; import { AereCloud } from '../../sdk/index.mjs'; export const TOOL = 'aere-proof-of-software/1.5.0'; const AICI = path.dirname(fileURLToPath(import.meta.url)); // peste prag, in flux: o greutate de model de cativa GB nu are voie sa fie citita intreaga in memorie (2026-09-25) const PRAG_FLUX = Number(process.env.AERE_POS_FLUX_PESTE || 64 * 1024 * 1024); export function sha256File(p) { const st = fs.statSync(p); if (st.size <= PRAG_FLUX) return '0x' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); const h = crypto.createHash('sha256'), buf = Buffer.allocUnsafe(8 * 1024 * 1024), fd = fs.openSync(p, 'r'); let citit = 0; try { for (let n; (n = fs.readSync(fd, buf, 0, buf.length, citit)) > 0; citit += n) h.update(n === buf.length ? buf : buf.subarray(0, n)); } finally { fs.closeSync(fd); } if (citit !== st.size) throw new Error(`${p}: read ${citit} bytes of ${st.size} (the file changed while it was hashed)`); return '0x' + h.digest('hex'); } const sha256Text = (t) => '0x' + crypto.createHash('sha256').update(t, 'utf8').digest('hex'); const git = (dir, args) => { try { return execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString().trim(); } catch { return null; } }; // With sourcePath the statement also names the git TREE of that directory at HEAD. A tree hash is a digest of content: // two checkouts with the same tree hold the same committed bytes, whatever the line endings of their working copies. function gitInfo(dir, sourcePath) { const commit = git(dir, ['rev-parse', 'HEAD']); if (!commit) { if (sourcePath) throw new Error('--source-path needs a git checkout'); return null; } const remote = git(dir, ['remote', 'get-url', 'origin']); const dirty = git(dir, ['status', '--porcelain']); const info = { commit, remote: remote ? remote.replace(/\/\/[^@/]+@/, '//') : null, // no credentials in the statement dirty: dirty === null ? null : dirty.length > 0, }; if (sourcePath) { const top = git(dir, ['rev-parse', '--show-toplevel']); const rel = path.relative(path.resolve(top), path.resolve(dir, sourcePath)).split(path.sep).join('/'); if (!rel || rel.startsWith('..')) throw new Error(`--source-path ${sourcePath}: must be a directory below the repository root`); const tree = git(dir, ['rev-parse', '--verify', '--quiet', `HEAD:${rel}`]); if (!tree) throw new Error(`--source-path ${sourcePath}: "${rel}" is not tracked at HEAD`); const pd = git(dir, ['status', '--porcelain', '--', path.resolve(dir, sourcePath)]); info.path = rel; info.tree = tree; info.pathDirty = pd === null ? null : pd.length > 0; } return info; } // The committed bytes of , written as they are in the object store: no checkout conversion (autocrlf, eol), // so the same tree gives the same files on every machine. Submodule entries are skipped. --full-tree: without it ls-tree filters the // tree by the caller's directory inside the repository, and from a subdirectory the listing comes back EMPTY. export function exportTree(repoDir, treeish, dest) { const out = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', treeish], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString(); let n = 0; for (const rec of out.split('\0').filter(Boolean)) { const tab = rec.indexOf('\t'); const [mode, type, sha] = rec.slice(0, tab).split(' '); const name = rec.slice(tab + 1); if (type !== 'blob') continue; const p = path.join(dest, name); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, execFileSync('git', ['cat-file', 'blob', sha], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 })); if (mode === '100755') { try { fs.chmodSync(p, 0o755); } catch { /* no mode bits on this filesystem */ } } n++; } if (!n) throw new Error(`${treeish}: no files`); return n; } const npmVersion = () => { try { return execFileSync('npm --version', { shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } }; // `npm pack` of the COMMITTED tree (never of the working copy): the artifact is then a function of the tree alone. export function packNpmFromTree(repoDir, treeish, outDir) { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-src-')); try { exportTree(repoDir, treeish, src); fs.mkdirSync(outDir, { recursive: true }); const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-out-')); const rel = path.relative(src, stage); // no spaces of ours in it, and quoted anyway const name = execFileSync(`npm pack --silent --ignore-scripts --pack-destination "${rel}"`, { cwd: src, shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().split(/\r?\n/).pop(); const out = path.join(outDir, name); fs.copyFileSync(path.join(stage, name), out); fs.rmSync(stage, { recursive: true, force: true }); return out; } finally { fs.rmSync(src, { recursive: true, force: true }); } } // numele unui artefact: bazenumele, sau calea relativa la --base (cu / ca separator, oricare ar fi sistemul) export function numeArtefact(p, base) { if (!base) return path.basename(p); const rel = path.relative(path.resolve(base), path.resolve(p)); if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`${p} is not inside --base ${base}`); return rel.split(path.sep).join('/'); } // ---------------------------------------------------------------- builder identity: the developer credential (1.3.0) // A signature says "these keys signed"; it does not say WHO holds them. A developer credential is an organization's statement, // signed with its own hybrid keys, that names a person and binds that name to the person's signing keys for a period: // { v:1, kind:'aere-developer-credential', body:{ issuer:{name, keys}, subject:{name, keys}, validFrom, validUntil }, signature } // `attest --credential` puts sha256(body text) into the statement (builder.credential), so the signature over the statement also // covers "I built this under credential C". `verify --trust-issuer` then requires: the issuer is the trust root the VERIFIER chose // (never the one the attestation carries), the credential names exactly the keys that signed the statement, the time falls in its // validity, and no revocation signed by the issuer covers that time. The time is the chain's first-seen time when the proof was // read, otherwise the builder's own createdAt, and the check says which one it used. export function publicKeysOf(k) { if (!k) return null; if (k.classicalPublicKey && k.pqPublicKey) return { alg: k.alg, classicalPublicKey: k.classicalPublicKey, pqPublicKey: k.pqPublicKey }; if (k.classical && k.pq) return { alg: k.alg, classicalPublicKey: k.classical.publicKey, pqPublicKey: k.pq.publicKey }; return null; } const keyId = (pub) => (pub ? sha256Text(JSON.stringify({ alg: pub.alg, classicalPublicKey: pub.classicalPublicKey, pqPublicKey: pub.pqPublicKey })) : null); export const credentialHash = (cred) => sha256Text(JSON.stringify(cred.body)); export function issueCredential({ issuerKeys, issuerName, subjectKeys, subjectName, validFrom, validUntil }) { const sub = publicKeysOf(subjectKeys); if (!sub) throw new Error('the subject public keys are required (keys.json or its public part)'); if (!(Date.parse(validUntil) > Date.parse(validFrom))) throw new Error('validUntil must be after validFrom'); const body = { issuer: { name: String(issuerName), keys: publicKeysOf(issuerKeys) }, subject: { name: String(subjectName), keys: sub }, validFrom: new Date(validFrom).toISOString(), validUntil: new Date(validUntil).toISOString() }; return { v: 1, kind: 'aere-developer-credential', body, signature: pq.sign(JSON.stringify(body), issuerKeys) }; } export function revokeCredential({ issuerKeys, credential, revokedAt, reason = '' }) { const body = { credential: credentialHash(credential), revokedAt: new Date(revokedAt).toISOString(), reason: String(reason).slice(0, 200), issuer: publicKeysOf(issuerKeys) }; return { v: 1, kind: 'aere-developer-credential-revocation', body, signature: pq.sign(JSON.stringify(body), issuerKeys) }; } function credentialChecks(att, { trustIssuer, revocations, chainTime }, ok, checks) { const want = att.statement.builder && att.statement.builder.credential; if (!want) { checks.push({ name: 'builder identity', pass: null, detail: 'no developer credential in the statement' }); return; } const cred = att.credential; if (!ok('credential: the attestation carries the credential the statement names', !!(cred && cred.kind === 'aere-developer-credential' && cred.body && credentialHash(cred) === want), cred ? 'hash differs' : 'absent')) return; const b = cred.body; const sv = pq.verify(JSON.stringify(b), cred.signature || {}); const semnatarEmitent = keyId(publicKeysOf(cred.signature)) === keyId(b.issuer && b.issuer.keys); ok(`credential: signed by the issuer it names (${b.issuer && b.issuer.name})`, sv.valid && semnatarEmitent, sv.valid ? (semnatarEmitent ? '' : 'signed by other keys than the named issuer') : sv.reason); if (trustIssuer) ok('credential: the issuer is the trust root you gave', keyId(publicKeysOf(trustIssuer)) === keyId(b.issuer && b.issuer.keys), 'issuer not trusted'); else checks.push({ name: 'credential: issuer trust', pass: null, detail: 'not judged; pass --trust-issuer to require your trust root' }); ok(`credential: names the keys that signed the statement (${b.subject && b.subject.name})`, !!att.signature && keyId(publicKeysOf(att.signature)) === keyId(b.subject && b.subject.keys), att.signature ? 'the statement was signed by other keys' : 'the statement is not signed'); const t = chainTime || att.statement.createdAt; const sursa = chainTime ? 'first seen on chain' : 'createdAt, declared by the builder'; // 2026-09-29 (B-18): data declarata e aleasa de cine tine cheile. Ea poate ACUZA (in afara valabilitatii, dupa revocare: fals), dar nu // poate ACHITA: inauntru, fara timpul lantului, raspunsul e nejudecat, nu adevarat. Masurat pe 1.4.0: cu cheile unei acreditari // revocate, o atestare antedatata inaintea revocarii iesea VALIDA, cu "not revoked" trecut. const declarat = !chainTime; const nejudecat = 'by the builder\'s own date, which whoever holds the keys chooses; notarize the attestation to judge it on the chain\'s time'; const inValabilitate = Date.parse(t) >= Date.parse(b.validFrom) && Date.parse(t) <= Date.parse(b.validUntil); if (!inValabilitate || !declarat) ok(`credential: valid at ${t} (${sursa})`, inValabilitate, `valid ${b.validFrom} .. ${b.validUntil}`); else checks.push({ name: `credential: valid at ${t} (${sursa})`, pass: null, detail: 'inside the validity ' + nejudecat }); for (const rv of revocations || []) { const rb = rv && rv.body; const rsv = rb ? pq.verify(JSON.stringify(rb), rv.signature || {}) : { valid: false }; const aEmitentului = rb && rsv.valid && keyId(publicKeysOf(rv.signature)) === keyId(b.issuer && b.issuer.keys) && keyId(rb.issuer) === keyId(b.issuer.keys); if (!aEmitentului) { checks.push({ name: 'credential: a revocation', pass: null, detail: 'ignored: not signed by this credential\'s issuer' }); continue; } if (rb.credential !== want) continue; // another credential of the same issuer const inainteDeRevocare = Date.parse(t) < Date.parse(rb.revokedAt); if (!inainteDeRevocare || !declarat) ok(`credential: not revoked at ${t} (revocation from ${rb.revokedAt})`, inainteDeRevocare, rb.reason || 'revoked'); else checks.push({ name: `credential: not revoked at ${t} (revocation from ${rb.revokedAt})`, pass: null, detail: 'before the revocation ' + nejudecat }); } if (!(revocations || []).length) checks.push({ name: 'credential: revocations', pass: null, detail: 'none given; a revocation the verifier was not handed cannot be seen' }); } export function buildStatement({ artifacts, sbom, name, version, sourcePath = null, build = null, base = null, credential = null, cwd = process.cwd(), now = new Date() }) { if (!artifacts.length) throw new Error('at least one artifact file is required'); const seen = new Set(); const list = artifacts.map((p) => { const base0 = numeArtefact(p, base); if (seen.has(base0)) throw new Error(`two artifacts share the name "${base0}"; artifacts are matched by name at verification${base ? '' : ' (use --base to name them by relative path)'}`); seen.add(base0); const st = fs.statSync(p); return { name: base0, sha256: sha256File(p), bytes: st.size }; }); return { v: 1, kind: 'aere-proof-of-software', tool: TOOL, subject: { name: name || list[0].name, version: version || null }, ...(base ? { artifactNames: 'relative-path' } : {}), artifacts: list, sbom: sbom ? { name: path.basename(sbom), sha256: sha256File(sbom), bytes: fs.statSync(sbom).size } : null, source: gitInfo(cwd, sourcePath), ...(build ? { build: build === 'npm-pack' ? { kind: 'npm-pack', npm: npmVersion() } : { kind: String(build) } } : {}), builder: { platform: `${process.platform}/${process.arch}`, node: process.version, ...(credential ? { credential: credentialHash(credential) } : {}) }, createdAt: now.toISOString(), }; } export async function attest(opts) { if (opts.credential) { // attesting under a credential that does not name these keys would produce an attestation that can only fail verification if (!opts.keys) throw new Error('--credential needs --keys: the credential names the keys that must sign the statement'); if (keyId(publicKeysOf(opts.keys)) !== keyId(opts.credential.body && opts.credential.body.subject && opts.credential.body.subject.keys)) throw new Error('the credential names other keys than --keys'); } const statement = buildStatement(opts); const statementJson = JSON.stringify(statement); const statementHash = sha256Text(statementJson); const out = { v: 1, kind: 'aere-proof-of-software-attestation', statement, statementHash, signature: null, notarization: null, ...(opts.credential ? { credential: opts.credential } : {}) }; if (opts.keys) out.signature = pq.sign(statementJson, opts.keys); if (opts.cloud) { const r = await opts.cloud.notarize(statementHash); out.notarization = { txHash: r.txHash, block: r.block, firstSeenAt: r.firstSeenAt, firstTime: r.firstTime, contract: r.contract, chainId: r.chainId, proof: '/v1/proof/' + statementHash }; } return out; } // The SBOM re-derived from the committed tree (1.3.0). `npm sbom --package-lock-only` puts a random serial number and a timestamp in // every run, so the attested file can never be reproduced byte for byte; what can be reproduced is what it SAYS. The semantic form: // the root, and every component by purl with its hashes and scope, and the dependency graph, all sorted. An SBOM edited by hand // (a component removed, a version or an integrity hash changed) and attested again passes on its digest and fails here. export function sbomSemantica(bom) { // 1.4.0: si proprietatile 'aere:' (un SBOM Go tine acolo hash-ul h1 din go.sum); un SBOM npm nu are asemenea proprietati, deci // judecata lui ramane aceeasi const aere = (ps) => (ps || []).filter((x) => String(x.name).startsWith('aere:')).map((x) => `${x.name}=${x.value}`).sort(); // 1.5.0: o componenta fara purl (o referinta de proiect .NET, radacina Gradle) se numeste prin bom-ref const comp = (c) => ({ purl: c.purl || c['bom-ref'] || `${c.name}@${c.version}`, scope: c.scope || null, hashes: (c.hashes || []).map((h) => `${h.alg}:${String(h.content).toLowerCase()}`).sort(), props: aere(c.properties) }); const cheie = (x) => JSON.stringify(x); const componente = (bom.components || []).map(comp).sort((a, b) => (cheie(a) < cheie(b) ? -1 : 1)); const dependente = (bom.dependencies || []).map((d) => ({ ref: d.ref, dependsOn: [...(d.dependsOn || [])].sort() })).sort((a, b) => (a.ref < b.ref ? -1 : 1)); const radacina = bom.metadata && bom.metadata.component ? comp(bom.metadata.component) : null; return { radacina: radacina && radacina.purl, meta: aere(bom.metadata && bom.metadata.properties), componente, dependente }; } export function sbomNpmFromTree(repoDir, treeish) { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-sbom-')); try { exportTree(repoDir, treeish, src); if (!fs.existsSync(path.join(src, 'package-lock.json'))) return { lipsa: 'the committed tree has no package-lock.json, so the SBOM cannot be re-derived from it' }; const out = execFileSync('npm sbom --sbom-format cyclonedx --package-lock-only', { cwd: src, shell: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString(); return { bom: JSON.parse(out) }; } catch (e) { return { lipsa: 'npm sbom did not run here: ' + String(e.message || e).slice(0, 100) }; } finally { fs.rmSync(src, { recursive: true, force: true }); } } // 1.4.0 (2026-09-29): SBOM-ul unui modul Go, derivat DETERMINIST din go.sum si go.mod (aceleasi fisiere -> aceiasi octeti: fara // timp, numarul de serie derivat din continut). Componentele sunt modulele al caror CONTINUT e fixat in go.sum (randul fara /go.mod), // cu hash-ul h1 al lui Go ca proprietate `aere:go-sum-h1`: h1 e un SHA-256 peste un rezumat al fisierelor modulului, nu peste o // arhiva, deci nu se scrie drept `hashes` SHA-256 (ar spune altceva decat este). Modulele fixate numai prin go.mod (consultate la // rezolvarea grafului, fara cod descarcat) se numara in `aere:go-sum-go-mod-only`, nu se listeaza. Ce NU spune: go.sum poate tine si // module ramase de la o versiune veche (pana la `go mod tidy`), deci SBOM-ul spune ce e FIXAT, nu ce s-a compilat. const GO_SUM_RAND = /^(\S+) (v\S+?)(\/go\.mod)? (h1:[A-Za-z0-9+/]{43}=)$/; const purlGo = (p, v) => `pkg:golang/${p.split('/').map(encodeURIComponent).join('/')}@${encodeURIComponent(v)}`; export function sbomGo({ goSum, goMod, version }) { const mm = /^module\s+"?([^\s"]+)"?\s*$/m.exec(String(goMod || '').replace(/\r/g, '')); if (!mm) throw new Error('go.mod names no module'); if (!version) throw new Error('an SBOM for a Go module needs --version (the module version is not in go.mod)'); const mods = new Map(); String(goSum || '').replace(/\r/g, '').split('\n').forEach((l, i) => { if (!l.trim()) return; const m = GO_SUM_RAND.exec(l); if (!m) throw new Error(`go.sum line ${i + 1} is not " [/go.mod] h1:"`); const k = `${m[1]} ${m[2]}`; const e = mods.get(k) || { path: m[1], version: m[2], h1: null, goMod: null }; if (m[3]) e.goMod = m[4]; else e.h1 = m[4]; mods.set(k, e); }); const components = [...mods.values()].filter((e) => e.h1).map((e) => { const purl = purlGo(e.path, e.version); return { type: 'library', 'bom-ref': purl, name: e.path, version: e.version, purl, properties: [{ name: 'aere:go-sum-h1', value: e.h1 }] }; }).sort((a, b) => (a.purl < b.purl ? -1 : a.purl > b.purl ? 1 : 0)); const doarGoMod = [...mods.values()].filter((e) => !e.h1).length; const root = purlGo(mm[1], version); const metadata = { component: { type: 'application', 'bom-ref': root, name: mm[1], version, purl: root }, tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] }, properties: [{ name: 'aere:derived-from', value: 'go.sum' }, { name: 'aere:go-sum-go-mod-only', value: String(doarGoMod) }], }; const b = crypto.createHash('sha256').update(JSON.stringify({ metadata, components })).digest(); b[6] = (b[6] & 0x0f) | 0x50; b[8] = (b[8] & 0x3f) | 0x80; const h = b.subarray(0, 16).toString('hex'); const serialNumber = `urn:uuid:${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; return { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber, version: 1, metadata, components, dependencies: [] }; } /** SBOM-ul Go al arborelui COMIS : (go.sum si go.mod citite din git, fara unealta Go). */ export function sbomGoFromTree(repoDir, commit, rel, version) { const blob = (f) => { try { return execFileSync('git', ['cat-file', '-p', `${commit}:${rel}/${f}`], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString(); } catch { return null; } }; const goMod = blob('go.mod'); if (goMod == null) return { lipsa: 'the committed tree has no go.mod, so the SBOM cannot be re-derived from it' }; const goSum = blob('go.sum'); if (goSum == null) return { lipsa: 'the committed tree has no go.sum, so the SBOM cannot be re-derived from it' }; try { return { bom: sbomGo({ goSum, goMod, version }) }; } catch (e) { return { lipsa: 'the committed go.sum or go.mod cannot be read: ' + String(e.message || e).slice(0, 100) }; } } const derivatDin = (bom) => ((bom && bom.metadata && (bom.metadata.properties || []).find((x) => x.name === 'aere:derived-from')) || {}).value || null; // numarul de serie al unui SBOM derivat: UUID (forma 5) din continut, deci aceleasi fisiere dau aceiasi octeti function serieDerivata(o) { const b = crypto.createHash('sha256').update(JSON.stringify(o)).digest(); b[6] = (b[6] & 0x0f) | 0x50; b[8] = (b[8] & 0x3f) | 0x80; const h = b.subarray(0, 16).toString('hex'); return `urn:uuid:${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; } const propr = (o) => Object.entries(o).filter(([, v]) => v != null).map(([name, value]) => ({ name, value: String(value) })).sort((a, b) => (a.name < b.name ? -1 : 1)); const graf = (muchii) => { const m = new Map(); for (const [de, la] of muchii) { if (!m.has(de)) m.set(de, new Set()); m.get(de).add(la); } return [...m.entries()].map(([ref, s]) => ({ ref, dependsOn: [...s].sort() })).sort((a, b) => (a.ref < b.ref ? -1 : 1)); }; const blobDin = (repoDir, commit, cale) => { try { return execFileSync('git', ['cat-file', '-p', `${commit}:${cale}`], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString(); } catch { return null; } }; const numeDin = (repoDir, commit, rel) => { try { return execFileSync('git', ['ls-tree', '--name-only', `${commit}:${rel}`], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().split('\n').filter(Boolean); } catch { return null; } }; // 1.5.0 (2026-09-29): SBOM-ul unui proiect .NET, derivat DETERMINIST din packages.lock.json COMIS (scris de NuGet cu // RestorePackagesWithLockFile). Componentele sunt pachetele din toate sectiunile (cadru, si cadru/RID), cu versiunea REZOLVATA; // contentHash-ul NuGet e o proprietate (`aere:nuget-content-hash`), nu un `hashes` SHA-512: NuGet il calculeaza peste continutul // pachetului FARA semnatura de depozit, deci nu e hash-ul fisierului .nupkg descarcat (masurat pe pachete reale semnate). Referintele // de proiect (tip Project) sunt componente cu bom-ref `project:`, fara purl. Graful vine din lockfile: fiecare dependinta // numita se rezolva la versiunea rezolvata a aceluiasi pachet din aceeasi sectiune (sau din cadrul de baza, pentru o sectiune RID). const NUGET_TIPURI = new Set(['Direct', 'Transitive', 'CentralTransitive', 'Project']); const NUGET_HASH = /^[A-Za-z0-9+/]{86}==$/; const purlNuget = (n, v) => `pkg:nuget/${encodeURIComponent(n)}@${encodeURIComponent(v)}`; export function sbomNuget({ lock, projectName, version }) { let j; try { j = typeof lock === 'string' ? JSON.parse(lock) : lock; } catch { throw new Error('packages.lock.json is not JSON'); } if (!j || ![1, 2].includes(j.version) || !j.dependencies || typeof j.dependencies !== 'object') throw new Error('packages.lock.json has no version 1 or 2, or no dependencies'); if (!projectName) throw new Error('an SBOM for a .NET project needs the project name (one project file next to packages.lock.json)'); if (!version) throw new Error('an SBOM for a .NET project needs --version (the project version is not in packages.lock.json)'); const root = purlNuget(projectName, version); const sectiuni = Object.keys(j.dependencies).sort(); const index = new Map(sectiuni.map((s) => [s, new Map(Object.entries(j.dependencies[s] || {}).map(([n, e]) => [n.toLowerCase(), [n, e]]))])); const pachete = new Map(), proiecte = new Map(), muchii = []; const refDe = ([n, e]) => (e.type === 'Project' ? `project:${n}` : purlNuget(n, e.resolved)); for (const s of sectiuni) { const ix = index.get(s), baza = s.includes('/') ? index.get(s.split('/')[0]) : null; const rezolva = (dn) => { const x = ix.get(dn.toLowerCase()) || (baza && baza.get(dn.toLowerCase())); if (!x) throw new Error(`packages.lock.json: ${dn} is a dependency in ${s} and is not resolved in it`); return x; }; for (const [lower, [n, e]] of ix) { if (!e || !NUGET_TIPURI.has(e.type)) throw new Error(`packages.lock.json: ${n} in ${s} has an unknown type ${e && e.type}`); if (e.type === 'Project') { proiecte.set(lower, n); } else { if (typeof e.resolved !== 'string' || !e.resolved) throw new Error(`packages.lock.json: ${n} in ${s} has no resolved version`); if (!NUGET_HASH.test(String(e.contentHash))) throw new Error(`packages.lock.json: ${n} ${e.resolved} in ${s} has no SHA-512 content hash`); const k = `${lower}@${e.resolved.toLowerCase()}`; const p = pachete.get(k) || { name: n, version: e.resolved, hash: e.contentHash, sectiuni: new Set(), tipuri: new Set() }; if (p.hash !== e.contentHash) throw new Error(`packages.lock.json: ${n} ${e.resolved} has two content hashes`); p.sectiuni.add(s); p.tipuri.add(e.type); pachete.set(k, p); } if (e.type === 'Direct' || e.type === 'Project') muchii.push([root, refDe([n, e])]); for (const dn of Object.keys(e.dependencies || {})) muchii.push([refDe([n, e]), refDe(rezolva(dn))]); } } const components = [ ...[...pachete.values()].map((p) => { const purl = purlNuget(p.name, p.version); return { type: 'library', 'bom-ref': purl, name: p.name, version: p.version, purl, properties: propr({ 'aere:nuget-content-hash': 'sha512-' + p.hash, 'aere:nuget-frameworks': [...p.sectiuni].sort().join(','), 'aere:nuget-type': [...p.tipuri].sort().join(',') }) }; }), ...[...proiecte.values()].map((n) => ({ type: 'library', 'bom-ref': `project:${n}`, name: n, properties: propr({ 'aere:nuget-type': 'Project' }) })), ].sort((a, b) => (a['bom-ref'] < b['bom-ref'] ? -1 : a['bom-ref'] > b['bom-ref'] ? 1 : 0)); const metadata = { component: { type: 'application', 'bom-ref': root, name: projectName, version, purl: root }, tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] }, properties: propr({ 'aere:derived-from': 'packages.lock.json', 'aere:nuget-lock-version': j.version, 'aere:nuget-frameworks': sectiuni.join(',') }), }; const dependencies = graf(muchii); return { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber: serieDerivata({ metadata, components, dependencies }), version: 1, metadata, components, dependencies }; } /** SBOM-ul .NET al arborelui COMIS :: packages.lock.json si numele UNUI fisier de proiect din acelasi dosar. */ export function sbomNugetFromTree(repoDir, commit, rel, version) { const lock = blobDin(repoDir, commit, `${rel}/packages.lock.json`); if (lock == null) return { lipsa: 'the committed tree has no packages.lock.json, so the SBOM cannot be re-derived from it' }; const proj = (numeDin(repoDir, commit, rel) || []).filter((n) => /\.(cs|fs|vb)proj$/i.test(n)); if (proj.length !== 1) return { lipsa: `the committed directory has ${proj.length} project files; exactly one names the SBOM root` }; try { return { bom: sbomNuget({ lock, projectName: proj[0].replace(/\.(cs|fs|vb)proj$/i, ''), version }) }; } catch (e) { return { lipsa: 'the committed packages.lock.json cannot be read: ' + String(e.message || e).slice(0, 120) }; } } // 1.5.0 (2026-09-29): SBOM-ul unui proiect Gradle (un singur proiect), derivat DETERMINIST din gradle/verification-metadata.xml // COMIS (verificarea dependintelor lui Gradle: fiecare artefact rezolvat cu hash-urile lui) si, daca e comis, din gradle.lockfile // (coordonatele fiecarei configuratii). Hash-urile artefactului principal (-.jar/.aar) sunt chiar hash-urile fisierului, // deci intra in `hashes`; toate artefactele (si pom-urile, modulele) stau ca `aere:gradle-artifact`. Ce NU spune: graful de dependinte // (niciunul din cele doua fisiere nu il tine, deci `dependencies` e gol si metadata o spune); verification-metadata cuprinde tot ce // rezolva constructia (si pom-uri parinte, si pluginuri), iar configuratiile din gradle.lockfile spun ce ajunge in care classpath. const XML_ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; const xmlDec = (s) => String(s).replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENT[e]); const CDX_ALG = { sha1: 'SHA-1', sha256: 'SHA-256', sha512: 'SHA-512', md5: 'MD5' }; const purlMaven = (g, n, v) => `pkg:maven/${encodeURIComponent(g)}/${encodeURIComponent(n)}@${encodeURIComponent(v)}`; // B-22 (2026-09-30): fisierul e al arborelui ATESTAT, deci al celui care atesta, si il citeste verificatorul. Prima forma a lui 1.5.0 il // citea cu expresii regulate care costau PATRATIC pe forme facute anume (masurat: un tag de 80 KB fara `=` -> 6,9 s, un settings.gradle // cu 80.000 de randuri goale -> 5 s; la 1 MB, zeci de minute). Cititorul de mai jos trece o SINGURA data prin text (indexOf, fara // intoarceri): taguri, atribute cu ghilimele duble, text, comentarii si sarite; orice altceva (DOCTYPE, CDATA, `<` intr-un tag, // un tag sau un comentariu neinchis, un atribut fara ghilimele, taguri incrucisate) e REFUZAT cu motivul, nu ghicit. const NUME_XML = /^[A-Za-z_][\w.:-]*$/; function atributeXml(s, unde) { const a = {}; let i = 0; const n = s.length; const alb = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r'; while (i < n) { while (i < n && alb(s[i])) i++; if (i >= n) break; let j = i; while (j < n && !alb(s[j]) && s[j] !== '=') j++; const nume = s.slice(i, j); if (!NUME_XML.test(nume)) throw new Error(`${unde}: an attribute name that is not a name`); while (j < n && alb(s[j])) j++; if (s[j] !== '=') throw new Error(`${unde}: attribute ${nume} has no value`); j++; while (j < n && alb(s[j])) j++; if (s[j] !== '"') throw new Error(`${unde}: attribute ${nume} is not in double quotes`); const f = s.indexOf('"', j + 1); if (f === -1) throw new Error(`${unde}: attribute ${nume} is not closed`); a[nume] = xmlDec(s.slice(j + 1, f)); i = f + 1; } return a; } function xmlTokeni(x, unde) { const out = []; let i = 0; const n = x.length; while (i < n) { const lt = x.indexOf('<', i); if (lt === -1) { out.push({ text: x.slice(i) }); break; } if (lt > i) out.push({ text: x.slice(i, lt) }); if (x.startsWith('', lt + 4); if (e === -1) throw new Error(`${unde}: a comment is not closed`); i = e + 3; continue; } if (x.startsWith('', lt + 2); if (e === -1) throw new Error(`${unde}: a declaration is not closed`); i = e + 2; continue; } if (x.startsWith('` intr-o valoare nu inchide tagul); fiecare caracter vazut o data let j = lt + 1; for (;;) { if (j >= n) throw new Error(`${unde}: a tag is not closed`); const c = x[j]; if (c === '>') break; if (c === '<') throw new Error(`${unde}: "<" inside a tag`); if (c === '"') { const f = x.indexOf('"', j + 1); if (f === -1) throw new Error(`${unde}: a quoted value is not closed`); j = f + 1; continue; } j++; } let corp = x.slice(lt + 1, j); if (corp[0] === '/') { const nume = corp.slice(1).trim(); if (!NUME_XML.test(nume)) throw new Error(`${unde}: a closing tag that is not a name`); out.push({ close: nume }); } else { const self = corp.endsWith('/'); if (self) corp = corp.slice(0, -1); let k = 0; while (k < corp.length && !' \t\n\r'.includes(corp[k])) k++; const nume = corp.slice(0, k); if (!NUME_XML.test(nume)) throw new Error(`${unde}: a tag whose name is not a name`); out.push({ open: nume, attrs: atributeXml(corp.slice(k), unde), self }); } i = j + 1; } return out; } /** Citirea lui gradle/verification-metadata.xml: configuratia (verify-metadata, verify-signatures, regulile de incredere) si componentele. */ export function citesteVerificationMetadata(text) { const U = 'gradle/verification-metadata.xml'; const tok = xmlTokeni(String(text || ''), U); const stiva = []; let radacina = false; let verMeta = null, verSemn = null, reguliIncredere = 0; const coord = new Map(); let comp = null, art = null; for (const t of tok) { const sus = stiva[stiva.length - 1]; if (t.text != null) { const v = t.text.trim(); if (!v) continue; if (sus === 'verify-metadata' && stiva[stiva.length - 2] === 'configuration') verMeta = v; else if (sus === 'verify-signatures' && stiva[stiva.length - 2] === 'configuration') verSemn = v; continue; } if (t.open) { if (!stiva.length) { if (radacina) throw new Error(`${U}: a second root element <${t.open}>`); if (t.open !== 'verification-metadata') throw new Error(`${U} has no element`); radacina = true; } if (t.open === 'trust' && stiva.includes('configuration')) reguliIncredere++; if (t.open === 'component') { if (sus !== 'components') throw new Error(`${U}: a outside `); const a = t.attrs; if (!a.group || !a.name || !a.version) throw new Error(`${U}: a component without group, name or version`); const k = `${a.group}:${a.name}:${a.version}`; if (coord.has(k)) throw new Error(`${U}: ${k} twice`); comp = { group: a.group, name: a.name, version: a.version, arte: [], configuratii: null }; coord.set(k, comp); } else if (t.open === 'artifact') { if (sus !== 'component') throw new Error(`${U}: an outside a `); if (!t.attrs.name) throw new Error(`${U}: an artifact of ${comp.group}:${comp.name} has no name`); art = { nume: t.attrs.name, valori: [], principale: {} }; comp.arte.push(art); } else if (['sha1', 'sha256', 'sha512', 'md5', 'pgp'].includes(t.open) && sus === 'artifact') { const v = String(t.attrs.value || '').toLowerCase(); if (v) { art.valori.push(`${t.open}:${v}`); if (t.open !== 'pgp') art.principale[t.open] = v; } } else if (t.open === 'also-trust' && ['sha1', 'sha256', 'sha512', 'md5'].includes(sus) && stiva[stiva.length - 2] === 'artifact') { const v = String(t.attrs.value || '').toLowerCase(); if (v) art.valori.push(`also-trust:${v}`); } if (!t.self) stiva.push(t.open); continue; } const scos = stiva.pop(); if (scos !== t.close) throw new Error(`${U}: closes <${scos || 'nothing'}>`); if (scos === 'artifact') art = null; else if (scos === 'component') comp = null; } if (!radacina) throw new Error(`${U} has no element`); if (stiva.length) throw new Error(`${U}: <${stiva[stiva.length - 1]}> is not closed`); for (const c of coord.values()) for (const a of c.arte) a.linie = `${a.nume} ${a.valori.sort().join(' ')}`; return { verMeta, verSemn, reguliIncredere, coord }; } export function sbomGradle({ verificationMetadata, lockfile = null, projectName, version }) { if (!projectName) throw new Error('an SBOM for a Gradle project needs the project name (rootProject.name in settings.gradle)'); if (!version) throw new Error('an SBOM for a Gradle project needs --version'); const { verMeta, verSemn, reguliIncredere, coord } = citesteVerificationMetadata(verificationMetadata); let blocate = 0; if (lockfile != null) { String(lockfile).replace(/\r/g, '').split('\n').forEach((l, i) => { const t = l.trim(); if (!t || t.startsWith('#')) return; const m = /^([^:=\s]+):([^:=\s]+):([^:=\s]+)=([\w,.-]*)$/.exec(t); if (!m) { if (/^empty=[\w,.-]*$/.test(t)) return; throw new Error(`gradle.lockfile line ${i + 1} is not "::="`); } const k = `${m[1]}:${m[2]}:${m[3]}`; const c = coord.get(k) || { group: m[1], name: m[2], version: m[3], arte: [], configuratii: null }; c.configuratii = m[4].split(',').filter(Boolean).sort(); coord.set(k, c); blocate++; }); } const components = [...coord.values()].map((c) => { const purl = purlMaven(c.group, c.name, c.version); const princ = c.arte.find((ar) => new RegExp(`^${c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-${c.version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(jar|aar|klib|war)$`).test(ar.nume)); const hashes = princ ? Object.entries(princ.principale).map(([alg, content]) => ({ alg: CDX_ALG[alg], content })).sort((p, q) => (p.alg < q.alg ? -1 : 1)) : []; const properties = [ ...c.arte.map((ar) => ({ name: 'aere:gradle-artifact', value: ar.linie })), ...(lockfile != null ? [{ name: 'aere:gradle-configurations', value: (c.configuratii || []).join(',') }] : []), ...(!c.arte.length ? [{ name: 'aere:gradle-no-checksum', value: 'true' }] : []), ].sort((p, q) => (p.name + p.value < q.name + q.value ? -1 : 1)); return { type: 'library', 'bom-ref': purl, group: c.group, name: c.name, version: c.version, purl, ...(hashes.length ? { hashes } : {}), properties }; }).sort((a, b) => (a.purl < b.purl ? -1 : a.purl > b.purl ? 1 : 0)); const root = `gradle-project:${projectName}@${version}`; const metadata = { component: { type: 'application', 'bom-ref': root, name: projectName, version }, tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] }, properties: propr({ 'aere:derived-from': 'gradle/verification-metadata.xml', 'aere:gradle-lockfile': lockfile != null ? 'gradle.lockfile' : 'absent', 'aere:gradle-verify-metadata': verMeta, 'aere:gradle-verify-signatures': verSemn, 'aere:gradle-trust-rules': reguliIncredere, 'aere:gradle-locked-without-checksum': lockfile != null ? components.filter((c) => c.properties.some((p) => p.name === 'aere:gradle-no-checksum')).length : null, 'aere:dependency-graph': 'absent: neither gradle/verification-metadata.xml nor gradle.lockfile records it' }), }; return { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber: serieDerivata({ metadata, components, blocate }), version: 1, metadata, components, dependencies: [] }; } export const RADACINA_GRADLE = /^[ \t]*rootProject\.name[ \t]*=[ \t]*['"]([^'"\r\n]+)['"][ \t]*\r?$/m; /** SBOM-ul Gradle al arborelui COMIS :: gradle/verification-metadata.xml, gradle.lockfile (daca e), rootProject.name. */ export function sbomGradleFromTree(repoDir, commit, rel, version) { const vm = blobDin(repoDir, commit, `${rel}/gradle/verification-metadata.xml`); if (vm == null) return { lipsa: 'the committed tree has no gradle/verification-metadata.xml, so the SBOM cannot be re-derived from it' }; const setari = blobDin(repoDir, commit, `${rel}/settings.gradle`) ?? blobDin(repoDir, commit, `${rel}/settings.gradle.kts`); // B-22: `^\s*` cu /m trece peste randuri goale si se reia de la fiecare rand (patratic: 80.000 de randuri goale -> 5 s); spatiile // se cauta numai in rand const nm = setari && RADACINA_GRADLE.exec(setari); if (!nm) return { lipsa: 'the committed settings.gradle(.kts) sets no rootProject.name, which names the SBOM root' }; try { return { bom: sbomGradle({ verificationMetadata: vm, lockfile: blobDin(repoDir, commit, `${rel}/gradle.lockfile`), projectName: nm[1], version }) }; } catch (e) { return { lipsa: 'the committed Gradle verification metadata cannot be read: ' + String(e.message || e).slice(0, 120) }; } } function sbomRebuildCheck(att, repoDir, sbomPath, ok, checks) { const s = att.statement.source; if (!att.statement.sbom) return; if (!sbomPath) { checks.push({ name: 'rebuild: SBOM', pass: null, detail: 'the attested SBOM was not handed to verify, so it was not compared with the committed lockfile' }); return; } let atestat; try { atestat = JSON.parse(fs.readFileSync(sbomPath, 'utf8')); } catch { return ok('rebuild: the attested SBOM parses', false, 'not JSON'); } // 1.4.0: un SBOM scris de `sbom-go` se re-deriva din go.sum/go.mod comise; 1.5.0: unul scris de `sbom-nuget` din packages.lock.json, // unul scris de `sbom-gradle` din gradle/verification-metadata.xml (+ gradle.lockfile); oricare altul, din package-lock.json (npm sbom) // 1.5.0 (B-21, 2026-09-30): pana la 1.4.0 felul il alegea SBOM-ul atestat, deci un SBOM npm editat de mana care spunea // `aere:derived-from: go.sum` scapa de judecata (NEJUDECAT, verdict VALID, cu motivul "arborele nu are go.mod"), la fel un SBOM Go // editat caruia i se scotea proprietatea (judecat ca npm, "arborele nu are package-lock.json"). Acum proprietatea e o afirmatie // despre ARBORE: un fisier numit care nu e in arborele comis e o afirmatie FALSA; iar un SBOM care nu isi numeste fisierul, intr-un // arbore fara package-lock.json, ramane nejudecat, dar motivul numeste fisierele din care arborele se POATE judeca. const din = derivatDin(atestat), ver = att.statement.subject && att.statement.subject.version; const inArbore = (f) => { try { execFileSync('git', ['cat-file', '-e', `${s.commit}:${s.path}/${f}`], { cwd: repoDir, stdio: 'ignore' }); return true; } catch { return false; } }; const UNEALTA = { 'go.sum': 'sbom-go', 'packages.lock.json': 'sbom-nuget', 'gradle/verification-metadata.xml': 'sbom-gradle', 'package-lock.json': 'npm sbom' }; const prezente = Object.keys(UNEALTA).filter(inArbore); if (din && !inArbore(din)) { return ok('rebuild: the file the attested SBOM says it was derived from is in the committed tree', false, `the SBOM says it was derived from ${din}, which ${s.commit.slice(0, 12)}:${s.path} does not have${prezente.length ? `; the tree has ${prezente.join(', ')}` : ''}`); } // un SBOM fara fisier numit (npm sbom, alte unelte) sau cu unul pe care verificatorul nu il stie se judeca fata de package-lock.json, // ca pana acum; fara package-lock.json, dar cu alt fisier cunoscut in arbore, ramane nejudecat si motivul spune din ce se putea judeca // (un nume necunoscut nu poate scoate un arbore npm de sub judecata: altfel oricare fisier al arborelui, numit, ar fi o scapare) const FEL = { 'go.sum': 'go', 'packages.lock.json': 'nuget', 'gradle/verification-metadata.xml': 'gradle' }; const fel = typeof din === 'string' && Object.hasOwn(FEL, din) ? FEL[din] : 'npm'; if (fel === 'npm' && !prezente.includes('package-lock.json') && prezente.length) { const cum = din ? `says it was derived from ${din}, which this verifier does not re-derive,` : 'names no lockfile (npm sbom and other tools do not)'; checks.push({ name: 'rebuild: SBOM', pass: null, detail: `NOT JUDGED although the tree can be: the attested SBOM ${cum} and the committed tree has no package-lock.json; it has ${prezente.join(', ')}, and an SBOM made from it with pos.mjs ${prezente.map((f) => UNEALTA[f]).join(' / ')} is judged here` }); return; } const r = fel === 'go' ? sbomGoFromTree(repoDir, s.commit, s.path, ver) : fel === 'nuget' ? sbomNugetFromTree(repoDir, s.commit, s.path, ver) : fel === 'gradle' ? sbomGradleFromTree(repoDir, s.commit, s.path, ver) : sbomNpmFromTree(repoDir, `${s.commit}:${s.path}`); if (r.lipsa) { checks.push({ name: 'rebuild: SBOM', pass: null, detail: r.lipsa }); return; } const a = sbomSemantica(atestat), b = sbomSemantica(r.bom); const aceleasi = JSON.stringify(a) === JSON.stringify(b); let detaliu = ''; if (!aceleasi) { const pa = new Set(a.componente.map((c) => JSON.stringify(c))), pb = new Set(b.componente.map((c) => JSON.stringify(c))); const doarA = [...pa].filter((x) => !pb.has(x)).length, doarB = [...pb].filter((x) => !pa.has(x)).length; detaliu = `${doarA} component(s) only in the attested SBOM, ${doarB} only in the lockfile; graph ${JSON.stringify(a.dependente) === JSON.stringify(b.dependente) ? 'same' : 'differs'}; root ${a.radacina === b.radacina ? 'same' : 'differs'}`; } const spune = { go: 'go.sum pins', nuget: 'packages.lock.json resolves', gradle: 'Gradle verification metadata pins' }[fel] || 'lockfile says'; ok(`rebuild: the attested SBOM says what the committed ${spune} (${b.componente.length} components)`, aceleasi, detaliu); } // Rebuild check: from a clone the VERIFIER chose, take the attested commit, require that : is the attested // tree, pack that tree again and require the attested artifact's digest. It proves the artifact is what the source // produces; a statement without source.tree or without build.kind=npm-pack cannot be rebuilt and says so. function rebuildChecks(att, repoDir, ok, note, sbomPath = null, checks = []) { const s = att.statement.source, b = att.statement.build; if (!s || !s.tree || !s.path) return ok('rebuild: the statement names a source tree', false, 'attested without --source-path'); const tree = git(repoDir, ['rev-parse', '--verify', '--quiet', `${s.commit}:${s.path}`]); if (!ok(`source: ${s.commit.slice(0, 12)}:${s.path} is the attested tree ${s.tree.slice(0, 12)}`, tree === s.tree, tree ? `this clone has ${tree.slice(0, 12)}` : 'commit or path not in this clone')) return false; // 1.4.0: o atestare fara `npm pack` (un modul Go, un binar construit altfel) nu se poate reconstrui aici, si asta e ABSENT, nu fals; // arborele si SBOM-ul se judeca oricum (pana la 1.3.0 raspunsul era "fals" si SBOM-ul nu se mai judeca deloc) if (!b || b.kind !== 'npm-pack') { checks.push({ name: 'rebuild: artifacts', pass: null, detail: `this tool repeats only npm pack; build ${b ? b.kind : 'not recorded'} was not repeated` }); sbomRebuildCheck(att, repoDir, sbomPath, ok, checks); return true; } const out = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-rebuild-')); try { const tgz = packNpmFromTree(repoDir, `${s.commit}:${s.path}`, out); const a = att.statement.artifacts.find((x) => x.name === path.basename(tgz)); if (!a) return ok(`rebuild: ${path.basename(tgz)} is an attested artifact`, false, 'the rebuilt file name is not in the statement'); const h = sha256File(tgz); const npmNow = npmVersion(); const same = ok(`rebuild: npm pack of the attested tree reproduces ${a.name} byte for byte`, h === a.sha256, h === a.sha256 ? '' : `got ${h} with npm ${npmNow}, attested with npm ${b.npm}`); if (same && npmNow !== b.npm) note(`rebuild: reproduced with npm ${npmNow} (attested with npm ${b.npm})`); sbomRebuildCheck(att, repoDir, sbomPath, ok, checks); return same; } catch (e) { return ok('rebuild: npm pack of the attested tree ran', false, String(e.message || e).slice(0, 120)); } finally { fs.rmSync(out, { recursive: true, force: true }); } } export async function verify(att, files, { cloud = null, rebuildFrom = null, base = null, trustIssuer = null, revocations = [], signer = null } = {}) { let chainTime = null; const checks = []; const ok = (name, pass, detail) => { checks.push({ name, pass, detail: detail || '' }); return pass; }; if (!att || att.kind !== 'aere-proof-of-software-attestation' || !att.statement) return { valid: false, checks: [{ name: 'shape', pass: false, detail: 'not an attestation' }] }; const statementJson = JSON.stringify(att.statement); ok('statementHash = sha256(statement text)', sha256Text(statementJson) === att.statementHash, att.statementHash); const relativ = att.statement.artifactNames === 'relative-path'; if (relativ && !base) ok('artifact names', false, 'this attestation names artifacts by relative path: pass --base '); // un fisier din afara lui --base (de ex. SBOM-ul langa dosarul modelului) se potriveste pe bazenume, ca in forma veche const cheie = (p) => { if (!(relativ && base)) return path.basename(p); try { return numeArtefact(p, base); } catch { return path.basename(p); } }; const byName = new Map(files.map((p) => [cheie(p), p])); for (const a of att.statement.artifacts) { const p = byName.get(a.name); if (!p) { ok(`artifact ${a.name}: present`, false, 'not given to verify'); continue; } const h = sha256File(p), b = fs.statSync(p).size; ok(`artifact ${a.name}: sha256 and size match`, h === a.sha256 && b === a.bytes, h === a.sha256 ? '' : `got ${h}`); } if (att.statement.sbom) { const p = byName.get(att.statement.sbom.name); if (p) ok(`sbom ${att.statement.sbom.name}: sha256 matches`, sha256File(p) === att.statement.sbom.sha256); else checks.push({ name: `sbom ${att.statement.sbom.name}`, pass: null, detail: 'not given to verify (absent, not wrong)' }); } // 2026-09-29 (B-18): o semnatura valida spune ca NISTE chei au semnat, nu CARE. Masurat pe 1.4.0: un strain isi semna cu cheile lui // declaratia despre artefactul lui (acelasi nume si versiune) si verificarea spunea VALID fara sa numeasca semnatarul. Acum numele // verificarii poarta amprenta cheilor, `signer` cere cheile asteptate, iar fara el (si fara o acreditare judecata) se spune nejudecat. const semnatar = att.signature ? keyId(publicKeysOf(att.signature)) : null; if (att.signature) { const r = pq.verify(statementJson, att.signature); ok(`hybrid signature ${att.signature.alg} by keys ${semnatar ? semnatar.slice(0, 18) : '?'}: both halves verify over the statement text`, r.valid, r.reason || ''); } else checks.push({ name: 'hybrid signature', pass: null, detail: 'absent' }); if (signer) ok('signer: the statement is signed by the keys you gave', !!semnatar && semnatar === keyId(publicKeysOf(signer)), semnatar ? `signed by keys ${semnatar.slice(0, 18)}` : 'the statement is not signed'); else if (!(att.statement.builder && att.statement.builder.credential && trustIssuer)) checks.push({ name: 'signer', pass: null, detail: `not judged: anyone can sign a statement with their own keys${semnatar ? ` (these are ${semnatar.slice(0, 18)})` : ''}; pass --signer , or --trust-issuer with a developer credential, to require who signed` }); if (att.notarization) { if (cloud) { try { const p = await cloud.proof(att.statementHash); const notarizat = ok('on chain: statementHash is notarized', p.notarized === true); const acelasi = ok(`on chain: first appearance matches the receipt (block ${att.notarization.block})`, p.block === att.notarization.block && p.txHash === att.notarization.txHash, `${p.block} ${p.txHash}`); // the chain's first-seen time (read now from the chain, not from the builder's receipt) judges the credential's validity, // not the builder's own createdAt; firstSeenAt is unix seconds if (notarizat && acelasi && Number(p.firstSeenAt) > 0) chainTime = new Date(Number(p.firstSeenAt) * 1000).toISOString(); checks.push({ name: `on chain: finality ${p.finality}${p.pqAnchor ? ' (anchor ' + p.pqAnchor.height + ', ' + p.pqAnchor.falconSeals + ' Falcon + ' + p.pqAnchor.slhDsaSeals + ' SLH-DSA seals)' : ''}`, pass: p.finality === 'post-quantum' ? true : null, detail: p.finality === 'post-quantum' ? '' : 'not yet covered by an anchor; ask again later' }); } catch (e) { ok('on chain: proof readable', false, String(e.message || e).slice(0, 120)); } } else checks.push({ name: 'on chain', pass: null, detail: 'receipt present; pass a Cloud key to read the proof' }); } else checks.push({ name: 'on chain', pass: null, detail: 'not notarized' }); credentialChecks(att, { trustIssuer, revocations, chainTime }, ok, checks); if (rebuildFrom) rebuildChecks(att, rebuildFrom, ok, (name) => checks.push({ name, pass: null, detail: '' }), att.statement.sbom ? byName.get(att.statement.sbom.name) || null : null, checks); else if (att.statement.source && att.statement.source.tree) checks.push({ name: 'rebuild', pass: null, detail: `not attempted; pass --rebuild-from to repeat the build of tree ${att.statement.source.tree.slice(0, 12)}` }); return { valid: checks.every((c) => c.pass !== false), checks }; } // ---------------------------------------------------------------- AI provenance: ML-BOM CycloneDX 1.6 // Fisierele modelului se listeaza RECURSIV (fara dosare ascunse), sortate dupa calea relativa; fiecare cu SHA-256 si marime. // Digestul manifestului = sha256 al randurilor "\t\t\n" in ordinea sortata: un singur numar care se // schimba la orice fisier adaugat, scos sau atins. Arhitectura se citeste din config.json (architectures, model_type) daca exista; // nimic nu se ghiceste. export function listaModelului(dir) { const out = []; const umbla = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { if (e.name.startsWith('.')) continue; const p = path.join(d, e.name); if (e.isDirectory()) umbla(p); else if (e.isFile()) out.push(p); } }; umbla(dir); return out.sort((a, b) => { const x = numeArtefact(a, dir), y = numeArtefact(b, dir); return x < y ? -1 : x > y ? 1 : 0; }); } export function modelBom({ dir, name, version, task, datasets = [], now = new Date(), uuid = crypto.randomUUID() }) { const files = listaModelului(dir); if (!files.length) throw new Error(`no files in ${dir}`); const randuri = files.map((p) => ({ rel: numeArtefact(p, dir), sha: sha256File(p), bytes: fs.statSync(p).size })); const manifestSha256 = sha256Text(randuri.map((r) => `${r.rel}\t${r.sha}\t${r.bytes}\n`).join('')); let cfg = null; const cfgPath = path.join(dir, 'config.json'); if (fs.existsSync(cfgPath)) { try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { cfg = null; } } const hex = (s) => s.replace(/^0x/, ''); const date = datasets.map((d) => { const i = d.indexOf('='); if (i < 1) throw new Error(`--dataset takes NAME=FILE, got "${d}"`); const nume = d.slice(0, i), fis = d.slice(i + 1); if (!fs.existsSync(fis)) throw new Error(`dataset file not found: ${fis}`); return { type: 'data', 'bom-ref': 'data:' + nume, name: nume, hashes: [{ alg: 'SHA-256', content: hex(sha256File(fis)) }], data: [{ type: 'dataset', name: nume, contents: { attachment: { contentType: 'application/octet-stream', content: path.basename(fis) } } }] }; }); const modelParameters = {}; if (task) modelParameters.task = task; if (cfg && typeof cfg.model_type === 'string') modelParameters.architectureFamily = cfg.model_type; if (cfg && Array.isArray(cfg.architectures) && typeof cfg.architectures[0] === 'string') modelParameters.modelArchitecture = cfg.architectures[0]; if (date.length) modelParameters.datasets = date.map((d) => ({ ref: d['bom-ref'] })); const model = { type: 'machine-learning-model', 'bom-ref': 'model', name: name || path.basename(path.resolve(dir)), ...(version ? { version } : {}), properties: [{ name: 'aere:manifestSha256', value: manifestSha256 }, { name: 'aere:manifestRule', value: 'sha256 of lines "\\t\\t\\n" sorted by path' }], modelCard: { modelParameters }, }; const componente = randuri.map((r) => ({ type: 'file', 'bom-ref': 'file:' + r.rel, name: r.rel, hashes: [{ alg: 'SHA-256', content: hex(r.sha) }], properties: [{ name: 'aere:bytes', value: String(r.bytes) }] })); const bom = { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber: 'urn:uuid:' + uuid, version: 1, metadata: { timestamp: now.toISOString(), tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] }, component: model }, components: [...componente, ...date], dependencies: [{ ref: 'model', dependsOn: [...componente.map((c) => c['bom-ref']), ...date.map((d) => d['bom-ref'])] }], }; return { bom, files, manifestSha256 }; } // ---------------------------------------------------------------- CLI function arg(args, name, dflt) { const i = args.indexOf(name); if (i < 0) return dflt; const v = args[i + 1]; args.splice(i, 2); return v; } function flag(args, name) { const i = args.indexOf(name); if (i < 0) return false; args.splice(i, 1); return true; } function cloudFrom(file) { if (!file) return null; return new AereCloud({ apiKey: fs.readFileSync(file, 'utf8').trim() }); } async function main() { const args = process.argv.slice(2); const cmd = args.shift(); if (cmd === 'pack-npm') { const sourcePath = arg(args, '--source-path'); const outDir = arg(args, '--out-dir', '.'); const commit = arg(args, '--commit', 'HEAD'); if (!sourcePath) throw new Error('pack-npm needs --source-path '); const top = git(process.cwd(), ['rev-parse', '--show-toplevel']); if (!top) throw new Error('pack-npm needs a git checkout'); const rel = path.relative(path.resolve(top), path.resolve(sourcePath)).split(path.sep).join('/'); console.log(packNpmFromTree(process.cwd(), `${commit}:${rel}`, outDir)); return; } if (cmd === 'keygen') { const out = arg(args, '--out', 'keys.json'); const alg = arg(args, '--alg', 'secp256k1+ml-dsa-65'); fs.writeFileSync(out, JSON.stringify(pq.generateKeyPair({ alg }), null, 1), { mode: 0o600 }); console.log(`keys written to ${out} (${alg}); keep it secret, publish only the public keys`); return; } if (cmd === 'pubkey') { // the public part of a keys file, to hand out (an issuer's trust root, a developer's credential subject) const keysFile = arg(args, '--keys'); const out = arg(args, '--out'); const pub = publicKeysOf(JSON.parse(fs.readFileSync(keysFile, 'utf8'))); if (!pub) throw new Error('not a keys file'); const s = JSON.stringify(pub, null, 1) + '\n'; if (out) fs.writeFileSync(out, s); else process.stdout.write(s); return; } if (cmd === 'credential') { // credential issue | revoke, signed with the ISSUER's keys (the organization's, not the developer's) const sub = args.shift(); const issuerKeys = JSON.parse(fs.readFileSync(arg(args, '--issuer-keys'), 'utf8')); const out = arg(args, '--out', 'credential.json'); if (sub === 'issue') { const days = Number(arg(args, '--valid-days', '365')); const from = arg(args, '--valid-from', new Date().toISOString()); const cred = issueCredential({ issuerKeys, issuerName: arg(args, '--issuer-name'), subjectKeys: JSON.parse(fs.readFileSync(arg(args, '--subject-pub'), 'utf8')), subjectName: arg(args, '--subject-name'), validFrom: from, validUntil: new Date(Date.parse(from) + days * 86400e3).toISOString() }); fs.writeFileSync(out, JSON.stringify(cred, null, 1)); console.log(`credential for "${cred.body.subject.name}" written to ${out}: ${credentialHash(cred)}`); return; } if (sub === 'revoke') { const rv = revokeCredential({ issuerKeys, credential: JSON.parse(fs.readFileSync(arg(args, '--credential'), 'utf8')), revokedAt: arg(args, '--at', new Date().toISOString()), reason: arg(args, '--reason', '') }); fs.writeFileSync(out, JSON.stringify(rv, null, 1)); console.log(`revocation written to ${out} (from ${rv.body.revokedAt})`); return; } throw new Error('credential issue|revoke ...'); } if (cmd === 'attest') { const out = arg(args, '--out', 'attestation.json'); const name = arg(args, '--name'); const version = arg(args, '--version'); const sbom = arg(args, '--sbom'); const keysFile = arg(args, '--keys'); const cloudKey = arg(args, '--cloud-key-file'); const sourcePath = arg(args, '--source-path'); const build = arg(args, '--build'); const base = arg(args, '--base'); const credFile = arg(args, '--credential'); const keys = keysFile ? JSON.parse(fs.readFileSync(keysFile, 'utf8')) : null; const credential = credFile ? JSON.parse(fs.readFileSync(credFile, 'utf8')) : null; const att = await attest({ artifacts: args, sbom, name, version, sourcePath, build, base, keys, credential, cloud: cloudFrom(cloudKey) }); fs.writeFileSync(out, JSON.stringify(att, null, 1)); console.log(`attestation written to ${out}: ${att.statement.artifacts.length} artifact(s), sbom ${att.statement.sbom ? 'yes' : 'no'}, signature ${att.signature ? att.signature.alg : 'none'}, notarized ${att.notarization ? 'block ' + att.notarization.block : 'no'}`); console.log(`statementHash ${att.statementHash}`); return; } if (cmd === 'verify') { const cloudKey = arg(args, '--cloud-key-file'); const rebuildFrom = arg(args, '--rebuild-from'); const base = arg(args, '--base'); const trustFile = arg(args, '--trust-issuer'); const revFiles = arg(args, '--revocations'); const signerFile = arg(args, '--signer'); const file = args.shift(); const att = JSON.parse(fs.readFileSync(file, 'utf8')); const trustIssuer = trustFile ? JSON.parse(fs.readFileSync(trustFile, 'utf8')) : null; const revocations = revFiles ? revFiles.split(',').map((f) => JSON.parse(fs.readFileSync(f, 'utf8'))) : []; const signer = signerFile ? JSON.parse(fs.readFileSync(signerFile, 'utf8')) : null; const r = await verify(att, args, { cloud: cloudFrom(cloudKey), rebuildFrom, base, trustIssuer, revocations, signer }); for (const c of r.checks) console.log(` ${c.pass === true ? 'OK ' : c.pass === false ? 'FAIL' : '-- '} ${c.name}${c.detail ? ' (' + c.detail + ')' : ''}`); const nejudecate = r.checks.filter((c) => c.pass === null).length; console.log(r.valid ? `VALID: every present claim holds${nejudecate ? `; ${nejudecate} not judged (the -- lines)` : ''}` : 'INVALID'); process.exitCode = r.valid ? 0 : 1; return; } if (cmd === 'sbom-go') { // SBOM-ul Go din arborele COMIS (ca pack-npm): go.mod si go.sum de la :, deci exact ce re-deriva verify --rebuild-from const src = arg(args, '--source-path'); const out = arg(args, '--out', 'sbom.cdx.json'); const version = arg(args, '--version'); const commit = arg(args, '--commit', 'HEAD'); if (!src) throw new Error('sbom-go needs --source-path '); const top = git(process.cwd(), ['rev-parse', '--show-toplevel']); if (!top) throw new Error('sbom-go needs a git checkout'); const rel = path.relative(path.resolve(top), path.resolve(src)).split(path.sep).join('/'); if (!rel || rel.startsWith('..')) throw new Error(`--source-path ${src}: must be a directory below the repository root`); const r = sbomGoFromTree(top, git(top, ['rev-parse', commit]), rel, version); if (r.lipsa) throw new Error(r.lipsa); fs.writeFileSync(out, JSON.stringify(r.bom, null, 1) + '\n'); console.log(`SBOM written to ${out}: ${r.bom.components.length} module(s) pinned in go.sum, ${r.bom.metadata.properties[1].value} pinned by go.mod only (not listed)`); return; } if (cmd === 'sbom-nuget' || cmd === 'sbom-gradle') { // 1.5.0: din arborele COMIS, ca sbom-go: exact ce re-deriva verify --rebuild-from const src = arg(args, '--source-path'); const out = arg(args, '--out', 'sbom.cdx.json'); const version = arg(args, '--version'); const commit = arg(args, '--commit', 'HEAD'); if (!src) throw new Error(`${cmd} needs --source-path `); const top = git(process.cwd(), ['rev-parse', '--show-toplevel']); if (!top) throw new Error(`${cmd} needs a git checkout`); const rel = path.relative(path.resolve(top), path.resolve(src)).split(path.sep).join('/'); if (!rel || rel.startsWith('..')) throw new Error(`--source-path ${src}: must be a directory below the repository root`); const r = (cmd === 'sbom-nuget' ? sbomNugetFromTree : sbomGradleFromTree)(top, git(top, ['rev-parse', commit]), rel, version); if (r.lipsa) throw new Error(r.lipsa); fs.writeFileSync(out, JSON.stringify(r.bom, null, 1) + '\n'); const cu = r.bom.components.filter((c) => c.purl).length; console.log(`SBOM written to ${out}: ${cu} package(s)${cmd === 'sbom-nuget' ? `, ${r.bom.components.length - cu} project reference(s), ${r.bom.dependencies.length} dependency record(s)` : ', no dependency graph (the Gradle lock files do not record it)'}`); return; } if (cmd === 'model-bom') { const dir = arg(args, '--model-dir'); const out = arg(args, '--out', 'mlbom.json'); const name = arg(args, '--name'); const version = arg(args, '--version'); const task = arg(args, '--task'); const datasets = []; for (let d; (d = arg(args, '--dataset')) !== undefined;) datasets.push(d); if (!dir) throw new Error('model-bom needs --model-dir '); const r = modelBom({ dir, name, version, task, datasets }); fs.writeFileSync(out, JSON.stringify(r.bom, null, 1) + '\n'); console.log(`ML-BOM written to ${out}: ${r.files.length} file(s), ${datasets.length} dataset(s), manifest ${r.manifestSha256}`); console.log('attest it with: node pos.mjs attest --base ' + dir + ' --sbom ' + out + ' --out attestation.json '); return; } console.log('usage: pos.mjs keygen|pack-npm|sbom-go|sbom-nuget|sbom-gradle|attest|verify|model-bom ... (see README.md)'); process.exitCode = 2; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((e) => { console.error('error:', e.message || e); process.exitCode = 1; });