#!/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 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.4.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(); const comp = (c) => ({ purl: c.purl || `${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 esteSbomGo = (bom) => !!(bom && bom.metadata && (bom.metadata.properties || []).some((x) => x.name === 'aere:derived-from' && x.value === 'go.sum')); 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; oricare altul, din package-lock.json (npm sbom) const go = esteSbomGo(atestat); const r = go ? sbomGoFromTree(repoDir, s.commit, s.path, att.statement.subject && att.statement.subject.version) : 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'}`; } ok(`rebuild: the attested SBOM says what the committed ${go ? 'go.sum pins' : 'lockfile says'} (${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 === '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|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; });