aere-proof-of-software/tools/proof-of-software/pos.mjs
Aere Network 065f84e34a Aere Proof of Software 1.4.0: an attestation of what was built, from what, by whom and when, verifiable without trusting Aere Network
Every artifact's SHA-256 and size, the SBOM's digest, the git commit and tree of the source, a hybrid signature (classical + ML-DSA,
both required) and, when notarized, a first-seen time on Aere Network covered by the validators' post-quantum certificate. verify
recomputes everything from the files; --rebuild-from repeats an npm pack build from a clone the verifier chose; the SBOM is
re-derived from the committed package-lock.json or, new in 1.4.0, from the committed go.mod and go.sum; --signer requires the
signing keys you expect (1.4.0); a developer credential binds the keys to a person, judged against a trust root you choose. The
builder's declared date can only accuse, never acquit (1.4.0). Includes the GitHub Action and the hybrid signature library it uses.

Laid out as in the development repository (tools/proof-of-software/, sdk-pq-sign/, sdk/) so that nothing is rewritten for
publication. Tests and negative controls measured on 2026-09-29 are listed in README.md.
2026-09-29 22:46:57 +03:00

557 lines
44 KiB
JavaScript

#!/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] <artifact files...>
// 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] <artifact files...>
// 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 <every file of DIR> 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 <treeish>, 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 <issuer public keys> 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 "<module> <version>[/go.mod] h1:<hash>"`);
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 <commit>:<cale> (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 <commit>:<path> 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 <directory>');
// 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 <public keys>, 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 <clone> 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 "<cale>\t<sha256>\t<octeti>\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 "<relative path>\\t<sha256>\\t<bytes>\\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 <package directory>');
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 <commit>:<cale>, 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 <directory of go.mod>');
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 <directory>');
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 <the ' + r.files.length + ' files>');
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; });