aere-proof-of-software/tools/proof-of-software/test/pos.test.mjs

156 lines
12 KiB
JavaScript

// Proof of Software: fiecare afirmatie cu perechea ei negativa. Offline; cu AERE_SCRIE=1 si AERE_CHEIE_FISIER face o
// notarizare reala (o tranzactie pe 2800) si citeste dovada ei.
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { attest, verify, buildStatement, packNpmFromTree } from '../pos.mjs';
import * as pq from '../../../sdk-pq-sign/index.mjs';
// 2026-10-21 (the stranger pass): every temporary directory of this test is remembered and removed when the process exits, on every
// path (the end, an exception, process.exit); until then each run left its directories in TEMP
const facute = [];
const tmp = (p) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), p)); facute.push(d); return d; };
process.on('exit', () => { for (const d of facute) { try { fs.rmSync(d, { recursive: true, force: true, maxRetries: 3 }); } catch { /* left for the system */ } } });
let treceri = 0; const esecuri = [];
async function test(nume, fn) { try { await fn(); treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' ESEC ' + nume + ' — ' + (e.message || e)); } }
const D = tmp('aere-pos-');
const A = path.join(D, 'app.tgz'), B = path.join(D, 'app.sha'), S = path.join(D, 'sbom.json');
fs.writeFileSync(A, Buffer.from('binary artifact bytes 12345')); fs.writeFileSync(B, 'checksums'); fs.writeFileSync(S, JSON.stringify({ bomFormat: 'CycloneDX', components: [] }));
const keys = pq.generateKeyPair({ alg: 'secp256k1+ml-dsa-65' });
await test('statement: artifacts with sha256 + size, sbom hashed, builder, time', () => {
const s = buildStatement({ artifacts: [A, B], sbom: S, name: 'app', version: '1.0.0', cwd: D });
assert.strictEqual(s.artifacts.length, 2); assert.match(s.artifacts[0].sha256, /^0x[0-9a-f]{64}$/); assert.strictEqual(s.artifacts[0].bytes, 27);
assert.strictEqual(s.sbom.name, 'sbom.json'); assert.strictEqual(s.subject.version, '1.0.0'); assert.ok(s.createdAt);
});
await test('two artifacts with the same name are refused (verification matches by name)', () => {
const D2 = tmp('aere-pos-'); fs.mkdirSync(path.join(D2, 'x')); fs.writeFileSync(path.join(D2, 'x', 'app.tgz'), 'a');
assert.throws(() => buildStatement({ artifacts: [A, path.join(D2, 'x', 'app.tgz')], cwd: D }), /share the name/);
});
const att = await attest({ artifacts: [A, B], sbom: S, name: 'app', version: '1.0.0', keys, cwd: D });
await test('attest + verify with the same files: VALID, signature both halves, on chain absent (reported, not assumed)', async () => {
const r = await verify(att, [A, B, S]);
assert.strictEqual(r.valid, true, JSON.stringify(r.checks));
assert.ok(r.checks.find((c) => c.name.startsWith('hybrid signature') && c.pass === true));
assert.ok(r.checks.find((c) => c.name === 'on chain' && c.pass === null));
});
await test('a tampered artifact (one byte) makes verification INVALID', async () => {
const T = path.join(D, 'app.tgz.tampered'); fs.mkdirSync(path.join(D, 't'), { recursive: true }); const T2 = path.join(D, 't', 'app.tgz');
fs.writeFileSync(T2, Buffer.from('binary artifact bytes 12346'));
const r = await verify(att, [T2, B, S]); assert.strictEqual(r.valid, false); assert.ok(r.checks.find((c) => c.name.includes('app.tgz') && c.pass === false));
});
await test('a missing artifact is INVALID (absent artifact is a failure, absent SBOM is only reported)', async () => {
const r = await verify(att, [A]); assert.strictEqual(r.valid, false);
assert.ok(r.checks.find((c) => c.name.includes('app.sha') && c.pass === false));
assert.ok(r.checks.find((c) => c.name.startsWith('sbom') && c.pass === null));
});
await test('an edited statement (version changed) breaks statementHash and the signature', async () => {
const e = JSON.parse(JSON.stringify(att)); e.statement.subject.version = '1.0.1';
const r = await verify(e, [A, B, S]); assert.strictEqual(r.valid, false);
assert.ok(r.checks.find((c) => c.name.startsWith('statementHash') && c.pass === false));
assert.ok(r.checks.find((c) => c.name.startsWith('hybrid signature') && c.pass === false));
});
await test('a flipped bit in the post-quantum half of the signature is INVALID', async () => {
const e = JSON.parse(JSON.stringify(att)); const b = pq.fromHex(e.signature.pqSignature); b[5] ^= 1; e.signature.pqSignature = pq.toHex(b);
const r = await verify(e, [A, B, S]); assert.strictEqual(r.valid, false);
});
await test('without keys: no signature, still VALID on digests alone, signature reported absent', async () => {
const a2 = await attest({ artifacts: [A], cwd: D }); const r = await verify(a2, [A]);
assert.strictEqual(r.valid, true); assert.ok(r.checks.find((c) => c.name === 'hybrid signature' && c.pass === null));
});
await test('CLI: keygen, attest, verify round trip through files; tampered file fails with exit 1', () => {
const pos = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..', 'pos.mjs');
const K = path.join(D, 'keys.json'), O = path.join(D, 'attestation.json');
execFileSync('node', [pos, 'keygen', '--out', K, '--alg', 'ed25519+ml-dsa-44'], { stdio: 'pipe' });
const out = execFileSync('node', [pos, 'attest', '--out', O, '--name', 'app', '--version', '2', '--sbom', S, '--keys', K, A, B], { stdio: 'pipe' }).toString();
assert.match(out, /signature ed25519\+ml-dsa-44/);
const v = execFileSync('node', [pos, 'verify', O, A, B, S], { stdio: 'pipe' }).toString(); assert.match(v, /VALID: every present claim holds/);
let code = 0; try { execFileSync('node', [pos, 'verify', O, path.join(D, 't', 'app.tgz'), B, S], { stdio: 'pipe' }); } catch (e) { code = e.status; }
assert.strictEqual(code, 1);
});
// ---- legarea de ARBORELE git si verificarea prin RECONSTRUCTIE (1.1.0). Un depozit de proba cu un pachet npm mic.
const G = tmp('aere-pos-git-');
const g = (args, cwd = G) => execFileSync('git', ['-c', 'core.autocrlf=false', '-c', 'user.name=proba', '-c', 'user.email=proba@example.invalid', ...args], { cwd, stdio: 'pipe' }).toString().trim();
g(['init', '-q']); fs.mkdirSync(path.join(G, 'pkg', 'lib'), { recursive: true }); fs.mkdirSync(path.join(G, 'altceva'));
fs.writeFileSync(path.join(G, 'pkg', 'package.json'), JSON.stringify({ name: 'proba-pos', version: '0.0.1', main: 'lib/index.js', files: ['lib', 'LICENSE'] }, null, 1) + '\n');
fs.writeFileSync(path.join(G, 'pkg', 'lib', 'index.js'), 'module.exports = 1;\n'); fs.writeFileSync(path.join(G, 'pkg', 'LICENSE'), 'line one\nline two\n');
fs.writeFileSync(path.join(G, 'altceva', 'x.txt'), 'nu face parte din pachet\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'pachet de proba']);
const OUT1 = path.join(G, '..', path.basename(G) + '-out'); fs.mkdirSync(OUT1); facute.push(OUT1);
const TGZ = packNpmFromTree(G, 'HEAD:pkg', OUT1);
await test('source tree: --source-path records path, tree and pathDirty; build records npm-pack and the npm version', () => {
const s = buildStatement({ artifacts: [TGZ], sourcePath: 'pkg', build: 'npm-pack', cwd: G });
assert.strictEqual(s.source.path, 'pkg'); assert.strictEqual(s.source.tree, g(['rev-parse', 'HEAD:pkg'])); assert.strictEqual(s.source.pathDirty, false);
assert.strictEqual(s.build.kind, 'npm-pack'); assert.match(s.build.npm, /^\d+\./);
assert.throws(() => buildStatement({ artifacts: [TGZ], sourcePath: 'nu-exista', cwd: G }), /not tracked at HEAD/);
});
await test('the listing of a tree does not depend on the directory it is asked from (ls-tree --full-tree)', () => {
const o = tmp('aere-pos-sub-');
const t2 = packNpmFromTree(path.join(G, 'altceva'), 'HEAD:pkg', o);
assert.strictEqual(fs.readFileSync(t2).equals(fs.readFileSync(TGZ)), true, 'same tree asked from a subdirectory gave other bytes');
});
await test('the artifact is a function of the COMMITTED tree: CRLF in the working copy changes a working-copy pack, not this one', () => {
fs.writeFileSync(path.join(G, 'pkg', 'LICENSE'), 'line one\r\nline two\r\n');
const o = tmp('aere-pos-crlf-');
const again = packNpmFromTree(G, 'HEAD:pkg', o);
assert.strictEqual(fs.readFileSync(again).equals(fs.readFileSync(TGZ)), true);
const wc = execFileSync('npm pack --silent --pack-destination "' + o + '"', { cwd: path.join(G, 'pkg'), shell: true, stdio: 'pipe' }).toString().trim().split(/\r?\n/).pop();
fs.renameSync(path.join(o, wc), path.join(o, 'din-copia-de-lucru.tgz'));
assert.strictEqual(fs.readFileSync(path.join(o, 'din-copia-de-lucru.tgz')).equals(fs.readFileSync(TGZ)), false, 'control: the working-copy pack should differ');
fs.writeFileSync(path.join(G, 'pkg', 'LICENSE'), 'line one\nline two\n');
});
const attG = await attest({ artifacts: [TGZ], name: 'proba-pos', version: '0.0.1', sourcePath: 'pkg', build: 'npm-pack', keys, cwd: G });
await test('verify --rebuild-from: a clone chosen by the verifier reproduces the attested artifact byte for byte', async () => {
const C = tmp('aere-pos-clona-'); g(['clone', '-q', G, C], os.tmpdir());
const r = await verify(attG, [TGZ], { rebuildFrom: C });
assert.strictEqual(r.valid, true, JSON.stringify(r.checks));
assert.ok(r.checks.find((c) => c.name.startsWith('rebuild: npm pack of the attested tree reproduces') && c.pass === true));
const r0 = await verify(attG, [TGZ]); assert.ok(r0.checks.find((c) => c.name === 'rebuild' && c.pass === null), 'without --rebuild-from the rebuild is reported as not attempted');
});
await test('an artifact that did NOT come from the attested tree fails the rebuild (digest differs), though its own digest matches', async () => {
fs.writeFileSync(path.join(G, 'pkg', 'lib', 'index.js'), 'module.exports = 2; // schimbat si necomis\n');
const o = tmp('aere-pos-strain-');
const wc = execFileSync('npm pack --silent --pack-destination "' + o + '"', { cwd: path.join(G, 'pkg'), shell: true, stdio: 'pipe' }).toString().trim().split(/\r?\n/).pop();
const strain = path.join(o, wc);
const a = await attest({ artifacts: [strain], sourcePath: 'pkg', build: 'npm-pack', cwd: G });
assert.strictEqual(a.statement.source.pathDirty, true, 'the statement itself says the path was dirty');
const plain = await verify(a, [strain]); assert.strictEqual(plain.valid, true, 'digests alone hold: that is why the rebuild matters');
const r = await verify(a, [strain], { rebuildFrom: G }); assert.strictEqual(r.valid, false);
assert.ok(r.checks.find((c) => c.name.startsWith('rebuild: npm pack of the attested tree reproduces') && c.pass === false && /got 0x/.test(c.detail)));
g(['checkout', '-q', '--', 'pkg/lib/index.js']);
});
await test('a clone that does not hold the attested commit fails on the SOURCE check, before any build', async () => {
const E = tmp('aere-pos-gol-'); g(['init', '-q'], E); fs.writeFileSync(path.join(E, 'a'), 'a'); g(['add', '-A'], E); g(['commit', '-q', '-m', 'alt depozit'], E);
const r = await verify(attG, [TGZ], { rebuildFrom: E }); assert.strictEqual(r.valid, false);
assert.ok(r.checks.find((c) => c.name.startsWith('source:') && c.pass === false && /not in this clone/.test(c.detail)));
assert.ok(!r.checks.find((c) => c.name.startsWith('rebuild: npm pack')), 'no build was attempted');
});
await test('a statement without a source tree cannot be rebuilt, and asking for it is INVALID, not silently skipped', async () => {
const r = await verify(att, [A, B, S], { rebuildFrom: G }); assert.strictEqual(r.valid, false);
assert.ok(r.checks.find((c) => c.name === 'rebuild: the statement names a source tree' && c.pass === false));
});
const CHEIE_F = process.env.AERE_CHEIE_FISIER;
if (process.env.AERE_SCRIE === '1' && CHEIE_F) {
const { AereCloud } = await import('../../../sdk/index.mjs');
const cloud = new AereCloud({ apiKey: fs.readFileSync(CHEIE_F, 'utf8').trim() });
await test('LIVE: attest with notarization on chain 2800, then verify reads the proof (notarized, receipt matches)', async () => {
const a = await attest({ artifacts: [A, B], sbom: S, name: 'app', version: 'live', keys, cloud, cwd: D });
assert.ok(a.notarization && a.notarization.txHash && a.notarization.block > 0, JSON.stringify(a.notarization));
await new Promise((r) => setTimeout(r, 4000));
const r = await verify(a, [A, B, S], { cloud });
assert.strictEqual(r.valid, true, JSON.stringify(r.checks));
assert.ok(r.checks.find((c) => c.name === 'on chain: statementHash is notarized' && c.pass === true));
console.log(' ' + r.checks.filter((c) => c.name.startsWith('on chain')).map((c) => c.name).join(' | '));
});
} else console.log(' (sarit) notarizarea reala: AERE_SCRIE=1 si AERE_CHEIE_FISIER=<fisier> (scrie o tranzactie pe 2800)');
console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`);
if (esecuri.length) process.exitCode = 1;