aere-proof-of-software/tools/proof-of-software/action/index.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

358 lines
25 KiB
JavaScript

#!/usr/bin/env node
// Aere Proof of Software ca GitHub Action (randul 19 din lista Aere Cloud: integrari native, GitHub e prima).
//
// Ordinea pasilor, si de ce e ordinea asta:
// 1. mastile pentru intrarile secrete (::add-mask::) INAINTEA oricarei alte iesiri
// 2. refuz daca source-path are schimbari necomise sau fisiere neurmarite: se atesta ARBORELE comis, nu copia de lucru
// 3. artefactul = `npm pack` al arborelui comis (pos.mjs packNpmFromTree: octetii din magazia git, fara conversia de la
// checkout; pe un runner Windows cu core.autocrlf=true copia de lucru are CRLF, arborele nu)
// 4. atestarea (pos.mjs attest), cu semnatura hibrida daca exista signing-key
// 5. verificarea prin RECONSTRUCTIE dintr-o clona curata a commitului (pos.mjs verify --rebuild-from); refuz daca nu
// reproduce artefactul octet cu octet
// 6. ABIA APOI, daca exista aere-api-key, notarizarea statementHash: o tranzactie pe 2800 nu se cheltuie pe o atestare
// care nu se reproduce. Se judeca CODUL HTTP (200) si chitanta (txHash, block), nu doar ca fetch a mers; fara
// redirectari urmate (un 30x ar duce antetul x-api-key la alta gazda)
// 7. atestarea se scrie, se reciteste de pe disc si se verifica, apoi iesirile ($GITHUB_OUTPUT) si rezumatul
// Logica atestarii NU e copiata aici: vine din ../pos.mjs, importat dinamic dupa ce dependintele lui (sdk-pq-sign ->
// @noble/*) sunt la locul lor. Acest fisier foloseste numai module node: (nicio dependinta externa).
//
// Niciun secret nu se tipareste: tot ce iese (jurnal, erori, rezumat, iesiri) trece prin Secrete.curata() (valorile secrete
// exacte, fara deosebire de litere mari/mici), iar mesajele BIBLIOTECILOR trec in plus prin taieHex() (orice sir hexa lung).
// Mesajele de parsare ale cheii NU se transmit deloc: JSON.parse din V8 citeaza o bucata din textul pe care cade.
//
// Carlige de proba (AERE_POS_TEST_HOOK, AERE_POS_API_BASE): exista numai pentru test/action.test.mjs. Carligele sunt
// refuzate pe un runner GitHub (GITHUB_ACTIONS=true); baza API poate arata numai spre o adresa loopback, deci cheia API
// nu poate fi trimisa altundeva decat la cloud.aere.network sau pe masina locala.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFileSync, execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const AICI = path.dirname(fileURLToPath(import.meta.url));
const DIR_POS = path.resolve(AICI, '..'); // tools/proof-of-software
const RADACINA = path.resolve(AICI, '..', '..', '..'); // radacina depozitului in care sta actiunea
export const API_IMPLICIT = 'https://cloud.aere.network/v1';
// Aceleasi intrari si iesiri ca in action.yml; test/action.test.mjs cere ca cele doua liste sa fie identice.
export const INTRARI = {
'source-path': { required: true },
build: { default: 'npm-pack' },
'signing-key': { secret: true },
'aere-api-key': { secret: true },
'verify-rebuild': { default: 'true' },
'out-dir': {},
};
export const IESIRI = ['attestation-path', 'artifact-path', 'artifact-sha256', 'tree', 'statement-hash', 'notarized-tx', 'proof-url'];
const CARLIGE = new Set(['alter-after-attest', 'foreign-artifact', 'npm-offline']);
class Refuz extends Error {}
export function intrare(env, nume) {
if (!(nume in INTRARI)) throw new Error(`intrare nedeclarata: ${nume}`);
const v = env[`INPUT_${nume.replace(/ /g, '_').toUpperCase()}`];
return v === undefined || v === '' ? (INTRARI[nume].default ?? '') : v;
}
// ---------------------------------------------------------------- secretele si curatarea textului
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export class Secrete {
constructor() { this.valori = new Set(); this.re = []; }
// intoarce valorile NOI (de mascat la runner); sub 8 caractere nu se inregistreaza nimic (ar masca text obisnuit)
adauga(x) {
if (typeof x !== 'string') return [];
const s = x.trim(); const noi = [];
for (const v of /^0x[0-9a-fA-F]+$/.test(s) ? [s, s.slice(2)] : [s]) {
if (v.length >= 8 && !this.valori.has(v)) { this.valori.add(v); noi.push(v); }
}
if (noi.length) this.re = [...this.valori].sort((a, b) => b.length - a.length).map((v) => new RegExp(escRe(v), 'gi'));
return noi;
}
curata(text) { let t = String(text); for (const r of this.re) t = t.replace(r, '***'); return t; }
}
// orice sir hexa de 32+ caractere dintr-un mesaj de BIBLIOTECA (chei secp256k1/ed25519, seminte, bucati de chei ML-DSA)
export const taieHex = (t) => String(t).replace(/(?:0x)?[0-9a-fA-F]{32,}/g, '[long hex removed]');
// ORDINEA conteaza: intai valorile secrete exacte (sec.curata), abia apoi sirurile hexa. Invers, taieHex ar rupe o valoare
// secreta a carei coada e hexa, si prefixul ei ar scapa de potrivirea exacta.
function mesajBib(e, sec = null) {
const baza = String((e && e.message) || e);
const cauza = e && e.cause && e.cause.message ? ` (${e.cause.message})` : '';
const t = sec ? sec.curata(baza + cauza) : baza + cauza;
return taieHex(t).replace(/\s+/g, ' ').slice(0, 300);
}
// ---------------------------------------------------------------- protocolul runner-ului
const escData = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
// nume=valoare pe un rand; o valoare pe mai multe randuri primeste un delimitator care nu apare in ea
export function linieIesire(nume, valoare) {
const v = String(valoare);
if (!/[\r\n]/.test(v)) return `${nume}=${v}\n`;
let d; do { d = `ghadelimiter_${crypto.randomUUID()}`; } while (v.includes(d));
return `${nume}<<${d}\n${v}\n${d}\n`;
}
function booleanGitHub(v, nume) {
if (/^(true|True|TRUE)$/.test(v)) return true;
if (/^(false|False|FALSE)$/.test(v)) return false;
throw new Refuz(`input ${nume} must be true or false`);
}
// Cheia de semnare: fisierul scris de `pos.mjs keygen` (JSON), sau base64 al lui. Nu tipareste nimic; motivul unui refuz
// nu contine niciodata continutul.
export function citesteCheia(brut) {
const incearca = (t) => { try { const o = JSON.parse(t); return o && typeof o === 'object' ? o : null; } catch { return null; } };
let text = brut; let obj = incearca(text);
if (!obj && !text.startsWith('{')) {
const dec = Buffer.from(text, 'base64').toString('utf8').trim();
if (dec.startsWith('{')) { text = dec; obj = incearca(dec); }
}
if (!obj) return { ok: false, text, motiv: 'signing-key is not valid JSON (nor base64 of JSON); its content is not shown' };
const f = (...k) => k.reduce((a, x) => (a && typeof a === 'object' ? a[x] : undefined), obj);
const lipsa = [['alg'], ['classical', 'secretKey'], ['classical', 'publicKey'], ['pq', 'secretKey'], ['pq', 'publicKey']].some((k) => typeof f(...k) !== 'string');
if (lipsa) return { ok: false, text, obj, motiv: 'signing-key is JSON but not a key file written by `pos.mjs keygen` (alg, classical.secretKey/publicKey, pq.secretKey/publicKey); its content is not shown' };
return { ok: true, text, keys: obj };
}
const frunze = (o, out = []) => { if (typeof o === 'string') out.push(o); else if (o && typeof o === 'object') for (const v of Object.values(o)) frunze(v, out); return out; };
function bazaApi(env) {
const v = String(env.AERE_POS_API_BASE || '').trim();
if (!v) return API_IMPLICIT;
let u; try { u = new URL(v); } catch { throw new Refuz('AERE_POS_API_BASE is not a URL'); }
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname) && /^https?:$/.test(u.protocol);
if (!loopback) throw new Refuz(`AERE_POS_API_BASE may point only to a loopback address (it exists for local tests); the API key is sent only to ${API_IMPLICIT}`);
return v.replace(/\/+$/, '');
}
function carligeDeProba(env) {
const v = String(env.AERE_POS_TEST_HOOK || '').trim();
if (!v) return new Set();
if (env.GITHUB_ACTIONS === 'true') throw new Refuz('AERE_POS_TEST_HOOK is a switch for the local tests and is refused on a GitHub runner');
const s = new Set(v.split(',').map((x) => x.trim()).filter(Boolean));
for (const x of s) if (!CARLIGE.has(x)) throw new Refuz(`unknown AERE_POS_TEST_HOOK "${x.slice(0, 40)}"`);
return s;
}
function git(cwd, args) {
try {
return { ok: true, out: execFileSync('git', ['--literal-pathspecs', ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 28 }).toString().replace(/\r?\n$/, '') };
} catch (e) { return { ok: false, out: '', err: String((e.stderr && e.stderr.toString()) || e.message || '').trim().split(/\r?\n/)[0] || 'git failed' }; }
}
// Dependintele lui pos.mjs (sdk-pq-sign -> @noble/*) nu sunt in depozit (node_modules e ignorat). Pe un runner, checkout-ul
// actiunii nu le are, deci se instaleaza EXACT versiunile din package-lock.json (npm ci verifica integritatea sha512), fara
// scripturi de instalare. Local, unde exista deja, nu se atinge nimic.
function dependinte(carlige, jurnal, bib) {
const dir = path.join(RADACINA, 'sdk-pq-sign');
const cere = ['@noble/post-quantum', '@noble/curves', '@noble/hashes'];
const lipsa = () => cere.filter((p) => !fs.existsSync(path.join(dir, 'node_modules', ...p.split('/'), 'package.json')));
const inainte = lipsa();
if (!inainte.length) return 'present';
if (!fs.existsSync(path.join(dir, 'package-lock.json'))) throw new Refuz('the action checkout has no sdk-pq-sign/package-lock.json, so its pinned dependencies cannot be installed');
jurnal(`installing the pinned dependencies of sdk-pq-sign from its package-lock.json (missing: ${inainte.join(', ')})`);
const cmd = 'npm ci --ignore-scripts --no-audit --no-fund' + (carlige.has('npm-offline') ? ' --offline' : '');
try { execSync(cmd, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 }); } catch (e) {
const coada = String(e.stderr || '').split(/\r?\n/).filter((l) => /npm (error|ERR)/.test(l)).slice(0, 2).join(' | ');
throw new Refuz(`could not install the pinned dependencies of sdk-pq-sign (npm ci from its package-lock.json): ${bib(coada || 'npm ci failed').slice(0, 300)}`);
}
const dupa = lipsa();
if (dupa.length) throw new Refuz(`npm ci ran but ${dupa.join(', ')} is still missing`);
return 'installed';
}
const strica = (p) => { const b = fs.readFileSync(p); const i = Math.min(64, b.length - 1); b[i] ^= 0x01; fs.writeFileSync(p, b); };
const sha256File = (p) => '0x' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
// ---------------------------------------------------------------- actiunea
export async function ruleaza(env = process.env) {
const sec = new Secrete();
const scrie = (s) => process.stdout.write(s);
const jurnal = (s) => scrie(sec.curata(s).split(/\r?\n/).map((l) => ' ' + l).join('\n') + '\n');
const masca = (v) => { for (const n of sec.adauga(v)) scrie(`::add-mask::${escData(n)}\n`); };
const sumar = (md) => { if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, sec.curata(md)); };
const stare = { notarizare: null, atestare: null };
const bib = (x) => taieHex(sec.curata(x)); // text venit de la o biblioteca sau o unealta (git, npm, serverul)
try {
// 1. mastile. Parsarea cheii nu tipareste nimic, deci poate sta inaintea mastilor; orice refuz vine DUPA ele.
const apiBrut = String(intrare(env, 'aere-api-key')).trim();
if (apiBrut) { masca(apiBrut); for (const p of apiBrut.split(/[.\s]+/)) masca(p); }
const cheieBrut = String(intrare(env, 'signing-key')).trim();
let keys = null;
if (cheieBrut) {
const r = citesteCheia(cheieBrut);
masca(cheieBrut); masca(r.text);
if (r.ok) {
keys = r.keys;
for (const s of [keys.classical.secretKey, keys.pq.secretKey, keys.pq.seed]) masca(s);
} else {
// nu stim ce e secret intr-o cheie pe care nu o intelegem: se mascheaza fiecare rand si fiecare valoare
for (const l of `${cheieBrut}\n${r.text}`.split(/\r?\n/)) masca(l);
for (const v of frunze(r.obj)) masca(v);
throw new Refuz(r.motiv);
}
}
if (apiBrut && /[\u0000-\u001f\u007f]/.test(apiBrut)) throw new Refuz('aere-api-key contains a line break or a control character; nothing was sent');
// 2. intrarile obisnuite
const build = String(intrare(env, 'build')).trim();
if (build !== 'npm-pack') throw new Refuz(`build "${build.slice(0, 40)}" is not supported: this version repeats only npm-pack builds (npm pack of the committed tree)`);
const verifRebuild = booleanGitHub(String(intrare(env, 'verify-rebuild')).trim(), 'verify-rebuild');
const sursa = String(intrare(env, 'source-path')).trim();
if (!sursa) throw new Refuz('source-path is required: the directory of the npm package inside the repository');
const ws = path.resolve(env.GITHUB_WORKSPACE || process.cwd());
let sursaAbs = path.resolve(ws, sursa);
const relWs = path.relative(ws, sursaAbs);
if (relWs.startsWith('..') || path.isAbsolute(relWs)) throw new Refuz(`source-path "${sursa}" is outside the workspace`);
if (!fs.existsSync(sursaAbs) || !fs.statSync(sursaAbs).isDirectory()) throw new Refuz(`source-path "${sursa}" is not a directory in the workspace`);
sursaAbs = fs.realpathSync.native(sursaAbs);
const apiBase = bazaApi(env);
const carlige = carligeDeProba(env);
if (carlige.size) jurnal(`TEST HOOK ACTIVE: ${[...carlige].join(', ')} (local tests only; this run does not produce an attestation to use)`);
const tmpBaza = env.RUNNER_TEMP || os.tmpdir();
const outDir = path.resolve(ws, String(intrare(env, 'out-dir')).trim() || path.join(tmpBaza, 'aere-proof-of-software'));
const relOut = path.relative(sursaAbs, outDir);
if (!relOut.startsWith('..') && !path.isAbsolute(relOut)) throw new Refuz('out-dir is inside source-path; the artifact would then dirty the tree it is attested from');
// 3. dependintele si modulele (pos.mjs + clientul Cloud), prin cale relativa
const dep = dependinte(carlige, jurnal, bib);
let pos, AereCloud;
try {
pos = await import(pathToFileURL(path.join(DIR_POS, 'pos.mjs')).href);
({ AereCloud } = await import(pathToFileURL(path.join(RADACINA, 'sdk', 'index.mjs')).href));
} catch (e) { throw new Refuz(`could not load pos.mjs and the Aere Cloud client next to this action: ${mesajBib(e, sec)}`); }
jurnal(`Aere Proof of Software (${pos.TOOL}), dependencies ${dep}`);
// 4. sursa: arborele comis al dosarului, si nimic necomis in el
const top = git(sursaAbs, ['rev-parse', '--show-toplevel']);
if (!top.ok) throw new Refuz(`source-path "${sursa}" is not inside a git checkout (${bib(top.err).slice(0, 160)})`);
const radGit = fs.realpathSync.native(path.resolve(top.out));
const commit = git(radGit, ['rev-parse', '--verify', 'HEAD^{commit}']);
if (!commit.ok || !commit.out) throw new Refuz('the checkout has no commit at HEAD');
const rel = path.relative(radGit, sursaAbs).split(path.sep).join('/');
if (!rel) throw new Refuz('source-path is the repository root: this version attests a package in a SUBDIRECTORY of the repository (the statement records the git tree of that directory)');
if (rel.startsWith('..')) throw new Refuz(`source-path "${sursa}" is outside the repository that contains it`);
const tree = git(radGit, ['rev-parse', '--verify', '--quiet', `${commit.out}:${rel}`]);
if (!tree.ok || !tree.out) throw new Refuz(`source-path "${rel}" is not tracked at commit ${commit.out.slice(0, 12)}`);
if (git(radGit, ['cat-file', '-t', tree.out]).out !== 'tree') throw new Refuz(`source-path "${rel}" is not a directory at commit ${commit.out.slice(0, 12)}`);
const st = git(radGit, ['status', '--porcelain=v1', '--untracked-files=all', '--', rel]);
if (!st.ok) throw new Refuz(`git status failed on source-path "${rel}": ${bib(st.err).slice(0, 160)}`);
if (st.out.trim()) {
const l = st.out.split('\n').filter(Boolean);
throw new Refuz(`source-path "${rel}" has uncommitted or untracked files, and the attestation is of the COMMITTED tree; commit or remove them first:\n${l.slice(0, 10).join('\n')}${l.length > 10 ? `\n... and ${l.length - 10} more` : ''}`);
}
const pj = git(radGit, ['cat-file', 'blob', `${commit.out}:${rel}/package.json`]);
let pkg = null; try { pkg = JSON.parse(pj.out); } catch { pkg = null; }
if (!pj.ok || !pkg || typeof pkg.name !== 'string' || typeof pkg.version !== 'string') throw new Refuz(`source-path "${rel}" has no committed package.json with a name and a version`);
jurnal(`source: ${rel} at commit ${commit.out}, git tree ${tree.out}; package ${pkg.name}@${pkg.version}`);
// 5. artefactul, din arborele comis
fs.mkdirSync(outDir, { recursive: true });
let tgz;
try { tgz = pos.packNpmFromTree(radGit, `${commit.out}:${rel}`, outDir); } catch (e) { throw new Refuz(`npm pack of the committed tree failed: ${mesajBib(e, sec)}`); }
if (carlige.has('foreign-artifact')) { strica(tgz); jurnal('TEST HOOK foreign-artifact: one byte of the artifact changed BEFORE attestation'); }
// 6. atestarea
let att;
try { att = await pos.attest({ artifacts: [tgz], name: pkg.name, version: pkg.version, sourcePath: sursaAbs, build: 'npm-pack', keys, cwd: radGit }); }
catch (e) { throw new Refuz(`${keys ? 'attestation or signing failed' : 'attestation failed'}: ${mesajBib(e, sec)}`); }
const s = att.statement.source || {};
if (s.commit !== commit.out || s.tree !== tree.out || s.path !== rel || s.pathDirty !== false) {
throw new Refuz(`the checkout changed during the run (the statement names ${String(s.commit).slice(0, 12)}:${s.path}, tree ${String(s.tree).slice(0, 12)}, pathDirty ${s.pathDirty}); nothing is attested`);
}
const art = att.statement.artifacts[0];
jurnal(`artifact: ${art.name}, ${art.bytes} bytes, sha256 ${art.sha256}`);
jurnal(`signature: ${att.signature ? `hybrid ${att.signature.alg}, classical public key ${att.signature.classicalPublicKey}` : 'none (no signing-key given)'}`);
jurnal(`statementHash ${att.statementHash}`);
if (carlige.has('alter-after-attest')) { strica(tgz); jurnal('TEST HOOK alter-after-attest: one byte of the artifact changed AFTER attestation'); }
// 7. verificarea, prin reconstructie dintr-o clona curata a commitului (--no-local: obiectele trec prin transportul git
// si se re-hashuiesc; --no-checkout: nu se face copie de lucru, deci nicio conversie de sfarsit de rand)
let clona = null; let r;
try {
if (verifRebuild) {
clona = fs.mkdtempSync(path.join(tmpBaza, 'aere-pos-clone-'));
const c = git(path.dirname(clona), ['clone', '--quiet', '--no-local', '--no-checkout', radGit, clona]);
if (!c.ok) throw new Refuz(`could not make a clean clone of the checkout for the rebuild: ${bib(c.err).slice(0, 200)}`);
}
r = await pos.verify(att, [tgz], { rebuildFrom: clona });
} finally { if (clona) { try { fs.rmSync(clona, { recursive: true, force: true, maxRetries: 3 }); } catch { /* curatenie; nu schimba verdictul */ } } }
for (const c of r.checks) jurnal(`${c.pass === true ? 'OK ' : c.pass === false ? 'FAIL' : '-- '} ${c.name}${c.detail ? ' (' + c.detail + ')' : ''}`);
const picate = r.checks.filter((c) => c.pass === false);
const linii = picate.map((c) => `FAIL ${c.name}${c.detail ? ' (' + c.detail + ')' : ''}`).join('\n');
if (picate.some((c) => /^(rebuild|source)/.test(c.name))) throw new Refuz(`the rebuild from a clean clone of ${commit.out.slice(0, 12)} does NOT reproduce the artifact byte for byte; nothing is notarized:\n${linii}`);
if (!r.valid) throw new Refuz(`verification of the fresh attestation failed; nothing is notarized:\n${linii}`);
const reconstruit = r.checks.some((c) => c.name.startsWith('rebuild: npm pack of the attested tree reproduces') && c.pass === true);
if (verifRebuild && !reconstruit) throw new Refuz('the rebuild was not confirmed (no passing rebuild check); nothing is notarized');
// 8. atestarea pe disc (inca nenotarizata; daca notarizarea cade, fisierul spune singur "notarization": null)
const attPath = path.join(outDir, path.basename(tgz).replace(/\.tgz$/, '') + '.attestation.json');
fs.writeFileSync(attPath, JSON.stringify(att, null, 1));
stare.atestare = attPath;
// 9. notarizarea, numai cu cheie, numai dupa verificare
let proofUrl = null;
if (apiBrut) {
let cod = null;
const f = async (url, o) => { const x = await fetch(url, { ...o, redirect: 'error', signal: AbortSignal.timeout(120000) }); cod = x.status; return x; };
let d;
try { d = await new AereCloud({ apiKey: apiBrut, baseUrl: apiBase, fetch: f }).notarize(att.statementHash); } catch (e) {
if (cod !== null && cod !== 200) {
const motiv = e && e.body && e.body.error ? ` (${bib(String(e.body.error)).replace(/\s+/g, ' ').slice(0, 120)})` : '';
throw new Refuz(`notarization refused: HTTP ${cod}${motiv}; the attestation is NOT notarized`);
}
throw new Refuz(`notarization request failed: ${mesajBib(e, sec)}; the attestation is NOT notarized`);
}
if (cod !== 200) throw new Refuz(`notarization answered HTTP ${cod}, not 200; the attestation is NOT notarized`);
if (!d || !/^0x[0-9a-fA-F]{64}$/.test(String(d.txHash || '')) || !Number.isInteger(d.block) || d.block <= 0) throw new Refuz('notarization answered 200 without a transaction hash and a block; the attestation is NOT notarized');
if (d.hash !== undefined && String(d.hash).toLowerCase() !== att.statementHash.toLowerCase()) throw new Refuz('notarization answered for another hash than the statementHash; the attestation is NOT notarized');
// aceeasi forma ca pos.mjs attest(), ca `pos.mjs verify` sa citeasca chitanta (block, txHash) fara nicio diferenta
att.notarization = { txHash: d.txHash, block: d.block, firstSeenAt: d.firstSeenAt ?? null, firstTime: d.firstTime ?? null, contract: d.contract ?? null, chainId: d.chainId ?? null, proof: '/v1/proof/' + att.statementHash };
proofUrl = `${apiBase}/proof/${att.statementHash}`;
stare.notarizare = { tx: d.txHash, block: d.block };
fs.writeFileSync(attPath, JSON.stringify(att, null, 1));
jurnal(`notarized: tx ${d.txHash}, block ${d.block}${d.firstTime === false ? ' (this hash was already notarized earlier; first seen at ' + d.firstSeenAt + ')' : ''}; proof ${proofUrl}`);
} else jurnal('notarization: skipped, no aere-api-key given; the attestation is NOT notarized');
// 10. ce se incarca e ce se verifica: atestarea recitita de pe disc
const inapoi = JSON.parse(fs.readFileSync(attPath, 'utf8'));
const r2 = await pos.verify(inapoi, [tgz]);
if (!r2.valid || inapoi.statementHash !== att.statementHash) throw new Refuz('the attestation file read back from disk does not verify');
// 11. iesirile si rezumatul
const iesiri = { 'attestation-path': attPath, 'artifact-path': tgz, 'artifact-sha256': art.sha256, tree: tree.out, 'statement-hash': att.statementHash };
if (stare.notarizare) { iesiri['notarized-tx'] = stare.notarizare.tx; iesiri['proof-url'] = proofUrl; }
const text = Object.entries(iesiri).map(([k, v]) => linieIesire(k, sec.curata(v))).join('');
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, text); else jurnal(`outputs (GITHUB_OUTPUT is not set):\n${text.trim()}`);
const relAtt = path.basename(attPath), relArt = path.basename(tgz);
sumar([
'## Aere Proof of Software', '',
'| | |', '|---|---|',
`| package | \`${pkg.name}@${pkg.version}\` |`,
`| artifact | \`${art.name}\`, ${art.bytes} bytes |`,
`| artifact sha256 | \`${art.sha256}\` |`,
`| source | commit \`${commit.out}\`, path \`${rel}\`, git tree \`${tree.out}\` |`,
`| build | npm pack of the committed tree (npm ${att.statement.build && att.statement.build.npm}) |`,
`| signature | ${att.signature ? `hybrid \`${att.signature.alg}\`, classical public key \`${att.signature.classicalPublicKey}\`` : '**unsigned**: no signing-key given'} |`,
`| rebuild | ${verifRebuild ? 'reproduced byte for byte from a clean clone of the commit' : '**NOT verified**: verify-rebuild is false'} |`,
`| notarization | ${stare.notarizare ? `tx \`${stare.notarizare.tx}\` in block ${stare.notarizare.block}; proof: ${proofUrl}` : '**NOT notarized**: no aere-api-key given, so the attestation carries no time on chain'} |`,
`| statementHash | \`${att.statementHash}\` |`, '',
'Anyone can check it without trusting this run, from their own clone of the repository:', '',
'```', `node tools/proof-of-software/pos.mjs verify ${relAtt} --rebuild-from <your clone> ${relArt}`, '```', '',
].join('\n'));
return 0;
} catch (e) {
const msg = sec.curata(e instanceof Refuz ? e.message : `unexpected error: ${mesajBib(e, sec)}`);
scrie(`::error::${escData(msg)}\n`);
process.stderr.write(msg + '\n');
sumar([
'## Aere Proof of Software: FAILED', '', '```', msg, '```', '',
stare.notarizare ? `Notarized before the failure: tx \`${stare.notarizare.tx}\`, block ${stare.notarizare.block}.` : 'The attestation is NOT notarized.',
stare.atestare ? `An attestation file was written before the failure (\`${path.basename(stare.atestare)}\`); this run did not complete, so do not publish it.` : '', '',
].join('\n'));
process.exitCode = 1;
return 1;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) ruleaza().catch((e) => { process.stderr.write(`unexpected error: ${mesajBib(e)}\n`); process.exitCode = 1; });