From 065f84e34a6d908204221f14aac5c52aa4ed54b3 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Tue, 29 Sep 2026 22:46:57 +0300 Subject: [PATCH] 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. --- LICENSE | 15 + README.md | 46 ++ sdk-pq-sign/LICENSE | 15 + sdk-pq-sign/README.md | 88 +++ sdk-pq-sign/index.mjs | 174 ++++++ sdk-pq-sign/package-lock.json | 104 ++++ sdk-pq-sign/package.json | 20 + sdk-pq-sign/test/pq-sign.test.mjs | 108 ++++ sdk/LICENSE | 15 + sdk/index.mjs | 194 ++++++ sdk/package.json | 16 + tools/proof-of-software/README.md | 202 +++++++ tools/proof-of-software/action/README.md | 132 +++++ tools/proof-of-software/action/action.yml | 58 ++ .../action/examples/attest-npm-package.yml | 54 ++ tools/proof-of-software/action/index.mjs | 357 +++++++++++ tools/proof-of-software/pos.mjs | 556 ++++++++++++++++++ tools/proof-of-software/test/_control.mjs | 46 ++ .../proof-of-software/test/action-dovada.mjs | 94 +++ tools/proof-of-software/test/action.test.mjs | 442 ++++++++++++++ .../test/credential-control-negativ.mjs | 14 + .../test/credential.test.mjs | 119 ++++ .../test/fixturi-go/gomock/go.mod | 8 + .../test/fixturi-go/gomock/go.sum | 28 + .../test/fixturi-go/sdk-go/go.mod | 10 + .../test/fixturi-go/sdk-go/go.sum | 6 + .../test/model-control-negativ.mjs | 42 ++ tools/proof-of-software/test/model.test.mjs | 102 ++++ tools/proof-of-software/test/pos.test.mjs | 150 +++++ .../test/sbom-control-negativ.mjs | 10 + .../test/sbom-go-control-negativ.mjs | 14 + tools/proof-of-software/test/sbom-go.test.mjs | 111 ++++ tools/proof-of-software/test/sbom.test.mjs | 83 +++ .../test/semnatar-control-negativ.mjs | 11 + .../proof-of-software/test/semnatar.test.mjs | 67 +++ 35 files changed, 3511 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 sdk-pq-sign/LICENSE create mode 100644 sdk-pq-sign/README.md create mode 100644 sdk-pq-sign/index.mjs create mode 100644 sdk-pq-sign/package-lock.json create mode 100644 sdk-pq-sign/package.json create mode 100644 sdk-pq-sign/test/pq-sign.test.mjs create mode 100644 sdk/LICENSE create mode 100644 sdk/index.mjs create mode 100644 sdk/package.json create mode 100644 tools/proof-of-software/README.md create mode 100644 tools/proof-of-software/action/README.md create mode 100644 tools/proof-of-software/action/action.yml create mode 100644 tools/proof-of-software/action/examples/attest-npm-package.yml create mode 100644 tools/proof-of-software/action/index.mjs create mode 100644 tools/proof-of-software/pos.mjs create mode 100644 tools/proof-of-software/test/_control.mjs create mode 100644 tools/proof-of-software/test/action-dovada.mjs create mode 100644 tools/proof-of-software/test/action.test.mjs create mode 100644 tools/proof-of-software/test/credential-control-negativ.mjs create mode 100644 tools/proof-of-software/test/credential.test.mjs create mode 100644 tools/proof-of-software/test/fixturi-go/gomock/go.mod create mode 100644 tools/proof-of-software/test/fixturi-go/gomock/go.sum create mode 100644 tools/proof-of-software/test/fixturi-go/sdk-go/go.mod create mode 100644 tools/proof-of-software/test/fixturi-go/sdk-go/go.sum create mode 100644 tools/proof-of-software/test/model-control-negativ.mjs create mode 100644 tools/proof-of-software/test/model.test.mjs create mode 100644 tools/proof-of-software/test/pos.test.mjs create mode 100644 tools/proof-of-software/test/sbom-control-negativ.mjs create mode 100644 tools/proof-of-software/test/sbom-go-control-negativ.mjs create mode 100644 tools/proof-of-software/test/sbom-go.test.mjs create mode 100644 tools/proof-of-software/test/sbom.test.mjs create mode 100644 tools/proof-of-software/test/semnatar-control-negativ.mjs create mode 100644 tools/proof-of-software/test/semnatar.test.mjs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f8ffba4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +MIT License + +Copyright (c) 2026 Aere Network + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. diff --git a/README.md b/README.md new file mode 100644 index 0000000..38bc8d7 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Aere Proof of Software + +An attestation of what was built, from what, by whom and when, that anyone can verify without trusting Aere Network. The tool and its +documentation are in [`tools/proof-of-software/`](tools/proof-of-software/README.md); start there. + +The folders are laid out as in the development repository, because the tool, its tests and its GitHub Action find their two +dependencies by relative path, and nothing was rewritten for publication: + +| folder | what it is | +|---|---| +| [`tools/proof-of-software/`](tools/proof-of-software/) | `pos.mjs` (attest, verify, rebuild, SBOM, ML-BOM, developer credentials), its tests and negative controls, and the GitHub Action in `action/` | +| [`sdk-pq-sign/`](sdk-pq-sign/) | the hybrid signature it uses: a classical scheme (secp256k1 or Ed25519) and ML-DSA (FIPS 204) over the same digest, both required; built on `@noble` | +| [`sdk/`](sdk/) | the Aere Cloud client, only for notarization and reading the on-chain proof; the same file as in `aere-cloud-sdk` | + +## Install and check + +``` +cd sdk-pq-sign && npm ci --ignore-scripts && cd .. # the pinned @noble dependencies, integrity-checked +cd tools/proof-of-software +node pos.mjs keygen --out keys.json +node test/pos.test.mjs # and the other tests below +``` + +Node.js 22 or later, and git. Results measured on 2026-09-29 (Node.js 24.14.1, Windows; git 2.x, npm 11): + +| test | result | negative control | +|---|---|---| +| attestation and verification | 16/16 (`test/pos.test.mjs`) | inside the test | +| ML-BOM of a model directory | 8/8 (`test/model.test.mjs`) | `test/model-control-negativ.mjs` edits `pos.mjs` in place and restores it; not run for this table | +| developer credential | 12/12 (`test/credential.test.mjs`) | 7/7 (`test/credential-control-negativ.mjs`) | +| who signed (`--signer`) | 7/7 (`test/semnatar.test.mjs`) | 4/4 (`test/semnatar-control-negativ.mjs`) | +| npm SBOM from the committed lockfile | 8/8 (`test/sbom.test.mjs`) | 3/3 (`test/sbom-control-negativ.mjs`) | +| Go SBOM from the committed go.sum | 13/13 (`test/sbom-go.test.mjs`) | 7/7 (`test/sbom-go-control-negativ.mjs`) | +| hybrid signature library | 34/34 (`sdk-pq-sign/test/pq-sign.test.mjs`); its check of the ML-DSA half on chain is skipped without a Cloud key | inside the test | +| GitHub Action | `test/action.test.mjs` (long; installs pinned dependencies from the npm registry) | not measured for this publication | + +The GitHub Action is in this repository, which lives on git.aere.network; GitHub runs actions only from GitHub repositories, so +to use it, copy these three folders into a GitHub repository you control and point `uses:` at it, pinned to a full commit SHA +(`tools/proof-of-software/action/README.md`). + +Code comments, test names and control messages are in Romanian; the command line, its output, the data formats, error messages and +the documentation are in English. No third party has reviewed this code. + +## Licence + +MIT, see [LICENSE](LICENSE). Files: 35 (tools/proof-of-software 24, sdk-pq-sign 6, sdk 3). diff --git a/sdk-pq-sign/LICENSE b/sdk-pq-sign/LICENSE new file mode 100644 index 0000000..f8ffba4 --- /dev/null +++ b/sdk-pq-sign/LICENSE @@ -0,0 +1,15 @@ +MIT License + +Copyright (c) 2026 Aere Network + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. diff --git a/sdk-pq-sign/README.md b/sdk-pq-sign/README.md new file mode 100644 index 0000000..e7c81fc --- /dev/null +++ b/sdk-pq-sign/README.md @@ -0,0 +1,88 @@ +# @aere/pq-sign + +Hybrid signatures for documents, payloads, receipts and API calls: a classical scheme (**secp256k1** or **Ed25519**) and a +NIST post-quantum scheme (**ML-DSA-44 / 65 / 87**, FIPS 204) sign the same digest, and a signature is valid only when +**both** halves verify. Keys are generated and used only on your side; nothing is sent anywhere. The post-quantum half +can be re-verified independently by the Aere Network chain precompile, so a third party does not have to trust this +library, or you, to check it. + +``` +npm install @aere/pq-sign +``` + +Pure JavaScript (the audited noble libraries; no native code): Node 18+, and any bundler for the browser. + +## Why hybrid + +A signature made with two independent schemes stays unforgeable while **either** scheme holds. If large quantum +computers break secp256k1 and Ed25519, the ML-DSA half still binds the signer; if a flaw is found in the young lattice +scheme, the classical half still does. This is the migration shape recommended by NIST, the German BSI and the French +ANSSI for the transition years: not "replace", but "add, and require both". + +## Use + +```js +import { generateKeyPair, sign, verify, serialize, parse, envelopeHash } from '@aere/pq-sign'; + +const keys = generateKeyPair({ alg: 'secp256k1+ml-dsa-65' }); // or 'ed25519+ml-dsa-65', '...+ml-dsa-44', '...+ml-dsa-87' +const envelope = sign('release notes of myapp 1.4.2', keys); // string or Uint8Array +const r = verify('release notes of myapp 1.4.2', envelope); +// r = { valid: true, classical: true, pq: true, messageHashMatches: true, toSignMatches: true, reason: null } + +const text = serialize(envelope); // JSON, all byte fields 0x-hex; parse(text) gives it back +``` + +Keep `keys.classical.secretKey` and `keys.pq.secretKey` where you keep secrets. Publish `keys.classical.publicKey` and +`keys.pq.publicKey`; they travel inside every envelope anyway. `generateKeyPair({ seed })` makes the ML-DSA key +deterministic (32-byte seed) for test vectors; classical keys are always fresh unless `classicalSecretKey` is given. + +## What exactly is signed + +``` +messageHash = sha256(message) +toSign = sha256("aere-hybrid-signature-v1" || alg || messageHash) +``` + +Both schemes sign the 32 bytes of `toSign`: secp256k1 as a digest (RFC 6979, compact 64-byte signature, compressed +33-byte key), Ed25519 as a message (64-byte signature, 32-byte key), ML-DSA in the pure FIPS 204 interface with an +empty context. The domain string and the algorithm label are under the hash, so an envelope cannot be replayed as a +different algorithm pair or for another message. The envelope: + +```json +{ "v": 1, "kind": "aere-hybrid-signature", "alg": "secp256k1+ml-dsa-65", "hash": "sha256", + "messageHash": "0x…", "toSign": "0x…", + "classicalPublicKey": "0x…", "classicalSignature": "0x…", + "pqPublicKey": "0x…", "pqSignature": "0x…" } +``` + +Sizes: ML-DSA-44 key 1,312 B, signature 2,420 B; ML-DSA-65 1,952 / 3,309 B; ML-DSA-87 2,592 / 4,627 B. + +## Independent verification on chain + +The Aere Network chain (2800) runs the NIST post-quantum verifiers as native precompiles. With an +[Aere Cloud](https://aere.network/cloud.html) key, the ML-DSA half of any envelope can be judged by the chain itself: + +```js +import { AereCloud } from '@aere/cloud'; +import { verifyOnChain } from '@aere/pq-sign'; +const r = await verifyOnChain(envelope, new AereCloud({ apiKey })); +// r = { pqValidOnChain: true, precompile: '0x…0ae3', block: 19018096, chainId: 2800 } +``` + +That answer does not depend on this library's code. One FIPS 204 detail, measured on chain 2800 on 2026-09-17: the +ML-DSA precompile implements the internal verify (Algorithm 8), and a standard external signature is made over +`M' = 0x00 || len(ctx) || ctx || M`; `verifyOnChain` asks the gateway for the external interface (`interface: +"external"`), which builds `M'` for an empty context. Callers of the precompile directly should pass +`precompileMessage(envelope)` as the message. And if you want a first-seen time for the signature that cannot be +backdated and is covered by the validators' post-quantum certificate, notarize `envelopeHash(envelope)` with +`POST /v1/notarize` and read `GET /v1/proof/{hash}`: first appearance, covering anchor, finality, verification steps. + +## Tests + +`npm test` runs every claim with its negative pair (a flipped bit in either half, another message, another key, a +foreign envelope). With `AERE_CHEIE` (or `AERE_CHEIE_FISIER`) in the environment it also asks the chain precompile to +confirm a fresh post-quantum signature and to refuse a corrupted one. + +## License + +MIT. Aere Network, 2026. diff --git a/sdk-pq-sign/index.mjs b/sdk-pq-sign/index.mjs new file mode 100644 index 0000000..7662467 --- /dev/null +++ b/sdk-pq-sign/index.mjs @@ -0,0 +1,174 @@ +// @aere/pq-sign: hybrid signatures, classical + post-quantum, with keys that never leave the caller. +// +// Why hybrid: a signature made with two independent schemes stays unforgeable while EITHER scheme holds. If +// large-scale quantum computers break secp256k1 / Ed25519, the ML-DSA half still binds the signer; if a flaw is +// found in the young lattice scheme, the classical half still does. Verification requires BOTH halves to verify +// over the SAME digest: a hybrid signature with one broken half is invalid, never "half valid". +// +// What is signed: toSign = sha256("aere-hybrid-signature-v1" || alg || sha256(message)). Both schemes sign +// exactly those 32 bytes, so the post-quantum half can also be verified by the Aere Network chain precompile +// (POST https://cloud.aere.network/v1/pq/verify with scheme, publicKey, signature = pqSignature, message = toSign), +// which gives an independent, on-chain answer about the ML-DSA half. +// +// Envelope (JSON, all byte fields 0x-hex): +// { v: 1, kind: "aere-hybrid-signature", alg: "secp256k1+ml-dsa-65", hash: "sha256", messageHash, toSign, +// classicalPublicKey, classicalSignature, pqPublicKey, pqSignature } +// +// Pure JavaScript (noble libraries, audited, no native code): Node 18+ and any bundler for the browser. +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { randomBytes } from '@noble/hashes/utils'; + +export const VERSION = 1; +export const KIND = 'aere-hybrid-signature'; +const DOMAIN = new TextEncoder().encode('aere-hybrid-signature-v1'); + +// ML-DSA parameter sets (FIPS 204). `id` is the AIP-20 algorithm id and the chain precompile scheme name is `chainScheme`. +export const PQ_SCHEMES = Object.freeze({ + 'ml-dsa-44': Object.freeze({ id: 1, impl: ml_dsa44, publicKeyBytes: 1312, secretKeyBytes: 2560, signatureBytes: 2420, chainScheme: 'ml-dsa-44' }), + 'ml-dsa-65': Object.freeze({ id: 2, impl: ml_dsa65, publicKeyBytes: 1952, secretKeyBytes: 4032, signatureBytes: 3309, chainScheme: 'ml-dsa-65' }), + 'ml-dsa-87': Object.freeze({ id: 3, impl: ml_dsa87, publicKeyBytes: 2592, secretKeyBytes: 4896, signatureBytes: 4627, chainScheme: 'ml-dsa-87' }), +}); + +// Classical schemes. secp256k1 signs the 32-byte digest (compact 64-byte signature, compressed 33-byte key); +// Ed25519 signs the same 32 bytes as a message (64-byte signature, 32-byte key). +export const CLASSICAL_SCHEMES = Object.freeze({ + secp256k1: Object.freeze({ + publicKeyBytes: 33, secretKeyBytes: 32, signatureBytes: 64, + generate: () => secp256k1.utils.randomPrivateKey(), + publicKey: (sk) => secp256k1.getPublicKey(sk, true), + sign: (digest, sk) => secp256k1.sign(digest, sk).toCompactRawBytes(), + verify: (sig, digest, pk) => { try { return secp256k1.verify(sig, digest, pk); } catch { return false; } }, + }), + ed25519: Object.freeze({ + publicKeyBytes: 32, secretKeyBytes: 32, signatureBytes: 64, + generate: () => ed25519.utils.randomPrivateKey(), + publicKey: (sk) => ed25519.getPublicKey(sk), + sign: (digest, sk) => ed25519.sign(digest, sk), + verify: (sig, digest, pk) => { try { return ed25519.verify(sig, digest, pk); } catch { return false; } }, + }), +}); + +// ---------------------------------------------------------------- bytes +export const toHex = (u8) => '0x' + Array.from(u8, (b) => b.toString(16).padStart(2, '0')).join(''); +export function fromHex(h) { + if (typeof h !== 'string') throw new TypeError('expected a 0x-hex string'); + const s = h.startsWith('0x') || h.startsWith('0X') ? h.slice(2) : h; + if (s.length % 2 || /[^0-9a-fA-F]/.test(s)) throw new TypeError('malformed hex'); + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(2 * i, 2 * i + 2), 16); + return out; +} +const asBytes = (m) => (m instanceof Uint8Array ? m : typeof m === 'string' ? new TextEncoder().encode(m) : (() => { throw new TypeError('message must be a Uint8Array or a string'); })()); +function concat(...parts) { + const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } return out; +} +function equal(a, b) { if (a.length !== b.length) return false; let d = 0; for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; return d === 0; } + +// ---------------------------------------------------------------- algorithms +export function parseAlg(alg) { + const m = /^([a-z0-9]+)\+([a-z0-9-]+)$/.exec(String(alg || '')); + if (!m || !CLASSICAL_SCHEMES[m[1]] || !PQ_SCHEMES[m[2]]) { + throw new Error(`unknown alg "${alg}"; expected <${Object.keys(CLASSICAL_SCHEMES).join('|')}>+<${Object.keys(PQ_SCHEMES).join('|')}>`); + } + return { alg: `${m[1]}+${m[2]}`, classical: m[1], pq: m[2], C: CLASSICAL_SCHEMES[m[1]], P: PQ_SCHEMES[m[2]] }; +} + +// The 32 bytes both schemes sign: domain, algorithm label and the message digest, so a signature cannot be +// replayed under another algorithm pair or another message. +export function messageHash(message) { return sha256(asBytes(message)); } +export function toSign(alg, msgHash) { + const { alg: a } = parseAlg(alg); + return sha256(concat(DOMAIN, new TextEncoder().encode(a), msgHash)); +} + +// ---------------------------------------------------------------- keys +// Generates both key pairs locally. `seed` (32 bytes) makes the ML-DSA key deterministic (for tests and vectors); +// classical keys are always fresh random unless `classicalSecretKey` is supplied. +export function generateKeyPair({ alg = 'secp256k1+ml-dsa-65', seed, classicalSecretKey } = {}) { + const A = parseAlg(alg); + const csk = classicalSecretKey ? fromHex(classicalSecretKey) : A.C.generate(); + if (csk.length !== A.C.secretKeyBytes) throw new Error(`classical secret key must be ${A.C.secretKeyBytes} bytes`); + const s = seed ? fromHex(seed) : randomBytes(32); + if (s.length !== 32) throw new Error('seed must be 32 bytes'); + const k = A.P.impl.keygen(s); + return { + alg: A.alg, + classical: { scheme: A.classical, publicKey: toHex(A.C.publicKey(csk)), secretKey: toHex(csk) }, + pq: { scheme: A.pq, publicKey: toHex(k.publicKey), secretKey: toHex(k.secretKey), seed: toHex(s) }, + }; +} + +// ---------------------------------------------------------------- sign / verify +export function sign(message, keys) { + const A = parseAlg(keys.alg); + const mh = messageHash(message); + const ts = toSign(A.alg, mh); + const csk = fromHex(keys.classical.secretKey), psk = fromHex(keys.pq.secretKey); + const cpk = fromHex(keys.classical.publicKey), ppk = fromHex(keys.pq.publicKey); + if (!equal(A.C.publicKey(csk), cpk)) throw new Error('classical public key does not match the secret key'); + const csig = A.C.sign(ts, csk); + const psig = A.P.impl.sign(ts, psk); + // a fresh signature that does not verify is a library defect, never something to hand out + if (!A.C.verify(csig, ts, cpk) || !A.P.impl.verify(psig, ts, ppk)) throw new Error('fresh hybrid signature does not verify'); + return { + v: VERSION, kind: KIND, alg: A.alg, hash: 'sha256', + messageHash: toHex(mh), toSign: toHex(ts), + classicalPublicKey: toHex(cpk), classicalSignature: toHex(csig), + pqPublicKey: toHex(ppk), pqSignature: toHex(psig), + }; +} + +// Verifies BOTH halves over the recomputed digest of `message`. `valid` is true only when every check holds. +export function verify(message, envelope) { + const out = { valid: false, classical: false, pq: false, messageHashMatches: false, toSignMatches: false, reason: null }; + let A; + try { + if (!envelope || envelope.v !== VERSION || envelope.kind !== KIND || envelope.hash !== 'sha256') { out.reason = 'unknown envelope'; return out; } + A = parseAlg(envelope.alg); + const mh = messageHash(message), ts = toSign(A.alg, mh); + out.messageHashMatches = equal(mh, fromHex(envelope.messageHash)); + out.toSignMatches = equal(ts, fromHex(envelope.toSign)); + const cpk = fromHex(envelope.classicalPublicKey), ppk = fromHex(envelope.pqPublicKey); + const csig = fromHex(envelope.classicalSignature), psig = fromHex(envelope.pqSignature); + if (cpk.length !== A.C.publicKeyBytes || csig.length !== A.C.signatureBytes) { out.reason = 'classical key or signature has the wrong size'; return out; } + if (ppk.length !== A.P.publicKeyBytes || psig.length !== A.P.signatureBytes) { out.reason = 'post-quantum key or signature has the wrong size'; return out; } + out.classical = A.C.verify(csig, ts, cpk); + try { out.pq = A.P.impl.verify(psig, ts, ppk); } catch { out.pq = false; } + out.valid = out.classical && out.pq && out.messageHashMatches && out.toSignMatches; + if (!out.valid) out.reason = !out.messageHashMatches ? 'message differs from the signed one' : !out.classical && !out.pq ? 'neither half verifies' : !out.classical ? 'classical half does not verify' : !out.pq ? 'post-quantum half does not verify' : 'toSign mismatch'; + return out; + } catch (e) { out.reason = 'malformed envelope: ' + (e.message || e); return out; } +} + +// ---------------------------------------------------------------- envelope text forms +export function serialize(envelope) { return JSON.stringify(envelope); } +export function parse(text) { + const e = JSON.parse(text); + if (!e || e.kind !== KIND) throw new Error('not an aere-hybrid-signature envelope'); + return e; +} +// the digest to notarize when you want an on-chain, post-quantum-anchored first-seen time for this signature +// (POST /v1/notarize with this hash; GET /v1/proof/{hash} afterwards) +export function envelopeHash(envelope) { return toHex(sha256(new TextEncoder().encode(serialize(envelope)))); } + +// ---------------------------------------------------------------- independent on-chain check of the post-quantum half +// `cloud` is an @aere/cloud client (or anything with pqVerify(body)); the chain precompile verifies the ML-DSA +// signature over toSign and answers with the block it was computed at. Independent of this library's code. +// +// FIPS 204 detail, measured on chain 2800 on 2026-09-17: the ML-DSA precompile implements the INTERNAL verify +// (Algorithm 8). A standard external signature (this library, Bouncy Castle, AIP-20) is made over +// M' = 0x00 || len(ctx) || ctx || M, so the precompile must be handed M' (here: 0x00 0x00 || toSign, empty context). +// The gateway does that when asked for the external interface; the raw encoding is exported for other callers. +export function precompileMessage(envelope, context = new Uint8Array(0)) { + if (context.length > 255) throw new Error('context is at most 255 bytes'); + return toHex(concat(new Uint8Array([0x00, context.length]), context, fromHex(envelope.toSign))); +} +export async function verifyOnChain(envelope, cloud) { + const A = parseAlg(envelope.alg); + const r = await cloud.pqVerify({ scheme: A.P.chainScheme, interface: 'external', publicKey: envelope.pqPublicKey, signature: envelope.pqSignature, message: envelope.toSign }); + return { pqValidOnChain: r && r.valid === true, precompile: r && r.precompile, block: r && r.block, chainId: r && r.chainId }; +} diff --git a/sdk-pq-sign/package-lock.json b/sdk-pq-sign/package-lock.json new file mode 100644 index 0000000..d114b9d --- /dev/null +++ b/sdk-pq-sign/package-lock.json @@ -0,0 +1,104 @@ +{ + "name": "@aere/pq-sign", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aere/pq-sign", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.2.0", + "@noble/hashes": "^1.3.2", + "@noble/post-quantum": "^0.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + } + } +} diff --git a/sdk-pq-sign/package.json b/sdk-pq-sign/package.json new file mode 100644 index 0000000..9de323e --- /dev/null +++ b/sdk-pq-sign/package.json @@ -0,0 +1,20 @@ +{ + "name": "@aere/pq-sign", + "version": "1.0.0", + "description": "Hybrid signatures for documents, payloads and API calls: a classical scheme (secp256k1 or Ed25519) and a NIST post-quantum scheme (ML-DSA-44/65/87) over the same digest, verified together. Keys are generated and used only on your side; the ML-DSA half can be re-verified independently by the Aere Network chain precompile.", + "type": "module", + "main": "index.mjs", + "exports": { ".": "./index.mjs" }, + "files": ["index.mjs", "README.md", "LICENSE"], + "scripts": { "test": "node test/pq-sign.test.mjs" }, + "keywords": ["post-quantum", "ml-dsa", "dilithium", "hybrid-signature", "secp256k1", "ed25519", "aere", "fips-204"], + "license": "MIT", + "homepage": "https://aere.network/cloud.html", + "repository": { "type": "git", "url": "https://git.aere.network/aere-network/aere-pq-sign" }, + "engines": { "node": ">=18" }, + "dependencies": { + "@noble/curves": "^1.2.0", + "@noble/hashes": "^1.3.2", + "@noble/post-quantum": "^0.6.1" + } +} diff --git a/sdk-pq-sign/test/pq-sign.test.mjs b/sdk-pq-sign/test/pq-sign.test.mjs new file mode 100644 index 0000000..3ff8059 --- /dev/null +++ b/sdk-pq-sign/test/pq-sign.test.mjs @@ -0,0 +1,108 @@ +// Probele @aere/pq-sign: fiecare afirmatie cu perechea ei negativa. Offline; cu AERE_CHEIE in mediu ruleaza si +// verificarea INDEPENDENTA pe lant a jumatatii post-cuantice (precompila 2800), in ambele sensuri. +import assert from 'node:assert'; +import { generateKeyPair, sign, verify, serialize, parse, envelopeHash, toSign, messageHash, fromHex, toHex, PQ_SCHEMES, CLASSICAL_SCHEMES, verifyOnChain } from '../index.mjs'; + +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 flip = (hex, i = -1) => { const b = fromHex(hex); const k = i < 0 ? b.length - 1 : i; b[k] ^= 0x01; return toHex(b); }; + +const MSG = 'Invoice 2026-0917: 12,400 EUR payable to Aere Foundation'; + +for (const alg of ['secp256k1+ml-dsa-65', 'ed25519+ml-dsa-65', 'secp256k1+ml-dsa-44', 'ed25519+ml-dsa-87']) { + const keys = generateKeyPair({ alg }); + const env = sign(MSG, keys); + await test(`${alg}: sign then verify is valid, both halves`, () => { + const r = verify(MSG, env); + assert.strictEqual(r.valid, true, JSON.stringify(r)); + assert.strictEqual(r.classical, true); assert.strictEqual(r.pq, true); + }); + await test(`${alg}: sizes are the scheme's (key ${PQ_SCHEMES[alg.split('+')[1]].publicKeyBytes} B, signature ${PQ_SCHEMES[alg.split('+')[1]].signatureBytes} B)`, () => { + const P = PQ_SCHEMES[alg.split('+')[1]], C = CLASSICAL_SCHEMES[alg.split('+')[0]]; + assert.strictEqual(fromHex(env.pqPublicKey).length, P.publicKeyBytes); + assert.strictEqual(fromHex(env.pqSignature).length, P.signatureBytes); + assert.strictEqual(fromHex(env.classicalSignature).length, C.signatureBytes); + assert.strictEqual(fromHex(env.classicalPublicKey).length, C.publicKeyBytes); + }); + await test(`${alg}: one flipped bit in the post-quantum half invalidates the hybrid (classical alone is not enough)`, () => { + const r = verify(MSG, { ...env, pqSignature: flip(env.pqSignature) }); + assert.strictEqual(r.valid, false); assert.strictEqual(r.classical, true); assert.strictEqual(r.pq, false); + assert.match(r.reason, /post-quantum half/); + }); + await test(`${alg}: one flipped bit in the classical half invalidates the hybrid (post-quantum alone is not enough)`, () => { + const r = verify(MSG, { ...env, classicalSignature: flip(env.classicalSignature, 10) }); + assert.strictEqual(r.valid, false); assert.strictEqual(r.pq, true); assert.strictEqual(r.classical, false); + assert.match(r.reason, /classical half/); + }); + await test(`${alg}: another message is refused by both halves`, () => { + const r = verify(MSG + '.', env); + assert.strictEqual(r.valid, false); assert.strictEqual(r.messageHashMatches, false); + assert.strictEqual(r.classical, false); assert.strictEqual(r.pq, false); + }); + await test(`${alg}: another post-quantum public key is refused`, () => { + const alta = generateKeyPair({ alg }); + const r = verify(MSG, { ...env, pqPublicKey: alta.pq.publicKey }); + assert.strictEqual(r.valid, false); assert.strictEqual(r.pq, false); + }); + await test(`${alg}: the envelope survives JSON and its digest is stable`, () => { + const back = parse(serialize(env)); + assert.deepStrictEqual(back, env); + assert.strictEqual(verify(MSG, back).valid, true); + assert.strictEqual(envelopeHash(back), envelopeHash(env)); + assert.match(envelopeHash(env), /^0x[0-9a-f]{64}$/); + }); +} + +await test('the signed digest binds domain, algorithm and message: same message under another alg has another toSign', () => { + const mh = messageHash(MSG); + assert.notStrictEqual(toHex(toSign('secp256k1+ml-dsa-65', mh)), toHex(toSign('ed25519+ml-dsa-65', mh))); + assert.strictEqual(toHex(toSign('secp256k1+ml-dsa-65', mh)), toHex(toSign('secp256k1+ml-dsa-65', mh))); +}); +await test('a deterministic seed gives the same post-quantum key (vectors), classical keys stay fresh', () => { + const seed = '0x' + '11'.repeat(32); + const a = generateKeyPair({ seed }), b = generateKeyPair({ seed }); + assert.strictEqual(a.pq.publicKey, b.pq.publicKey); + assert.notStrictEqual(a.classical.publicKey, b.classical.publicKey); +}); +await test('a mismatched classical key pair is refused at signing time', () => { + const k = generateKeyPair(); const alt = generateKeyPair(); + assert.throws(() => sign(MSG, { ...k, classical: { ...k.classical, publicKey: alt.classical.publicKey } }), /does not match/); +}); +await test('an unknown algorithm pair is refused', () => { + assert.throws(() => generateKeyPair({ alg: 'rsa+ml-dsa-65' }), /unknown alg/); + const r = verify(MSG, { v: 1, kind: 'aere-hybrid-signature', hash: 'sha256', alg: 'secp256k1+sphincs' }); + assert.strictEqual(r.valid, false); assert.match(r.reason, /malformed|unknown/); +}); +await test('a foreign envelope is refused without throwing', () => { + const r = verify(MSG, { v: 2, kind: 'jws' }); + assert.strictEqual(r.valid, false); assert.strictEqual(r.reason, 'unknown envelope'); +}); +await test('binary messages sign and verify like text', () => { + const k = generateKeyPair(); const m = new Uint8Array([0, 255, 1, 2, 3, 254]); + assert.strictEqual(verify(m, sign(m, k)).valid, true); + assert.strictEqual(verify(new Uint8Array([0, 255, 1, 2, 3, 253]), sign(m, k)).valid, false); +}); + +// optional: the chain precompile judges the ML-DSA half, independently of this code, in both directions. +// The key comes from AERE_CHEIE or is read by this process from the file named in AERE_CHEIE_FISIER (never echoed). +const CHEIE = process.env.AERE_CHEIE || (process.env.AERE_CHEIE_FISIER ? (await import('node:fs')).readFileSync(process.env.AERE_CHEIE_FISIER, 'utf8').trim() : ''); +if (CHEIE) { + const { AereCloud } = await import('../../aere-cloud-sdk/index.mjs'); + const cloud = new AereCloud({ apiKey: CHEIE }); + const keys = generateKeyPair({ alg: 'secp256k1+ml-dsa-44' }); + const env = sign(MSG, keys); + await test('on chain (precompile 0x...0ae3): the post-quantum half of a fresh hybrid signature verifies', async () => { + const r = await verifyOnChain(env, cloud); + assert.strictEqual(r.pqValidOnChain, true, JSON.stringify(r)); assert.ok(r.block > 0); + }); + await test('on chain: one flipped bit in the post-quantum half is refused by the precompile', async () => { + const r = await verifyOnChain({ ...env, pqSignature: flip(env.pqSignature) }, cloud); + assert.strictEqual(r.pqValidOnChain, false, JSON.stringify(r)); + }); +} else console.log(' (sarit) verificarea pe lant a jumatatii ML-DSA: pune AERE_CHEIE in mediu'); + +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +if (esecuri.length) process.exitCode = 1; diff --git a/sdk/LICENSE b/sdk/LICENSE new file mode 100644 index 0000000..f8ffba4 --- /dev/null +++ b/sdk/LICENSE @@ -0,0 +1,15 @@ +MIT License + +Copyright (c) 2026 Aere Network + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. diff --git a/sdk/index.mjs b/sdk/index.mjs new file mode 100644 index 0000000..c421c4f --- /dev/null +++ b/sdk/index.mjs @@ -0,0 +1,194 @@ +// @aere/cloud — clientul oficial al Aere Cloud. Zero dependinte: doar fetch si crypto, +// disponibile in Node 18+ si in browser. Impacheteaza fiecare ruta a gateway-ului intr-o +// metoda, cu erorile ridicate ca AereCloudError (cod HTTP + corpul serverului), ca integratorul +// sa nu mai construiasca cereri de mana. Aceeasi filozofie ca API-ul: nimic ascuns, totul +// verificabil — de aceea verifyWebhook e in SDK, ca primitorul de webhook-uri sa nu-si scrie +// singur verificarea HMAC (locul unde integrarile gresesc cel mai des si primesc date false). + +export class AereCloudError extends Error { + constructor(status, body, path) { + super(`Aere Cloud ${status} on ${path}: ${body && body.error ? body.error : 'request failed'}`); + this.name = 'AereCloudError'; + this.status = status; + this.body = body; + this.path = path; + } +} + +export class AereCloud { + /** + * @param {object} opts + * @param {string} opts.apiKey cheia in forma ak2800.. + * @param {string} [opts.baseUrl='https://cloud.aere.network/v1'] + * @param {function} [opts.fetch] injectabil pentru teste/Node vechi + */ + constructor(opts = {}) { + if (!opts.apiKey) throw new Error('AereCloud: apiKey is required (ak2800.
.)'); + this.apiKey = opts.apiKey; + this.baseUrl = (opts.baseUrl || 'https://cloud.aere.network/v1').replace(/\/$/, ''); + this._fetch = opts.fetch || globalThis.fetch; + if (!this._fetch) throw new Error('AereCloud: no fetch available; pass opts.fetch on old Node'); + } + + async _req(method, path, { body, auth = true, raw = false } = {}) { + const headers = { 'content-type': 'application/json' }; + if (auth) headers['x-api-key'] = this.apiKey; + // 1.5.1: fara redirectari urmate. fetch le urmeaza implicit si duce x-api-key la orice gazda numita in Location; + // un 3xx e un raspuns (AereCloudError cu statusul lui), nu un drum. + const r = await this._fetch(this.baseUrl + path, { + method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', + }); + if (raw) return r; + let data = null; + try { data = await r.json(); } catch { /* corp non-JSON */ } + if (!r.ok) throw new AereCloudError(r.status, data, path); + return data; + } + + // ---- health & account ---- + health() { return this._req('GET', '/health', { auth: false }); } + account() { return this._req('GET', '/account'); } + + // ---- JSON-RPC ---- + /** Un apel JSON-RPC brut; @returns rezultatul sau arunca la eroarea RPC. */ + async rpc(method, params = []) { + const d = await this._req('POST', '/rpc', { body: { jsonrpc: '2.0', id: 1, method, params } }); + if (d && d.error) throw new AereCloudError(200, d, '/rpc'); + return d && d.result; + } + async blockNumber() { return parseInt(await this.rpc('eth_blockNumber'), 16); } + + // ---- post-quantum verification ---- + /** + * @param {object} p { scheme, publicKey, signature?, message?, signedMessage? } (0x-hex) + * @returns {Promise<{valid:boolean, scheme, precompile, block, chainId}>} + */ + pqVerify(p) { return this._req('POST', '/pq/verify', { body: p }); } + + /** + * the Trust API (1.6.0): one AERE Proof Protocol (AIP-23) envelope of any kind -> VALID / INVALID / PARTIAL with the levels checked and the command that reproduces the verdict; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify. + * @param {object} envelope the envelope as produced (statement, statementHash, signatures, notarization...) + * @param {{chain?: number}} [opts] 2800 or 28001; omitted = the chain the envelope declares + * @returns {Promise<{verdict:'VALID'|'INVALID'|'PARTIAL', levels:object[], verifier:object, reproduce:string}>} + */ + verify(envelope, { chain } = {}) { + return this._req('POST', '/verify' + (chain ? `?chain=${encodeURIComponent(chain)}` : ''), { body: envelope }); + } + + // ---- chain data ---- + dataHead() { return this._req('GET', '/data/head'); } + validators() { return this._req('GET', '/data/validators'); } + anchors(limit = 10) { return this._req('GET', `/data/anchors?limit=${encodeURIComponent(limit)}`); } + anchor(height) { return this._req('GET', `/data/anchors/${encodeURIComponent(height)}`); } + // 2026-09-26: the post-quantum finality point (finalized/safe = the parent of the last certified anchor), the tag nodes do not serve yet + finality() { return this._req('GET', '/data/finality'); } + transfers(address, limit = 50) { + return this._req('GET', `/data/transfers?address=${encodeURIComponent(address)}&limit=${encodeURIComponent(limit)}`); + } + + // ---- notarization ---- + /** Notarizeaza un digest de 32 de octeti (0x + 64 hex); noi platim gazul. */ + notarize(hash) { return this._req('POST', '/notarize', { body: { hash } }); } + proofOf(hash) { return this._req('GET', `/notarize/${encodeURIComponent(hash)}`); } + // 2026-09-17: the proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps) + proof(hash) { return this._req('GET', `/proof/${encodeURIComponent(hash)}`); } + + // quantum readiness scan of a domain's public TLS edge: free without a key; { attest: true } notarizes the report digest + // on chain 2800 and needs the key (the response then carries `attestation`) + readiness(domain, opts = {}) { + const body = { domain, ...(opts.fresh ? { fresh: true } : {}), ...(opts.attest ? { attest: true } : {}) }; + return this._req('POST', '/pq/readiness', { body, auth: !!opts.attest }); + } + readinessReport(domain) { return this._req('GET', `/pq/readiness/${encodeURIComponent(domain)}`, { auth: false }); } + // the whole public perimeter of a domain in one report (keyed): the hosts you name (labels or names under the domain) plus common + // prefixes found through DNS, each measured with the same handshakes. The request has a deadline: while summary.complete is false, + // call again (finished scans are cached). { attest: true } notarizes the digest of a COMPLETE report on chain 2800. + readinessPerimeter(domain, opts = {}) { + const body = { domain, ...(opts.hosts ? { hosts: opts.hosts } : {}), ...(opts.discover === false ? { discover: false } : {}), ...(opts.attest ? { attest: true } : {}) }; + return this._req('POST', '/pq/readiness/perimeter', { body }); + } + + // AIP-21 (2026-09-18): the per-block post-quantum finality record (seals heard by a validator, re-verified, quorum + // verdict). Free, no key. Testnet 28001 first; `network` is reserved for chain 2800 once the method reaches its validators. + // `client`: 'besu' (default), 'nethermind', or 'both' (two independent implementations, with `agreement` computed by the gateway). + pqFinality(block = 'latest', network = 'testnet', client = 'besu') { + return this._req('GET', `/pq/finality/${encodeURIComponent(block)}?network=${encodeURIComponent(network)}&client=${encodeURIComponent(client)}`, { auth: false }); + } + + // audit log of every keyed request of this account for one UTC day (append-only, paged), with the day file's sha256 + auditLog({ day, offset = 0, limit = 1000 } = {}) { + const q = new URLSearchParams({ offset: String(offset), limit: String(limit) }); + if (day) q.set('day', day); + return this._req('GET', `/account/audit?${q}`); + } + // the same day as a file (1.4.0): format 'jsonl' = the EXACT bytes of the day file, whose sha256 is the digest you notarize + // (`verified` says the bytes you received hash to the digest the gateway states); 'cef' = one ArcSight CEF line per request + // for a SIEM, carrying the digest of the source file. Returns { day, format, bytes, text, entries, digest, verified }. + async auditExport({ day, format = 'jsonl' } = {}) { + const q = new URLSearchParams({ format }); if (day) q.set('day', day); + const r = await this._req('GET', `/account/audit?${q}`, { raw: true }); + if (!r.ok) { let d = null; try { d = await r.json(); } catch { /* non-JSON */ } throw new AereCloudError(r.status, d, '/account/audit'); } + const bytes = new Uint8Array(await r.arrayBuffer()); + const digest = r.headers.get('x-aere-digest'); + const local = '0x' + [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))].map((b) => b.toString(16).padStart(2, '0')).join(''); + return { day: r.headers.get('x-aere-day'), format, bytes, text: new TextDecoder().decode(bytes), entries: Number(r.headers.get('x-aere-entries') || 0), digest, + verified: format === 'jsonl' ? local === digest : null }; + } + // retention of the audit log (1.5.0), READ-ONLY with the API key: what is in force, what is pending and until when, what we + // hold, and the ledger of every change and of every day deleted (each with its digest). Retention is SET with a wallet + // signature in the console, never with the key: a stolen key must not be able to shorten the trail of its own use. + auditRetention() { return this._req('GET', '/account/audit/retention'); } + // does an export you KEPT still match the day on our side, even after we deleted it under your retention? Hashes YOUR bytes + // locally and compares with the digest of the day we hold ('held') or with the tombstone the deleted day left ('expired'). + // Returns { day, state, digest, local, matches, deletedAt }. + async auditVerifyKept(day, bytes) { + const u8 = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : new Uint8Array(bytes); + const local = '0x' + [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', u8))].map((b) => b.toString(16).padStart(2, '0')).join(''); + const r = await this._req('GET', `/account/audit?${new URLSearchParams({ format: 'jsonl', day })}`, { raw: true }); + let d = null; + if (!r.ok) { try { d = await r.json(); } catch { /* non-JSON */ } } + if (r.status === 410 && d && d.expired) return { day, state: 'expired', digest: d.expired.digest, local, matches: local === d.expired.digest, deletedAt: d.expired.deletedAt }; + if (!r.ok) throw new AereCloudError(r.status, d, '/account/audit'); + await r.arrayBuffer(); + const digest = r.headers.get('x-aere-digest'); + return { day, state: 'held', digest, local, matches: local === digest, deletedAt: null }; + } + + // ---- webhooks ---- + listWebhooks() { return this._req('GET', '/webhooks'); } + createWebhook(spec) { return this._req('POST', '/webhooks', { body: spec }); } + deleteWebhook(id) { return this._req('DELETE', `/webhooks/${encodeURIComponent(id)}`); } + + // ---- gas sponsorship ---- + sponsorHealth() { return this._req('GET', '/sponsor/health'); } + sponsorCreateAccount(body) { return this._req('POST', '/sponsor/createAccount', { body }); } + /** + * Cont inteligent cu proprietar POST-CUANTIC (Falcon-512), gazul platit de releu. + * @param {string} falconPubKey 0x-hex, 897 octeti, antetul 0x09 + * @param {number} [salt=0] idempotent pe (cheie, salt): a doua chemare intoarce contul, fara tx + */ + sponsorCreatePqAccount(falconPubKey, salt = 0) { + return this._req('POST', '/sponsor/createPqAccount', { body: { falconPubKey, salt } }); + } + sponsorExecute(body) { return this._req('POST', '/sponsor/execute', { body }); } +} + +/** + * Verifica semnatura unei livrari de webhook. A NU crede un webhook fara asta. + * @param {string} rawBody corpul brut al cererii, EXACT ca octeti (nu re-serializat) + * @param {string} signatureHeader antetul x-aere-signature + * @param {string} secret secretul webhook-ului, primit o data la creare + * @returns {boolean} true doar daca semnatura se potriveste + */ +export async function verifyWebhook(rawBody, signatureHeader, secret) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const mac = await crypto.subtle.sign('HMAC', key, typeof rawBody === 'string' ? enc.encode(rawBody) : rawBody); + const asteptat = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join(''); + const primit = String(signatureHeader || ''); + // comparatie in timp constant, pe lungimi egale + if (asteptat.length !== primit.length) return false; + let dif = 0; + for (let i = 0; i < asteptat.length; i++) dif |= asteptat.charCodeAt(i) ^ primit.charCodeAt(i); + return dif === 0; +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..507d980 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,16 @@ +{ + "name": "@aere/cloud", + "version": "1.6.0", + "description": "Official client for the Aere Cloud API: keyed RPC, post-quantum verification, chain data, notarization, webhooks and gas sponsorship on Aere Network (chain 2800).", + "type": "module", + "main": "index.mjs", + "exports": { ".": "./index.mjs" }, + "files": ["index.mjs", "README.md"], + "scripts": { "test": "node test/sdk.test.mjs" }, + "keywords": ["aere", "blockchain", "evm", "post-quantum", "falcon", "rpc", "web3", "chain-2800"], + "license": "MIT", + "homepage": "https://aere.network/cloud.html", + "repository": { "type": "git", "url": "https://git.aere.network/aere-network/aere-cloud-sdk" }, + "engines": { "node": ">=18" }, + "dependencies": {} +} diff --git a/tools/proof-of-software/README.md b/tools/proof-of-software/README.md new file mode 100644 index 0000000..e10cc58 --- /dev/null +++ b/tools/proof-of-software/README.md @@ -0,0 +1,202 @@ +# Aere Proof of Software + +An attestation of **what** was built, **from what**, **by whom** (the keys that signed, and who holds them when a developer credential +or `--signer` says so) and **when**, that anyone can verify without trusting +Aere: every artifact's SHA-256 and size, the SBOM's digest, the git commit and remote of the source tree, a **hybrid +signature** (classical + ML-DSA, both required) by the builder's own keys, and a **first-seen time on chain 2800** +covered by the validators' post-quantum certificate. It is the software-supply-chain shape of the Proof API: the +statement hash is notarized, and `GET /v1/proof/{statementHash}` returns the covering anchor and the finality. + +``` +node pos.mjs keygen --out keys.json # once, on the build machine; keep it secret +node pos.mjs attest --out attestation.json \ + --name myapp --version 1.4.2 --sbom sbom.json \ + --keys keys.json --cloud-key-file ~/.aere-cloud-key \ + dist/myapp-1.4.2.tgz dist/myapp-1.4.2.sha256 +node pos.mjs verify attestation.json --cloud-key-file ~/.aere-cloud-key dist/myapp-1.4.2.tgz dist/myapp-1.4.2.sha256 sbom.json +``` + +`verify` recomputes every digest from the files it is handed, checks the statement text against `statementHash`, +verifies the hybrid signature, and asks the chain for the proof. It exits 0 only when everything present holds; what is +absent (no signature, no notarization, SBOM not handed over) is reported as absent, never as valid, and the verdict line +counts what was not judged. A missing artifact is a failure. Without a Cloud key the on-chain part is reported as unread, +not as valid. + +**Who signed is yours to require (1.4.0).** A valid signature says that some keys signed, not which: anyone can attest their +own file under your package's name with their own keys, and until 1.4.0 that verified VALID without naming the signer. The +signature check now names the keys (`by keys 0x…`), `verify --signer ` requires the keys you expect (`node pos.mjs +pubkey --keys keys.json --out pub.json` writes the public part to hand out), and without `--signer` or a judged developer +credential the signer is reported as not judged. + +## The statement + +```json +{ "v": 1, "kind": "aere-proof-of-software", "tool": "aere-proof-of-software/1.0.0", + "subject": { "name": "myapp", "version": "1.4.2" }, + "artifacts": [ { "name": "myapp-1.4.2.tgz", "sha256": "0x…", "bytes": 123456 } ], + "sbom": { "name": "sbom.json", "sha256": "0x…", "bytes": 4321 }, + "source": { "commit": "…", "remote": "https://github.com/org/repo.git", "dirty": false }, + "builder": { "platform": "linux/x64", "node": "v22.0.0" }, + "createdAt": "2026-09-17T22:00:00.000Z" } +``` + +The canonical text is `JSON.stringify(statement)` exactly as written into the attestation; `statementHash` is its +SHA-256; the hybrid signature (see `@aere/pq-sign`) is over that text; the notarized digest is `statementHash`. The +SBOM is hashed, not interpreted: produce it with whatever you use (`npm sbom --sbom-format cyclonedx`, `syft`, +`cyclonedx-maven`) and hand the same file to `verify`. + +## Rebuild: the artifact is what the source produces (1.1.0) + +Digests prove the file is the file that was attested. They do not prove it came from the commit the statement names. +For npm packages the tool closes that gap: + +``` +node pos.mjs pack-npm --source-path sdk --out-dir dist # npm pack of the COMMITTED tree, not the working copy +node pos.mjs attest --out attestation.json --source-path sdk --build npm-pack dist/aere-cloud-1.2.1.tgz +node pos.mjs verify attestation.json --rebuild-from /path/to/any/clone dist/aere-cloud-1.2.1.tgz +``` + +`--source-path` adds to `source` the directory (`path`), its git **tree** hash at HEAD (`tree`) and whether that +directory had uncommitted changes (`pathDirty`); `--build npm-pack` records the build and the npm version. +`pack-npm` writes the committed bytes straight from the object store (no checkout conversion) and packs those, so the +artifact is a function of the tree alone: measured here, a working-copy pack of the same commit gave another digest +because a checkout with `core.autocrlf=true` had turned `LICENSE` into CRLF. `verify --rebuild-from` takes a clone the +**verifier** chose, requires `:` to be the attested tree, packs it again and requires the attested digest +byte for byte. An artifact built from anything else passes on digests and fails the rebuild, which is the point. +Measured: same tree, same npm version, same machine. Not measured: another operating system or another npm major; the +npm version is in the statement so a mismatch can be told apart from tampering. + +In the development repository a script runs this on our own packages (`@aere/cloud`, `@aere/pq-sign`), with a CycloneDX SBOM +and two negative controls. Those attestations are unsigned and not notarized until a release signing key exists, they say so +themselves, and they are not part of the public copy of this tool (they name the development repository as their source). + +## AI models: provenance of the weights (1.2.0) + +``` +node pos.mjs model-bom --model-dir ./my-model --out mlbom.json --name my-model --version 1.0 \ + --task text-generation --dataset train=./train.jsonl +node pos.mjs attest --base ./my-model --sbom mlbom.json --out attestation.json ./my-model/* ./my-model/tokenizer/* +node pos.mjs verify attestation.json --base ./my-model mlbom.json ./my-model/* ./my-model/tokenizer/* +``` + +`model-bom` writes a CycloneDX 1.6 ML-BOM: a `machine-learning-model` component (architecture and family read from +`config.json` when it exists, nothing guessed), every file of the directory with its SHA-256 and size (hidden folders +such as caches are left out), the datasets you name with theirs, and a manifest digest: the SHA-256 of the lines +`\t\t` sorted by path, which changes when any file is added, removed or touched. +`--base` names artifacts by their path relative to the model directory, because a model often carries two +`config.json`; a verification without `--base` of such an attestation fails and says why. Files over 64 MiB are +hashed in a stream, so multi-gigabyte weights never have to fit in memory. A missing file fails the verification: an +incomplete model is not a verified model. + +What it proves: these exact bytes are the model you attested, with the datasets and the time you recorded, and (with a +Cloud key) the statement was notarized on chain 2800 with post-quantum finality. What it does not prove: how the model +was trained, that the datasets are what their names say, or anything about its behaviour. + +## GitHub Actions + +```yaml +- run: npm ci && npm run build +- run: npm sbom --sbom-format cyclonedx > sbom.json +- run: | + printf '%s' "${{ secrets.AERE_CLOUD_KEY }}" > .aere-key + node tools/proof-of-software/pos.mjs attest --out attestation.json \ + --name ${{ github.event.repository.name }} --version ${{ github.ref_name }} \ + --sbom sbom.json --keys <(printf '%s' "${{ secrets.AERE_POS_KEYS }}") --cloud-key-file .aere-key dist/* + rm -f .aere-key +- uses: actions/upload-artifact@v4 + with: { name: attestation, path: attestation.json } +``` + +Publish `attestation.json` next to the release. A consumer runs `verify` with the downloaded files and, with any Aere +Cloud key, reads the chain's answer: notarized, first seen in block N, covered by anchor M with K post-quantum seals. + +## Why this and not only Sigstore / SLSA provenance + +Those bind a build to an identity with classical signatures and a transparency log operated by one party. This binds +it with a hybrid signature that stays valid if either scheme falls, and with a first-seen time whose rewriting would +require forging a post-quantum validator certificate. Use both: the attestation file is small enough to sit next to a +SLSA provenance. + +## 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 the organization's own hybrid keys, that names a person and binds that name to the person's signing keys for a period. + +``` +node pos.mjs keygen --out org.json # the organization's keys (the issuer); keep them offline +node pos.mjs pubkey --keys org.json --out org.pub.json # the trust root you hand to whoever verifies +node pos.mjs pubkey --keys dev.json --out dev.pub.json # the developer's public keys +node pos.mjs credential issue --issuer-keys org.json --issuer-name "Example Org" \ + --subject-pub dev.pub.json --subject-name "Ana Dev" --valid-days 365 --out cred.json +node pos.mjs attest --keys dev.json --credential cred.json --out attestation.json dist/app.tgz +node pos.mjs verify attestation.json --trust-issuer org.pub.json [--revocations rev.json] dist/app.tgz +node pos.mjs credential revoke --issuer-keys org.json --credential cred.json --at 2026-10-01T00:00:00Z --out rev.json +``` + +`attest --credential` puts the credential's hash into the statement (`builder.credential`), so the statement signature also +covers "built under this credential"; it refuses a credential that names other keys than `--keys`. `verify` then checks that the +attestation carries that credential, that it is signed by the issuer it names, that the issuer is **the trust root you gave** +(never the one the attestation carries; without `--trust-issuer` the issuer is reported as not judged), that the credential names +exactly the keys that signed the statement, that the time falls in its validity, and that no revocation signed by the issuer covers +that time. The time is the chain's first-seen time when the on-chain proof was read, otherwise the builder's own `createdAt`, and +the check says which one it used: a builder who backdates `createdAt` under an expired credential is caught by the chain's time. +The declared `createdAt` can only accuse (1.4.0): outside the validity or after a revocation it fails the check, but inside them it +is reported as not judged, because whoever holds the keys chooses it. Until 1.4.0 an attestation signed with the keys of a revoked +credential and dated before the revocation passed "not revoked"; notarize attestations for the time to count. +A revocation you were not handed cannot be seen, and verification says so. + +``` +node test/credential.test.mjs # 12/12: each check with its negative pair, the chain time over the declared one, a + # backdated attestation under a revoked credential (not judged; caught on chain time), the CLI +node test/credential-control-negativ.mjs # on a copy, each of 7 guards removed -> its own test turns red +node test/semnatar.test.mjs # 7/7: the signer named, --signer against another's keys -> INVALID, unsigned -> INVALID +node test/semnatar-control-negativ.mjs # on a copy, each of 4 guards removed -> its own test turns red +# in the development repository all of the above also run as one suite, tied to its findings registry +``` + +## The SBOM is what the committed lockfile says (1.3.0) + +`npm sbom --package-lock-only` writes a random serial number and a timestamp on every run, so the attested SBOM can never be +reproduced byte for byte, and until 1.3.0 an SBOM edited by hand (a component dropped, a version or an integrity hash changed) and +attested again passed on its digest. `verify --rebuild-from `, when you also hand it the attested SBOM, now re-derives the +SBOM from `:/package-lock.json` in the clone YOU chose and compares what the two SAY: the root, every component by purl +with its hashes and scope, and the dependency graph. Same content with another serial number passes; different content fails. A +committed tree without `package-lock.json` cannot re-derive anything, and verification says so instead of passing it. + +``` +node test/sbom.test.mjs # 8/8: two runs differ in bytes, not in content; a component dropped, a version changed, an + # integrity hash changed -> caught; SBOM not handed / no committed lockfile -> reported, not passed +node test/sbom-control-negativ.mjs # on a copy, 3 guards removed -> their own tests turn red +``` + +## A Go module's SBOM, from the committed go.sum (1.4.0) + +``` +node pos.mjs sbom-go --source-path ./mymod --version v1.4.2 --out sbom.cdx.json # from the COMMITTED go.mod and go.sum +node pos.mjs attest --source-path ./mymod --sbom sbom.cdx.json --name mymod --version v1.4.2 --out attestation.json dist/* +node pos.mjs verify attestation.json --rebuild-from dist/* sbom.cdx.json +``` + +`sbom-go` writes a CycloneDX 1.6 SBOM from `:/go.mod` and `go.sum`, with no Go toolchain and no network, and the same +files always give the same bytes (no timestamp; the serial number is derived from the content). Its components are the modules whose +content `go.sum` pins, each with its `pkg:golang` purl and Go's `h1:` hash as the property `aere:go-sum-h1`; the root is the module +named in `go.mod`. The `h1:` hash is a SHA-256 over a summary of the module's files, not over an archive, so it is not written as a +CycloneDX SHA-256 hash of the component. Modules pinned only by their `go.mod` line (consulted while resolving the module graph, their +code not downloaded) are counted in `aere:go-sum-go-mod-only`, not listed. What it does not say: `go.sum` can keep entries left over +from an older version until `go mod tidy`, so the SBOM says what is pinned, not what was compiled. + +`verify --rebuild-from` re-derives it from the committed tree and compares what the two say (root, every module with its version and +`h1:`). An SBOM edited by hand and attested again passes on its digest and fails here. This tool rebuilds artifacts only with `npm +pack`; for a Go binary the rebuild of the artifacts is reported as not attempted (absent, not wrong), while the source tree and the +SBOM are still judged. + +``` +node test/sbom-go.test.mjs # 13/13 on real go.mod/go.sum files (ours, and golang/mock v1.6.0 with 23 go.mod-only entries): + # deterministic, h1 equal to what Go recorded when it downloaded the module (when the module is + # in the local Go module cache; skipped otherwise), a module dropped, a version, an h1, the root + # changed -> caught; no committed go.sum -> reported, not passed +node test/sbom-go-control-negativ.mjs # on a copy, 7 guards removed -> their own tests turn red +``` + +Java (Maven, Gradle) and .NET lockfiles: not done; the machine these were built and measured on has neither toolchain to produce +real lockfiles to test against, and a parser tested only on files written by hand would test its own assumptions. diff --git a/tools/proof-of-software/action/README.md b/tools/proof-of-software/action/README.md new file mode 100644 index 0000000..75c5bf4 --- /dev/null +++ b/tools/proof-of-software/action/README.md @@ -0,0 +1,132 @@ +# Aere Proof of Software: GitHub Action + +Attests an npm package in CI so that anyone can check, without trusting the CI run, that the published `.tgz` is exactly +what `npm pack` of a named git tree produces. It is a thin wrapper around `tools/proof-of-software/pos.mjs` (the same +`attest` and `verify --rebuild-from` a stranger runs); the attestation logic is not copied into the action. + +## What a run does + +1. Masks the secret inputs (`::add-mask::`) before anything else is printed. +2. Refuses if `source-path` has uncommitted changes or untracked files. What is attested is the **committed** tree. +3. Builds the artifact as `npm pack` of the committed tree, written from the git object store, not from the working + copy. A checkout with `core.autocrlf=true` (CRLF in the working copy) gives the same bytes as one without. +4. Writes the attestation: artifact SHA-256 and size, commit, the git **tree** hash of `source-path`, the npm version, + builder, time; signed with a hybrid signature (classical + ML-DSA, both required) when `signing-key` is given. +5. Verifies it by **rebuilding**: clones the commit into a clean directory, packs the attested tree again and requires + the attested digest byte for byte. The step fails if it is not reproduced. +6. Only then, when `aere-api-key` is given, notarizes the statement hash on Aere Network (chain 2800) through + `POST https://cloud.aere.network/v1/notarize`. Anything but HTTP 200 with a transaction hash and a block fails the + step; redirects are not followed. Without a key nothing is sent, and the step summary says the attestation is + **not notarized**. +7. Reads the attestation file back from disk, verifies it again, then writes the outputs and a step summary. + +## Usage + +```yaml +- uses: actions/checkout@v4 +- uses: actions/setup-node@v4 + with: { node-version: 22 } +- id: pos + uses: //tools/proof-of-software/action@ + with: + source-path: packages/mylib + signing-key: ${{ secrets.AERE_POS_SIGNING_KEY }} # optional + aere-api-key: ${{ secrets.AERE_API_KEY }} # optional +- uses: actions/upload-artifact@v4 + with: + name: proof-of-software + path: | + ${{ steps.pos.outputs.attestation-path }} + ${{ steps.pos.outputs.artifact-path }} +``` + +A complete workflow is in [`examples/attest-npm-package.yml`](examples/attest-npm-package.yml). Pin the action to a +full commit SHA. If you publish to npm, publish the attested file itself (`npm publish `), not a new +pack: a new pack is a new build and the attestation does not cover it. + +The signing key is the file written by `node tools/proof-of-software/pos.mjs keygen --out keys.json` (JSON, or base64 +of it). Generate it once, store it as a repository or environment secret, and publish only its public keys. + +## Inputs + +| input | required | default | meaning | +|---|---|---|---| +| `source-path` | yes | | directory of the npm package, a subdirectory of the repository | +| `build` | no | `npm-pack` | the build to record and repeat; only `npm-pack` is supported | +| `signing-key` | no | | secret: key file from `pos.mjs keygen`; signs the statement (hybrid) | +| `aere-api-key` | no | | secret: Aere Cloud API key; notarizes the statement hash after the rebuild check | +| `verify-rebuild` | no | `true` | rebuild from a clean clone of the commit and require the same bytes | +| `out-dir` | no | `$RUNNER_TEMP/aere-proof-of-software` | where the `.tgz` and the attestation are written (not inside `source-path`) | + +## Outputs + +| output | set when | value | +|---|---|---| +| `attestation-path` | always on success | path of the attestation JSON | +| `artifact-path` | always on success | path of the attested `.tgz` | +| `artifact-sha256` | always on success | SHA-256 of the artifact, `0x` hex | +| `tree` | always on success | git tree hash of `source-path` at the attested commit | +| `statement-hash` | always on success | SHA-256 of the statement text: what is signed and notarized | +| `notarized-tx` | notarized only | transaction hash on chain 2800 | +| `proof-url` | notarized only | `https://cloud.aere.network/v1/proof/` | + +On failure no output is written. + +## Secrets + +Secret inputs are registered with `::add-mask::` before any other output. Nothing the action prints (log lines, +errors, step summary, outputs) contains them: every message passes through a filter that removes the exact secret +values, and messages that come from libraries or tools are additionally stripped of long hex strings. A signing key +that does not parse is refused without quoting any of it. The API key is sent only to `https://cloud.aere.network`; +the only other base the code accepts is a loopback address, which exists for the local tests. + +## Verifying an attestation yourself + +You need the attestation file, the artifact, your own clone of the attested repository, and `pos.mjs` (Node 18 or +later; run `npm ci` once in `sdk-pq-sign/` next to it): + +``` +node tools/proof-of-software/pos.mjs verify attestation.json --rebuild-from /path/to/your/clone mylib-1.4.2.tgz +``` + +It checks the artifact digest, the statement hash, the hybrid signature if present, that `:` in your +clone is the attested tree, and it packs that tree again and requires the attested digest. It prints `VALID` and exits +0 only when every present claim holds; what is absent (no signature, no notarization) is reported as absent. With an +Aere Cloud key (`--cloud-key-file`) it also reads the notarization proof and its finality from the chain. + +## What it proves, and what it does not + +It proves that the artifact is byte for byte what `npm pack --ignore-scripts` of the named git tree produces (anyone can +repeat that), that the statement was signed by the holder of the signing key (if signed), and that the statement hash +existed on chain 2800 no later than the notarization block (if notarized). + +It does **not** prove: + +- that the source is safe, correct or free of malicious code; only that the artifact comes from that tree; +- who wrote or reviewed the commit; git authorship is not verified; +- that the signing key was used only by your CI; anyone holding the secret can sign; +- anything about the package's dependencies: `npm pack` contains only the package's own files; +- anything about packages that need a build step: the pack runs with `--ignore-scripts`, so `prepare`/`prepack` + scripts do not run and such packages are not supported by the `npm-pack` build yet; +- reproducibility with another npm major version or operating system: the npm version is recorded in the statement so + that a mismatch can be told apart from tampering, but it has not been measured across versions. + +## Requirements and limits + +- A runner with Node 24 for the action itself (`runs.using: node24`) and `npm` and `git` on `PATH`. +- The package must be in a subdirectory of the repository; a package at the repository root is refused by this version. +- When the action's own checkout lacks the dependencies of `sdk-pq-sign`, it installs the versions pinned in + `sdk-pq-sign/package-lock.json` with `npm ci --ignore-scripts` (integrity-checked), which needs access to the npm + registry. +- The verification clone is a full local clone of the checkout; its time grows with the size of the repository. + +## Tests + +``` +node tools/proof-of-software/test/action.test.mjs +``` + +The tests run the action the way a runner does (inputs in `INPUT_*`, `GITHUB_OUTPUT` and `GITHUB_STEP_SUMMARY` in files, +`GITHUB_WORKSPACE` on a scratch git repository) and send notarizations to a local HTTP server on `127.0.0.1`. Each +negative control requires its named reason: an untracked file, HTTP 403, a changed artifact byte, an artifact that does +not come from the tree, and planted secrets that must not appear in any output. diff --git a/tools/proof-of-software/action/action.yml b/tools/proof-of-software/action/action.yml new file mode 100644 index 0000000..3823a3e --- /dev/null +++ b/tools/proof-of-software/action/action.yml @@ -0,0 +1,58 @@ +name: Aere Proof of Software +description: >- + Attest an npm package built from the committed git tree, verify the attestation by rebuilding it from a clean clone + of the commit, optionally sign it (hybrid classical + ML-DSA) and notarize it on Aere Network (chain 2800). +author: Aere Network +branding: + icon: shield + color: blue +inputs: + source-path: + description: >- + Directory of the npm package inside the repository (for example packages/mylib). It must be a subdirectory of + the repository, with no uncommitted or untracked files: the artifact is npm pack of the COMMITTED tree. + required: true + build: + description: Build to record and repeat. This version supports only npm-pack (npm pack of the committed tree). + required: false + default: npm-pack + signing-key: + description: >- + Optional secret. The key file written by `pos.mjs keygen` (JSON, or base64 of it). When given, the statement is + signed with a hybrid signature (classical + ML-DSA, both required to verify). + required: false + aere-api-key: + description: >- + Optional secret. An Aere Cloud API key. When given, and only after the rebuild verification passed, the + statement hash is notarized on chain 2800 (POST https://cloud.aere.network/v1/notarize). Without it the + attestation is not notarized, and the step summary says so. + required: false + verify-rebuild: + description: >- + After attesting, clone the commit into a clean directory, repeat the build there and require the attested + digest byte for byte. The step fails if the artifact is not reproduced. + required: false + default: 'true' + out-dir: + description: >- + Directory for the artifact and the attestation file. Defaults to $RUNNER_TEMP/aere-proof-of-software, outside + the repository, so the run does not dirty the checkout. It may not be inside source-path. + required: false +outputs: + attestation-path: + description: Path of the attestation JSON file (upload it next to the release). + artifact-path: + description: Path of the attested .tgz. Publish this very file (npm publish ), not a new pack. + artifact-sha256: + description: SHA-256 of the artifact, 0x-prefixed hex. + tree: + description: Git tree hash of source-path at the attested commit. + statement-hash: + description: SHA-256 of the statement text; this is the digest that is signed and notarized. + notarized-tx: + description: Transaction hash of the notarization on chain 2800. Set only when the statement hash was notarized. + proof-url: + description: URL of the proof (GET /v1/proof/{statementHash}). Set only when the statement hash was notarized. +runs: + using: node24 + main: index.mjs diff --git a/tools/proof-of-software/action/examples/attest-npm-package.yml b/tools/proof-of-software/action/examples/attest-npm-package.yml new file mode 100644 index 0000000..5283c6d --- /dev/null +++ b/tools/proof-of-software/action/examples/attest-npm-package.yml @@ -0,0 +1,54 @@ +# Attest an npm package on every release tag: npm pack of the COMMITTED tree, attestation, rebuild check from a clean +# clone of the commit, optional hybrid signature, optional notarization on Aere Network (chain 2800). +# +# Repository secrets used (both optional): +# AERE_POS_SIGNING_KEY the key file written by `node tools/proof-of-software/pos.mjs keygen --out keys.json` +# (paste the JSON, or base64 of it). Keep keys.json itself off the repository. +# AERE_API_KEY an Aere Cloud API key; without it the attestation is not notarized, and the summary says so. +name: Attest npm package + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + attest: + runs-on: ubuntu-latest + steps: + # the default fetch-depth (1) is enough: the rebuild needs only the attested commit + - uses: actions/checkout@v4 + + # npm on PATH performs the pack; its version is recorded in the statement + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - id: pos + # Reference this action by its directory in the repository that contains it, pinned to a full commit SHA. + uses: //tools/proof-of-software/action@ + with: + source-path: packages/mylib + signing-key: ${{ secrets.AERE_POS_SIGNING_KEY }} + aere-api-key: ${{ secrets.AERE_API_KEY }} + # verify-rebuild: true (default; the step fails if the artifact is not reproduced byte for byte) + + - uses: actions/upload-artifact@v4 + with: + name: proof-of-software + path: | + ${{ steps.pos.outputs.attestation-path }} + ${{ steps.pos.outputs.artifact-path }} + + # Optional: publish the very file that was attested, never a new pack. + # - run: npm publish "${{ steps.pos.outputs.artifact-path }}" + # env: + # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - run: | + echo "artifact sha256: ${{ steps.pos.outputs.artifact-sha256 }}" + echo "git tree: ${{ steps.pos.outputs.tree }}" + echo "statement hash: ${{ steps.pos.outputs.statement-hash }}" + echo "notarized tx: ${{ steps.pos.outputs.notarized-tx }}" diff --git a/tools/proof-of-software/action/index.mjs b/tools/proof-of-software/action/index.mjs new file mode 100644 index 0000000..40d2679 --- /dev/null +++ b/tools/proof-of-software/action/index.mjs @@ -0,0 +1,357 @@ +#!/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 ${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; }); diff --git a/tools/proof-of-software/pos.mjs b/tools/proof-of-software/pos.mjs new file mode 100644 index 0000000..5da870e --- /dev/null +++ b/tools/proof-of-software/pos.mjs @@ -0,0 +1,556 @@ +#!/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; }); diff --git a/tools/proof-of-software/test/_control.mjs b/tools/proof-of-software/test/_control.mjs new file mode 100644 index 0000000..4b112ae --- /dev/null +++ b/tools/proof-of-software/test/_control.mjs @@ -0,0 +1,46 @@ +// Mecanismul comun al controalelor negative ale Proof of Software 1.3.0: o COPIE (pos.mjs + suita + sdk-pq-sign + sdk, intr-un dosar +// temporar; depozitul nu se atinge), un paznic scos in pos.mjs-ul copiei, suita rulata pe copie. Martorul (copia neatinsa) trebuie +// verde; fiecare plantare trebuie sa inroseasca EXACT proba numita, cu suita chiar rulata (rezumatul ei exista). O ancora care nu +// apare exact o data e un esec al controlului (STRICAT), nu o linie informativa. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RAD = path.resolve(AICI, '..', '..', '..'); + +export function controleaza(suita, plantari) { + function copie() { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-cn-')); + for (const d of ['sdk-pq-sign', 'sdk']) fs.cpSync(path.join(RAD, d), path.join(t, d), { recursive: true }); + fs.mkdirSync(path.join(t, 'tools', 'proof-of-software', 'test'), { recursive: true }); + fs.copyFileSync(path.join(RAD, 'tools', 'proof-of-software', 'pos.mjs'), path.join(t, 'tools', 'proof-of-software', 'pos.mjs')); + fs.copyFileSync(path.join(AICI, suita), path.join(t, 'tools', 'proof-of-software', 'test', suita)); + // 1.4.0: fixturile suitelor (dosarele fixturi-*, de ex. go.mod/go.sum reale) merg si ele in copie + for (const d of fs.readdirSync(AICI).filter((n) => n.startsWith('fixturi') && fs.statSync(path.join(AICI, n)).isDirectory())) { + fs.cpSync(path.join(AICI, d), path.join(t, 'tools', 'proof-of-software', 'test', d), { recursive: true }); + } + return t; + } + function ruleaza(t) { + const r = spawnSync(process.execPath, [path.join(t, 'tools', 'proof-of-software', 'test', suita)], { encoding: 'utf8', timeout: 400000 }); + const out = (r.stdout || '') + (r.stderr || ''); + return { cod: r.status, rulat: /\d+ treceri, \d+ esecuri/.test(out), picate: out.split('\n').filter((l) => l.startsWith(' ESEC')) }; + } + let rele = 0; + const t0 = copie(); const m = ruleaza(t0); fs.rmSync(t0, { recursive: true, force: true }); + if (m.cod === 0 && m.rulat && !m.picate.length) console.log(' OK martorul: copia neatinsa verde'); else { rele++; console.log(` STRICAT martorul nu e verde (cod ${m.cod}, ${m.picate.length} esecuri)`); } + for (const [nume, din, inl, tinta] of plantari) { + const t = copie(); const f = path.join(t, 'tools', 'proof-of-software', 'pos.mjs'); const src = fs.readFileSync(f, 'utf8'); + if (src.split(din).length !== 2) { rele++; console.log(` STRICAT ${nume}: ancora apare de ${src.split(din).length - 1} ori`); fs.rmSync(t, { recursive: true, force: true }); continue; } + fs.writeFileSync(f, src.replace(din, inl)); + const r = ruleaza(t); fs.rmSync(t, { recursive: true, force: true }); + if (!r.rulat) { rele++; console.log(` STRICAT ${nume}: suita nu a ajuns la rezumat (cod ${r.cod})`); continue; } + if (r.picate.some((l) => l.includes(tinta))) console.log(` ROSU cum trebuia ${nume} (proba '${tinta}' pica)`); + else { rele++; console.log(` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde (${r.picate.length} esecuri altundeva)`); } + } + console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `DOVEDIT: martorul verde, ${plantari.length} din ${plantari.length} paznici scosi -> proba lor rosie`); + return rele ? 1 : 0; +} diff --git a/tools/proof-of-software/test/action-dovada.mjs b/tools/proof-of-software/test/action-dovada.mjs new file mode 100644 index 0000000..7d7c73d --- /dev/null +++ b/tools/proof-of-software/test/action-dovada.mjs @@ -0,0 +1,94 @@ +// Dovada ca CONTROALELE NEGATIVE ale actiunii pot iesi rosii din motivul lor: pe o COPIE a actiunii (setul minim de fisiere + +// dependintele fixate, intr-un dosar temporar; depozitul nu se atinge) se scoate cate un paznic cu o conditie falsa la RULARE +// (compileaza, deci un rosu nu poate veni de la sintaxa), se ruleaza test/action.test.mjs pe copie si se cere ca EXACT probele +// paznicului scos sa iasa ESEC, iar toate celelalte OK. +// Trei grupe, fiindca paznicii interactioneaza: fara judecarea notarizarii (grupa B), ecoul cheii din raspunsul 403 nu mai +// ajunge sa fie tiparit, si proba (d) ar ramane verde din alt motiv decat cel masurat; de aceea redactarea (grupa C) se scoate +// singura, cu notarizarea intacta. +// Verdict cu trei valori: VERDE (fiecare paznic scos si-a inrosit proba, restul verde), ROSU (un paznic scos si proba lui a +// ramas verde, sau a picat alta), STRICAT (o ancora de plantare nu s-a gasit exact o data, copia nu compileaza, suita nu a +// rulat pe copie). Un STRICAT e un esec al dovezii, nu o linie informativa. +// node aerenew/tools/proof-of-software/test/action-dovada.mjs +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const RADACINA = path.resolve(AICI, '..', '..', '..'); +const SUITA = path.join(AICI, 'action.test.mjs'); +const FALS = "process.env.AERE_POS_NICIODATA === '1'"; + +const GRUPE = { + A: { + plantari: [ + ['garda fisierelor necomise', ' if (st.out.trim()) {', ` if (st.out.trim() && ${FALS}) {`], + ['refuzul verificarii', ' if (!r.valid) throw new Refuz(', ` if (!r.valid && ${FALS}) throw new Refuz(`], + ['verificarea atestarii recitite', 'if (!r2.valid || inapoi.statementHash', `if ((!r2.valid && ${FALS}) || inapoi.statementHash`], + ['refuzul reconstructiei', " if (picate.some((c) => /^(rebuild|source)/.test(c.name))) throw", ` if (picate.some((c) => /^(rebuild|source)/.test(c.name)) && ${FALS}) throw`], + ], + rosii: ['CONTROL NEGATIV (a):', 'CONTROL NEGATIV (a, varianta)', 'CONTROL NEGATIV (c):', 'CONTROL NEGATIV (c, varianta)'], + }, + // redactarea, singura: (d) o cere, si (b) o cere si ea (mesajul lui 403 trebuie sa arate cheia ecou ca ***) + C: { + plantari: [ + ['redactarea secretelor', 'curata(text) { let t = String(text); for (const r of this.re)', `curata(text) { let t = String(text); for (const r of (${FALS} ? this.re : []))`], + ], + rosii: ['CONTROL NEGATIV (b):', 'CONTROL NEGATIV (d):'], + }, + B: { + plantari: [ + ['clientul care judeca raspunsul (inlocuit cu un fetch brut)', 'try { d = await new AereCloud({ apiKey: apiBrut, baseUrl: apiBase, fetch: f }).notarize(att.statementHash); } catch (e) {', + "try { d = await (await f(apiBase + '/notarize', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': apiBrut }, body: JSON.stringify({ hash: att.statementHash }) })).json(); } catch (e) {"], + ['judecarea codului HTTP', ' if (cod !== 200) throw new Refuz(', ` if (cod !== 200 && ${FALS}) throw new Refuz(`], + ['judecarea chitantei', "|| d.block <= 0) throw new Refuz('notarization answered 200", `|| d.block <= 0) if (${FALS}) throw new Refuz('notarization answered 200`], + ], + rosii: ['CONTROL NEGATIV (b):', 'CONTROL NEGATIV (b, varianta): 200 fara txHash'], + }, +}; + +function copie() { + const L = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-dovada-')); + for (const f of ['tools/proof-of-software/pos.mjs', 'tools/proof-of-software/action/index.mjs', 'tools/proof-of-software/action/action.yml', 'sdk/index.mjs', 'sdk/package.json', 'sdk-pq-sign/index.mjs', 'sdk-pq-sign/package.json', 'sdk-pq-sign/package-lock.json']) { + fs.mkdirSync(path.dirname(path.join(L, f)), { recursive: true }); fs.copyFileSync(path.join(RADACINA, f), path.join(L, f)); + } + fs.cpSync(path.join(RADACINA, 'sdk-pq-sign', 'node_modules'), path.join(L, 'sdk-pq-sign', 'node_modules'), { recursive: true }); + return L; +} + +let verdict = 'VERDE'; +const slab = (v) => { if (v === 'STRICAT' || verdict === 'STRICAT') verdict = 'STRICAT'; else verdict = 'ROSU'; }; +for (const [nume, g] of Object.entries(GRUPE)) { + console.log(`=== grupa ${nume}: ${g.plantari.map((p) => p[0]).join('; ')}`); + const L = copie(); const act = path.join(L, 'tools', 'proof-of-software', 'action', 'index.mjs'); + let src = fs.readFileSync(act, 'utf8'); let bun = true; + for (const [ce, ancora, inlocuire] of g.plantari) { + const n = src.split(ancora).length - 1; + if (n !== 1) { console.log(` STRICAT: ancora pentru "${ce}" apare de ${n} ori (se cere exact o data)`); bun = false; continue; } + src = src.replace(ancora, inlocuire); + } + if (!bun) { slab('STRICAT'); continue; } + fs.writeFileSync(act, src); + const chk = spawnSync(process.execPath, ['--check', act], { encoding: 'utf8' }); + if (chk.status !== 0) { console.log(' STRICAT: copia cu paznicii scosi nu compileaza: ' + (chk.stderr || '').split('\n').slice(0, 3).join(' | ')); slab('STRICAT'); continue; } + const env = { ...process.env, AERE_POS_ACTIUNE_PROBA: act }; delete env.AERE_POS_NICIODATA; + const r = spawnSync(process.execPath, [SUITA], { encoding: 'utf8', env, maxBuffer: 1 << 26, timeout: 900000 }); + const out = r.stdout || ''; + if (!out.includes('actiunea: ' + act)) { console.log(' STRICAT: suita nu a rulat pe copie (nu numeste actiunea copiata)'); slab('STRICAT'); continue; } + const ok = [...out.matchAll(/^ {2}OK {3}(.+)$/gm)].map((m) => m[1]); + // randul intreg (numele probelor contin si ele ': ', deci numele nu se poate taia la primul) + const esec = [...out.matchAll(/^ {2}ESEC (.+)$/gm)].map((m) => m[1]); + if (!/^\d+ treceri, \d+ esecuri$/m.test(out) || ok.length + esec.length < 20) { console.log(` STRICAT: suita nu a terminat (${ok.length} OK, ${esec.length} ESEC citite)`); slab('STRICAT'); continue; } + const eRosie = (t) => g.rosii.some((p) => t.startsWith(p)); + const rosiiLipsa = g.rosii.filter((p) => !esec.some((t) => t.startsWith(p))); + const rosiiStraine = esec.filter((t) => !eRosie(t)); + for (const p of g.rosii) console.log(` ${esec.some((t) => t.startsWith(p)) ? 'ROSIE, cum trebuia' : 'VERDE, deci paznicul scos NU e vazut de proba'}: ${p}`); + for (const m of [...out.matchAll(/^ {2}ESEC (.+)$/gm)].map((x) => x[1])) if (eRosie(m)) console.log(` ${m.slice(0, 40)} ... ${m.slice(-180)}`); + if (rosiiStraine.length) console.log(' au picat si probe care nu tin de paznicii scosi: ' + rosiiStraine.map((x) => x.slice(0, 160)).join(' | ')); + if (rosiiLipsa.length || rosiiStraine.length) slab('ROSU'); + console.log(` grupa ${nume}: ${ok.length} OK, ${esec.length} ESEC (asteptate ${g.rosii.length})`); + try { fs.rmSync(L, { recursive: true, force: true, maxRetries: 3 }); } catch { console.log(' (copia temporara nu s-a putut sterge: ' + L + ')'); } +} +console.log(`\nDOVADA CONTROALELOR NEGATIVE: ${verdict}`); +process.exitCode = verdict === 'VERDE' ? 0 : verdict === 'ROSU' ? 1 : 2; diff --git a/tools/proof-of-software/test/action.test.mjs b/tools/proof-of-software/test/action.test.mjs new file mode 100644 index 0000000..44fc977 --- /dev/null +++ b/tools/proof-of-software/test/action.test.mjs @@ -0,0 +1,442 @@ +// Probele actiunii GitHub Proof of Software, rulata LOCAL ca de un runner: intrarile in INPUT_* (cu liniuta, ca la runner), +// GITHUB_OUTPUT si GITHUB_STEP_SUMMARY in fisiere temporare, GITHUB_WORKSPACE pe un depozit git de proba cu un pachet npm +// mic, comis. Notarizarea merge la un server HTTP LOCAL (node:http pe 127.0.0.1), injectat prin AERE_POS_API_BASE; nicio +// cerere nu pleaca spre cloud.aere.network. Fiecare control negativ cere MOTIVUL numit (mesajul), nu doar un cod nenul. +// node aerenew/tools/proof-of-software/test/action.test.mjs +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import crypto from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as pq from '../../../sdk-pq-sign/index.mjs'; +import { INTRARI, IESIRI, linieIesire, Secrete, taieHex } from '../action/index.mjs'; +import { packNpmFromTree, verify } from '../pos.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const DIR_POS = path.resolve(AICI, '..'); +// AERE_POS_ACTIUNE_PROBA: numai pentru test/action-dovada.mjs, care ruleaza aceasta suita pe o COPIE a actiunii cu un paznic +// scos, ca sa arate ca proba lui iese rosie +const ACTIUNE = process.env.AERE_POS_ACTIUNE_PROBA || path.join(DIR_POS, 'action', 'index.mjs'); +const POS = path.join(DIR_POS, 'pos.mjs'); +const RADACINA = path.resolve(DIR_POS, '..', '..'); + +let treceri = 0; const esecuri = []; const nemasurate = []; +class Nemasurat extends Error {} +async function test(nume, fn) { + try { await fn(); treceri++; console.log(' OK ' + nume); } catch (e) { + if (e instanceof Nemasurat) { nemasurate.push(nume); console.log(' NEMASURAT ' + nume + ': ' + e.message); return; } + esecuri.push(nume); console.log(' ESEC ' + nume + ': ' + String(e.message || e).slice(0, 900)); + } +} +// fiecare dosar temporar se tine minte si se sterge la capat (AERE_POS_PASTREAZA=1 le lasa, pentru depanare) +const facute = []; +const tmp = (p) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), p)); facute.push(d); return d; }; +const sha256 = (p) => '0x' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +const gitIn = (cwd, args) => execFileSync('git', ['-c', 'user.name=proba', '-c', 'user.email=proba@example.invalid', ...args], { cwd, stdio: 'pipe' }).toString().trim(); + +// ---------------------------------------------------------------- depozitul de proba +function depozitDeProba() { + const G = tmp('aere-pos-act-git-'); + gitIn(G, ['init', '-q']); gitIn(G, ['config', 'core.autocrlf', 'false']); + 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-actiune', version: '1.2.3', main: 'lib/index.js', files: ['lib', 'LICENSE', 'README.md'] }, 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, 'pkg', 'README.md'), '# proba\n\nun pachet de proba\n'); + fs.writeFileSync(path.join(G, 'altceva', 'x.txt'), 'nu face parte din pachet\n'); + gitIn(G, ['add', '-A']); gitIn(G, ['commit', '-q', '-m', 'pachet de proba']); + return G; +} + +// ---------------------------------------------------------------- serverul de notarizare, LOCAL +const TX = '0x' + 'ab'.repeat(32); +let modServer = '200'; const cereri = []; +const server = http.createServer((req, res) => { + let b = ''; req.on('data', (c) => { b += c; }); + req.on('end', () => { + cereri.push({ m: req.method, u: req.url, k: req.headers['x-api-key'], b }); + const trimite = (cod, o) => { res.writeHead(cod, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }; + if (req.url === '/v1/leak') return trimite(200, { txHash: '0x' + 'ee'.repeat(32), block: 1 }); + if (req.method !== 'POST' || req.url !== '/v1/notarize') return trimite(404, { error: 'not_found' }); + let h = null; try { h = JSON.parse(b).hash; } catch { /* corp stricat */ } + if (modServer === '200') return trimite(200, { hash: h, txHash: TX, block: 4242, firstSeenAt: 1790000000, firstTime: true, contract: '0x' + '11'.repeat(20), chainId: 2800 }); + if (modServer === '403') return trimite(403, { error: 'invalid api key ' + req.headers['x-api-key'] }); // ecoul cheii: redactarea trebuie sa il taie + if (modServer === '200-fara-tx') return trimite(200, { hash: h }); + if (modServer === '302') { res.writeHead(302, { location: `http://127.0.0.1:${server.address().port}/v1/leak` }); return res.end(); } + return trimite(500, { error: 'mod necunoscut' }); + }); +}); +await new Promise((r) => server.listen(0, '127.0.0.1', r)); +const BAZA = `http://127.0.0.1:${server.address().port}/v1`; + +// ---------------------------------------------------------------- rularea actiunii ca un runner +function parseaza(t) { + const o = {}; const L = t.split('\n'); + for (let i = 0; i < L.length; i++) { + const l = L[i]; if (!l) continue; + const m = /^([^=<]+)<<(.+)$/.exec(l); + if (m) { const v = []; i++; while (i < L.length && L[i] !== m[2]) v.push(L[i++]); assert.ok(!(m[1] in o), 'output written twice: ' + m[1]); o[m[1]] = v.join('\n'); continue; } + const k = l.indexOf('='); assert.ok(k > 0, 'malformed output line: ' + l); assert.ok(!(l.slice(0, k) in o), 'output written twice'); o[l.slice(0, k)] = l.slice(k + 1); + } + return o; +} +function mediuCurat() { const e = {}; for (const [k, v] of Object.entries(process.env)) if (!/^(INPUT_|GITHUB_|RUNNER_|AERE_POS_)/i.test(k)) e[k] = v; return e; } +const toateRularile = []; +async function ruleaza(eticheta, { workspace, inputs = {}, env = {}, actiune = ACTIUNE, faraImplicite = false, secrete = false }) { + const T = tmp('aere-pos-act-run-'); + const OUTF = path.join(T, 'github_output'), SUMF = path.join(T, 'step_summary'); + fs.writeFileSync(OUTF, ''); fs.writeFileSync(SUMF, ''); + // runner-ul pune in INPUT_* implicitele din action.yml; faraImplicite lasa actiunea sa le puna singura + const inp = faraImplicite ? { ...inputs } : { build: 'npm-pack', 'verify-rebuild': 'true', ...inputs }; + const e = { ...mediuCurat(), GITHUB_WORKSPACE: workspace, GITHUB_OUTPUT: OUTF, GITHUB_STEP_SUMMARY: SUMF, RUNNER_TEMP: T, ...env }; + for (const [k, v] of Object.entries(inp)) e['INPUT_' + k.toUpperCase()] = v; + cereri.length = 0; + const rez = await new Promise((res, rej) => { + const c = spawn(process.execPath, [actiune], { cwd: workspace, env: e, stdio: ['ignore', 'pipe', 'pipe'] }); + let o = '', er = ''; c.stdout.on('data', (d) => { o += d; }); c.stderr.on('data', (d) => { er += d; }); + const t = setTimeout(() => c.kill(), 240000); + c.on('error', rej); c.on('close', (cod) => { clearTimeout(t); res({ cod, stdout: o, stderr: er }); }); + }); + const iesiriBrut = fs.readFileSync(OUTF, 'utf8'); + const outDir = path.join(T, 'aere-proof-of-software'); + const r = { eticheta, secrete, ...rez, iesiriBrut, iesiri: parseaza(iesiriBrut), sumar: fs.readFileSync(SUMF, 'utf8'), outDir, cereri: [...cereri], mesaj: rez.stdout + '\n' + rez.stderr }; + toateRularile.push(r); + return r; +} +const fisiereAtestare = (r) => (fs.existsSync(r.outDir) ? fs.readdirSync(r.outDir).filter((f) => f.endsWith('.json')).map((f) => [`fisier ${f}`, fs.readFileSync(path.join(r.outDir, f), 'utf8')]) : []); + +// ---------------------------------------------------------------- secretele plantate, recognoscibile +const CHEI = pq.generateKeyPair({ alg: 'secp256k1+ml-dsa-44', seed: '0x' + 'deadbeef'.repeat(8), classicalSecretKey: '0x' + 'c0ffee00'.repeat(8) }); +const CHEIE_TEXT = JSON.stringify(CHEI, null, 1); +// AERE-SINTETIC: cheie API plantata de proba (forma unei chei Cloud, valoare inventata), ca sa se vada ca nu apare in nicio iesire +const API_KEY = 'ak2800.0x' + '1234abcd'.repeat(5) + '.PLANTEDAPISECRET' + 'f00d'.repeat(8); +const pqBare = CHEI.pq.secretKey.slice(2); +// ATENTIE: primii 32 de octeti ai cheii secrete ML-DSA sunt rho, care e si inceputul cheii PUBLICE (publicata in atestare, +// in semnatura). Deci nu se cauta inceputul cheii secrete: se cauta K (octetii 32..63) si o bucata din s1 (128..159). +const SECRETE_CAUTATE = { + 'aere-api-key intreaga': API_KEY, + 'aere-api-key partea secreta': 'PLANTEDAPISECRET' + 'f00d'.repeat(8), + 'marcajul cheii API': 'PLANTEDAPISECRET', + 'cheia clasica': CHEI.classical.secretKey.slice(2), + 'o bucata din cheia clasica': 'c0ffee00c0ffee00', + 'samanta ML-DSA': 'deadbeefdeadbeef', + 'K din cheia ML-DSA': pqBare.slice(64, 128), + 's1 din cheia ML-DSA': pqBare.slice(256, 320), + 'marcajul cheii stricate': 'PLANTEDBADKEY9c0de', + 'marcajul cheii API cu rand nou': 'PLANTEDNL', +}; +const cautaSecrete = (text) => { const t = String(text).toLowerCase(); return Object.entries(SECRETE_CAUTATE).filter(([, v]) => t.includes(v.toLowerCase())).map(([k]) => k); }; +const faraMasti = (s) => s.split(/\r?\n/).filter((l) => !l.startsWith('::add-mask::')).join('\n'); + +// ================================================================ probele +console.log('actiunea: ' + ACTIUNE); + +await test('action.yml si index.mjs declara ACELEASI intrari (cu implicitele si obligativitatea lor) si iesiri; runs node24 cu index.mjs; index.mjs importa static numai module node:', () => { + const y = fs.readFileSync(path.join(DIR_POS, 'action', 'action.yml'), 'utf8'); + const L = y.split(/\r?\n/); + const bloc = (nume) => { const i = L.indexOf(nume + ':'); assert.ok(i >= 0, 'no ' + nume + ' block'); const out = []; for (let j = i + 1; j < L.length && (L[j].startsWith(' ') || !L[j].trim()); j++) out.push(L[j]); return out; }; + const campuri = (linii) => { const o = {}; let cur = null; for (const l of linii) { const m = /^ {2}([a-z][a-z0-9-]*):/.exec(l); if (m) { cur = m[1]; o[cur] = {}; continue; } const d = /^ {4}(default|required):\s*'?([^']*?)'?\s*$/.exec(l); if (d && cur) o[cur][d[1]] = d[2]; } return o; }; + const intrari = campuri(bloc('inputs')); + assert.deepStrictEqual(Object.keys(intrari).sort(), Object.keys(INTRARI).sort()); + for (const [k, v] of Object.entries(INTRARI)) { + assert.strictEqual(intrari[k].default, v.default, `default of ${k}`); + assert.strictEqual(intrari[k].required === 'true', v.required === true, `required of ${k}`); + } + assert.deepStrictEqual(Object.keys(campuri(bloc('outputs'))).sort(), [...IESIRI].sort()); + assert.match(y, /^ {2}using: node24$/m); assert.match(y, /^ {2}main: index\.mjs$/m); + const src = fs.readFileSync(ACTIUNE, 'utf8'); + const statice = [...src.matchAll(/^import .* from '([^']+)';/gm)].map((m) => m[1]); + assert.ok(statice.length >= 5, 'the import scan found nothing (method control)'); + assert.deepStrictEqual(statice.filter((s) => !s.startsWith('node:')), [], 'static imports outside node:'); + assert.strictEqual([...src.matchAll(/await import\(/g)].length, 2, 'exactly two dynamic imports: pos.mjs and the Cloud client, by relative path'); + for (const f of ['action/index.mjs', 'action/action.yml', 'action/README.md', 'action/examples/attest-npm-package.yml', 'test/action.test.mjs']) { + // caracterul se construieste din cod: scris literal (sau ca secventa de evadare, pe care unealta de scriere o + // transforma in caracter) proba s-ar gasi pe ea insasi + assert.ok(!fs.readFileSync(path.join(DIR_POS, f), 'utf8').includes(String.fromCharCode(0x2014)), 'em-dash in ' + f); + } +}); + +await test('iesirile: nume=valoare pe un rand; o valoare pe mai multe randuri primeste delimitator si se citeste inapoi identic, fara sa poata injecta alta iesire', () => { + assert.strictEqual(linieIesire('a', 'b c'), 'a=b c\n'); + const v = 'rand 1\nrand 2\r\nrand 3'; const l = linieIesire('x', v); + assert.match(l, /^x< { + const s = new Secrete(); s.adauga('0x' + 'AbCd'.repeat(16)); + assert.strictEqual(s.curata('x 0x' + 'abcd'.repeat(16) + ' y'), 'x *** y'); + assert.strictEqual(s.curata('ABCD'.repeat(16)), '***'); + assert.deepStrictEqual(s.adauga('scurt'), []); + assert.strictEqual(taieHex('key ' + 'f'.repeat(64) + ' end'), 'key [long hex removed] end'); + assert.strictEqual(taieHex('abbrev ' + 'a'.repeat(31)), 'abbrev ' + 'a'.repeat(31)); +}); + +const G = depozitDeProba(); +const TGZ_REF = packNpmFromTree(G, 'HEAD:pkg', tmp('aere-pos-act-ref-')); +const SHA_REF = sha256(TGZ_REF); + +const R1 = await ruleaza('pozitiv simplu', { workspace: G, inputs: { 'source-path': 'pkg' } }); +await test('POZITIV: atestare + reconstructie dintr-o clona curata; iesirile scrise corect; nesemnat si NEnotarizat, iar rezumatul o spune', () => { + assert.strictEqual(R1.cod, 0, R1.mesaj); + const o = R1.iesiri; + assert.deepStrictEqual(Object.keys(o).sort(), ['artifact-path', 'artifact-sha256', 'attestation-path', 'statement-hash', 'tree']); + assert.ok(fs.existsSync(o['artifact-path']) && fs.existsSync(o['attestation-path'])); + assert.strictEqual(o['artifact-sha256'], sha256(o['artifact-path'])); + assert.strictEqual(o['artifact-sha256'], SHA_REF, 'the artifact is npm pack of the committed tree'); + assert.strictEqual(o.tree, gitIn(G, ['rev-parse', 'HEAD:pkg'])); + const att = JSON.parse(fs.readFileSync(o['attestation-path'], 'utf8')); + assert.strictEqual(att.statementHash, o['statement-hash']); assert.strictEqual(att.statement.artifacts[0].sha256, o['artifact-sha256']); + assert.strictEqual(att.statement.source.path, 'pkg'); assert.strictEqual(att.statement.source.pathDirty, false); + assert.strictEqual(att.statement.source.commit, gitIn(G, ['rev-parse', 'HEAD'])); assert.strictEqual(att.statement.build.kind, 'npm-pack'); + assert.strictEqual(att.signature, null); assert.strictEqual(att.notarization, null); + assert.match(R1.stdout, /OK {3}rebuild: npm pack of the attested tree reproduces proba-actiune-1\.2\.3\.tgz byte for byte/); + assert.match(R1.sumar, /\*\*NOT notarized\*\*/); assert.match(R1.sumar, /\*\*unsigned\*\*/); assert.match(R1.sumar, /reproduced byte for byte from a clean clone/); + assert.strictEqual(R1.cereri.length, 0, 'no request is made without an API key'); +}); + +await test('un STRAIN verifica atestarea iesita cu `pos.mjs verify --rebuild-from` pe clona LUI: VALID, reconstructia reproduce artefactul', () => { + const C = tmp('aere-pos-act-strain-'); gitIn(os.tmpdir(), ['clone', '-q', G, C]); + const out = execFileSync(process.execPath, [POS, 'verify', R1.iesiri['attestation-path'], '--rebuild-from', C, R1.iesiri['artifact-path']], { stdio: 'pipe' }).toString(); + assert.match(out, /OK {3}rebuild: npm pack of the attested tree reproduces/); assert.match(out, /VALID: every present claim holds/); +}); + +modServer = '200'; +const R2 = await ruleaza('semnat + notarizat', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': CHEIE_TEXT, 'aere-api-key': API_KEY }, env: { AERE_POS_API_BASE: BAZA }, secrete: true }); +await test('POZITIV: semnat hibrid + notarizat (serverul raspunde 200): notarized-tx si proof-url, chitanta in atestare, o singura cerere cu cheia si hash-ul corect; pos.mjs verify citeste chitanta', async () => { + assert.strictEqual(R2.cod, 0, R2.mesaj); + const o = R2.iesiri; const att = JSON.parse(fs.readFileSync(o['attestation-path'], 'utf8')); + assert.strictEqual(o['notarized-tx'], TX); assert.strictEqual(o['proof-url'], `${BAZA}/proof/${att.statementHash}`); + assert.strictEqual(R2.cereri.length, 1); const c = R2.cereri[0]; + assert.strictEqual(c.m, 'POST'); assert.strictEqual(c.u, '/v1/notarize'); assert.strictEqual(c.k, API_KEY); + assert.deepStrictEqual(JSON.parse(c.b), { hash: att.statementHash }); + assert.strictEqual(att.notarization.txHash, TX); assert.strictEqual(att.notarization.block, 4242); assert.strictEqual(att.notarization.proof, '/v1/proof/' + att.statementHash); + assert.strictEqual(att.signature.alg, 'secp256k1+ml-dsa-44'); assert.strictEqual(att.signature.classicalPublicKey, CHEI.classical.publicKey); + assert.strictEqual(o['artifact-sha256'], SHA_REF, 'signing does not change the artifact'); + const lant = { proof: async () => ({ notarized: true, block: 4242, txHash: TX, finality: 'post-quantum', pqAnchor: null }) }; + const r = await verify(att, [o['artifact-path']], { cloud: lant }); + assert.strictEqual(r.valid, true, JSON.stringify(r.checks)); + assert.ok(r.checks.find((x) => x.name.startsWith('hybrid signature') && x.pass === true)); + assert.ok(r.checks.find((x) => x.name.startsWith('on chain: first appearance matches the receipt') && x.pass === true)); + const r0 = await verify(att, [o['artifact-path']], { cloud: { proof: async () => ({ notarized: true, block: 4243, txHash: TX, finality: 'post-quantum' }) } }); + assert.strictEqual(r0.valid, false, 'control: a chain answering another block must make it INVALID'); + assert.match(R2.sumar, /tx `0xabab/); assert.doesNotMatch(R2.sumar, /NOT notarized/); +}); + +const SH = tmp('aere-pos-act-shallow-'); gitIn(os.tmpdir(), ['clone', '-q', '--depth', '1', pathToFileURL(G).href, SH]); +const R3 = await ruleaza('clona superficiala, implicitele actiunii', { workspace: SH, inputs: { 'source-path': 'pkg' }, faraImplicite: true }); +await test('POZITIV: spatiu de lucru SUPERFICIAL (ca actions/checkout implicit, fetch-depth 1) si fara INPUT_BUILD/INPUT_VERIFY-REBUILD: implicitele din cod, acelasi artefact', () => { + assert.strictEqual(gitIn(SH, ['rev-parse', '--is-shallow-repository']), 'true', 'control: the workspace really is shallow'); + assert.strictEqual(R3.cod, 0, R3.mesaj); + assert.match(R3.stdout, /OK {3}rebuild: npm pack of the attested tree reproduces/); + assert.strictEqual(R3.iesiri['artifact-sha256'], SHA_REF); +}); + +const DH = tmp('aere-pos-act-detasat-'); gitIn(os.tmpdir(), ['clone', '-q', G, DH]); gitIn(DH, ['config', 'core.autocrlf', 'false']); +gitIn(DH, ['checkout', '-q', '--detach']); fs.writeFileSync(path.join(DH, 'pkg', 'lib', 'index.js'), 'module.exports = 3;\n'); gitIn(DH, ['commit', '-q', '-am', 'commit detasat']); +const R4 = await ruleaza('HEAD detasat', { workspace: DH, inputs: { 'source-path': 'pkg' } }); +await test('POZITIV: HEAD detasat pe un commit care nu e pe nicio ramura (ca un checkout de pull request): clona curata il contine si reconstructia trece', () => { + assert.strictEqual(gitIn(DH, ['for-each-ref', '--contains', 'HEAD', 'refs/heads']), '', 'control: no branch contains the commit'); + assert.strictEqual(R4.cod, 0, R4.mesaj); + const att = JSON.parse(fs.readFileSync(R4.iesiri['attestation-path'], 'utf8')); + assert.strictEqual(att.statement.source.commit, gitIn(DH, ['rev-parse', 'HEAD'])); + assert.notStrictEqual(R4.iesiri['artifact-sha256'], SHA_REF, 'other content, other artifact'); + assert.strictEqual(R4.iesiri['artifact-sha256'], sha256(packNpmFromTree(DH, 'HEAD:pkg', tmp('aere-pos-act-ref2-')))); +}); + +const W = tmp('aere-pos-act-crlf-'); gitIn(os.tmpdir(), ['clone', '-q', '-c', 'core.autocrlf=true', G, W]); +const R5 = await ruleaza('copie de lucru CRLF', { workspace: W, inputs: { 'source-path': 'pkg' } }); +await test('POZITIV: copie de lucru cu CRLF (core.autocrlf=true, ca un runner Windows): nu e "murdara", iar artefactul e acelasi, din octetii comisi', () => { + assert.ok(fs.readFileSync(path.join(W, 'pkg', 'LICENSE'), 'utf8').includes('\r\n'), 'control: the checkout really has CRLF'); + assert.strictEqual(R5.cod, 0, R5.mesaj); + assert.strictEqual(R5.iesiri['artifact-sha256'], SHA_REF); +}); + +fs.appendFileSync(path.join(G, 'altceva', 'x.txt'), 'schimbat, in afara pachetului\n'); +const R6 = await ruleaza('schimbare in afara source-path', { workspace: G, inputs: { 'source-path': 'pkg' } }); +gitIn(G, ['checkout', '-q', '--', 'altceva/x.txt']); +await test('POZITIV (marginea gardei): o schimbare necomisa IN AFARA source-path nu opreste atestarea; declaratia spune dirty=true, pathDirty=false', () => { + assert.strictEqual(R6.cod, 0, R6.mesaj); + const att = JSON.parse(fs.readFileSync(R6.iesiri['attestation-path'], 'utf8')); + assert.strictEqual(att.statement.source.dirty, true); assert.strictEqual(att.statement.source.pathDirty, false); + assert.strictEqual(R6.iesiri['artifact-sha256'], SHA_REF); +}); + +const R7 = await ruleaza('cheie base64', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': Buffer.from(CHEIE_TEXT).toString('base64'), 'verify-rebuild': 'false' }, secrete: true }); +await test('POZITIV: cheia de semnare data ca base64 al fisierului keygen; cu verify-rebuild=false rezumatul spune ca reconstructia NU e verificata', () => { + assert.strictEqual(R7.cod, 0, R7.mesaj); + const att = JSON.parse(fs.readFileSync(R7.iesiri['attestation-path'], 'utf8')); + assert.strictEqual(att.signature.classicalPublicKey, CHEI.classical.publicKey); + assert.match(R7.sumar, /\*\*NOT verified\*\*: verify-rebuild is false/); + assert.doesNotMatch(R7.stdout, /rebuild: npm pack of the attested tree reproduces/); +}); + +// ---------------------------------------------------------------- controalele negative +fs.writeFileSync(path.join(G, 'pkg', 'nou.txt'), 'necomis\n'); +const Ra = await ruleaza('(a) fisier neurmarit', { workspace: G, inputs: { 'source-path': 'pkg' } }); +fs.rmSync(path.join(G, 'pkg', 'nou.txt')); +await test('CONTROL NEGATIV (a): un fisier neurmarit in source-path -> refuz cu motivul si fisierul numite; fara iesiri, fara artefact', () => { + assert.notStrictEqual(Ra.cod, 0); + assert.match(Ra.mesaj, /source-path "pkg" has uncommitted or untracked files/); assert.match(Ra.stderr, /\?\? pkg\/nou\.txt/); + assert.match(Ra.stdout, /^::error::source-path "pkg" has uncommitted or untracked files/m); + assert.strictEqual(Ra.iesiriBrut, ''); assert.ok(!fs.existsSync(Ra.outDir) || !fs.readdirSync(Ra.outDir).length); +}); +fs.appendFileSync(path.join(G, 'pkg', 'lib', 'index.js'), '// schimbat si necomis\n'); +const Ra2 = await ruleaza('(a) fisier modificat', { workspace: G, inputs: { 'source-path': 'pkg' } }); +gitIn(G, ['checkout', '-q', '--', 'pkg/lib/index.js']); +await test('CONTROL NEGATIV (a, varianta): un fisier urmarit modificat si necomis in source-path -> refuz, cu fisierul numit', () => { + assert.notStrictEqual(Ra2.cod, 0); + assert.match(Ra2.mesaj, /has uncommitted or untracked files/); assert.match(Ra2.stderr, / M pkg\/lib\/index\.js/); + assert.strictEqual(Ra2.iesiriBrut, ''); +}); + +modServer = '403'; +const Rb = await ruleaza('(b) 403', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': CHEIE_TEXT, 'aere-api-key': API_KEY }, env: { AERE_POS_API_BASE: BAZA }, secrete: true }); +await test('CONTROL NEGATIV (b): serverul de notarizare raspunde 403 -> actiunea cade, spune HTTP 403 si NOT notarized, nu scrie proof-url; cererea a ajuns (refuzul e pe RASPUNS)', () => { + assert.notStrictEqual(Rb.cod, 0); + assert.match(Rb.mesaj, /notarization refused: HTTP 403 \(invalid api key \*\*\*\); the attestation is NOT notarized/); + assert.strictEqual(Rb.cereri.length, 1, 'the request reached the server'); + assert.ok(!('proof-url' in Rb.iesiri) && !('notarized-tx' in Rb.iesiri)); assert.strictEqual(Rb.iesiriBrut, ''); + assert.match(Rb.sumar, /The attestation is NOT notarized\./); assert.doesNotMatch(Rb.sumar, /proof:/); + const ramas = fisiereAtestare(Rb); assert.strictEqual(ramas.length, 1); assert.strictEqual(JSON.parse(ramas[0][1]).notarization, null); +}); +modServer = '200-fara-tx'; +const Rb2 = await ruleaza('(b) 200 fara txHash', { workspace: G, inputs: { 'source-path': 'pkg', 'aere-api-key': API_KEY, 'verify-rebuild': 'false' }, env: { AERE_POS_API_BASE: BAZA }, secrete: true }); +await test('CONTROL NEGATIV (b, varianta): 200 fara txHash si bloc -> cade; un 200 gol nu e o notarizare', () => { + assert.notStrictEqual(Rb2.cod, 0); + assert.match(Rb2.mesaj, /notarization answered 200 without a transaction hash and a block; the attestation is NOT notarized/); + assert.strictEqual(Rb2.iesiriBrut, ''); +}); +modServer = '302'; +const Rb3 = await ruleaza('(b) redirectare', { workspace: G, inputs: { 'source-path': 'pkg', 'aere-api-key': API_KEY, 'verify-rebuild': 'false' }, env: { AERE_POS_API_BASE: BAZA }, secrete: true }); +await test('CONTROL NEGATIV (b, varianta): o redirectare 302 NU se urmeaza (ar duce x-api-key in alta parte) -> cade; tinta redirectarii nu primeste nimic', () => { + assert.notStrictEqual(Rb3.cod, 0); + assert.match(Rb3.mesaj, /notarization request failed: .*redirect/); + assert.strictEqual(Rb3.cereri.filter((c) => c.u === '/v1/leak').length, 0); assert.strictEqual(Rb3.cereri.length, 1); + assert.strictEqual(Rb3.iesiriBrut, ''); +}); +const Rb4 = await ruleaza('(b) baza API straina', { workspace: G, inputs: { 'source-path': 'pkg', 'aere-api-key': API_KEY }, env: { AERE_POS_API_BASE: 'https://example.invalid/v1' }, secrete: true }); +await test('CONTROL NEGATIV (b, varianta): AERE_POS_API_BASE spre o gazda care nu e loopback -> refuz inainte de orice cerere', () => { + assert.notStrictEqual(Rb4.cod, 0); + assert.match(Rb4.mesaj, /AERE_POS_API_BASE may point only to a loopback address/); + assert.strictEqual(Rb4.iesiriBrut, ''); +}); + +modServer = '200'; +const Rc = await ruleaza('(c) artefact alterat dupa atestare', { workspace: G, inputs: { 'source-path': 'pkg', 'aere-api-key': API_KEY }, env: { AERE_POS_TEST_HOOK: 'alter-after-attest', AERE_POS_API_BASE: BAZA }, secrete: true }); +await test('CONTROL NEGATIV (c): un octet schimbat in artefact intre atestare si verificare -> verificarea cade pe digestul artefactului; nimic nu se notarizeaza', () => { + assert.notStrictEqual(Rc.cod, 0); + assert.match(Rc.mesaj, /verification of the fresh attestation failed; nothing is notarized/); + assert.match(Rc.mesaj, /FAIL artifact proba-actiune-1\.2\.3\.tgz: sha256 and size match \(got 0x[0-9a-f]{64}\)/); + assert.match(Rc.stdout, /TEST HOOK ACTIVE: alter-after-attest/); + assert.match(Rc.stdout, /OK {3}rebuild: npm pack of the attested tree reproduces/, 'the rebuild itself held: the failure is the one named'); + assert.strictEqual(Rc.cereri.length, 0, 'nothing was sent for notarization'); assert.strictEqual(Rc.iesiriBrut, ''); +}); +const Rc2 = await ruleaza('(c) artefact strain de arbore', { workspace: G, inputs: { 'source-path': 'pkg' }, env: { AERE_POS_TEST_HOOK: 'foreign-artifact' } }); +await test('CONTROL NEGATIV (c, varianta): un artefact care NU vine din arborele comis (alterat inainte de atestare) -> digestul tine, reconstructia NU reproduce, refuz', () => { + assert.notStrictEqual(Rc2.cod, 0); + assert.match(Rc2.mesaj, /does NOT reproduce the artifact byte for byte; nothing is notarized/); + assert.match(Rc2.mesaj, /FAIL rebuild: npm pack of the attested tree reproduces proba-actiune-1\.2\.3\.tgz byte for byte \(got 0x[0-9a-f]{64}/); + assert.match(Rc2.stdout, /OK {3}artifact proba-actiune-1\.2\.3\.tgz: sha256 and size match/); + assert.strictEqual(Rc2.iesiriBrut, ''); +}); +const Rc3 = await ruleaza('(c) artefact strain, fara reconstructie', { workspace: G, inputs: { 'source-path': 'pkg', 'verify-rebuild': 'false' }, env: { AERE_POS_TEST_HOOK: 'foreign-artifact' } }); +await test('perechea lui (c, varianta): ACELASI artefact strain cu verify-rebuild=false TRECE (numai digesturile), si rezumatul spune NOT verified: reconstructia e singura care il prinde', () => { + assert.strictEqual(Rc3.cod, 0, Rc3.mesaj); + assert.match(Rc3.sumar, /\*\*NOT verified\*\*/); assert.notStrictEqual(Rc3.iesiri['artifact-sha256'], SHA_REF); +}); + +const cheieRea = '{"alg":"secp256k1+ml-dsa-44","classical":{"secretKey":"0xPLANTEDBADKEY9c0de' + 'ab'.repeat(20); +const Rd1 = await ruleaza('(d) cheie JSON stricat', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': cheieRea }, secrete: true }); +// AERE-SINTETIC: o cheie de semnare de alta forma, inventata pentru proba de refuz +const Rd2 = await ruleaza('(d) cheie de alta forma', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': JSON.stringify({ alg: 'x', secret: 'PLANTEDBADKEY9c0de-forma' }) }, secrete: true }); +const alta = pq.generateKeyPair({ alg: 'secp256k1+ml-dsa-44' }); +const Rd3 = await ruleaza('(d) cheie publica nepotrivita', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': JSON.stringify({ ...CHEI, classical: { ...CHEI.classical, publicKey: alta.classical.publicKey } }), 'verify-rebuild': 'false' }, secrete: true }); +const Rd4 = await ruleaza('(d) cheie API cu rand nou', { workspace: G, inputs: { 'source-path': 'pkg', 'aere-api-key': 'ak2800.0x' + '9876fedc'.repeat(5) + '.PLANTEDNL' + 'beef'.repeat(4) + '\nsecond-line-value' }, env: { AERE_POS_API_BASE: BAZA }, secrete: true }); +await test('intrarile secrete stricate sunt refuzate cu motivul numit (JSON stricat, alta forma, cheie publica nepotrivita, rand nou in cheia API), fara sa trimita nimic', () => { + assert.notStrictEqual(Rd1.cod, 0); assert.match(Rd1.mesaj, /signing-key is not valid JSON \(nor base64 of JSON\); its content is not shown/); + assert.notStrictEqual(Rd2.cod, 0); assert.match(Rd2.mesaj, /signing-key is JSON but not a key file written by `pos\.mjs keygen`/); + assert.notStrictEqual(Rd3.cod, 0); assert.match(Rd3.mesaj, /attestation or signing failed: classical public key does not match the secret key/); + assert.notStrictEqual(Rd4.cod, 0); assert.match(Rd4.mesaj, /aere-api-key contains a line break or a control character; nothing was sent/); assert.strictEqual(Rd4.cereri.length, 0); + for (const r of [Rd1, Rd2, Rd3, Rd4]) assert.strictEqual(r.iesiriBrut, '', r.eticheta); +}); + +await test('CONTROL NEGATIV (d): cheia de semnare si cheia API plantate NU apar in stdout (in afara comenzilor ::add-mask::), stderr, GITHUB_OUTPUT, GITHUB_STEP_SUMMARY, nici in fisierele de atestare, in NICIO rulare cu secrete (reusite sau cazute)', () => { + // controlul pozitiv al metodei: cautarea gaseste ce e sigur acolo + assert.ok(cautaSecrete('x ' + API_KEY + ' y').includes('aere-api-key intreaga')); + assert.ok(cautaSecrete(CHEIE_TEXT.toUpperCase()).includes('K din cheia ML-DSA'), 'the search is case-insensitive'); + assert.strictEqual(pqBare.slice(0, 64), CHEI.pq.publicKey.slice(2, 66), 'rho: the start of the ML-DSA secret key IS public (why it is not searched)'); + const cuSecrete = toateRularile.filter((r) => r.secrete); + assert.ok(cuSecrete.length >= 10, 'runs with planted secrets: ' + cuSecrete.length); + const gasite = []; + for (const r of cuSecrete) { + assert.ok(/^::add-mask::/m.test(r.stdout), r.eticheta + ': no ::add-mask::'); + const locuri = [['stdout', faraMasti(r.stdout)], ['stderr', r.stderr], ['GITHUB_OUTPUT', r.iesiriBrut], ['GITHUB_STEP_SUMMARY', r.sumar], ...fisiereAtestare(r)]; + for (const [loc, text] of locuri) { const g = cautaSecrete(text); if (g.length) gasite.push(`${r.eticheta} / ${loc}: ${g.join(', ')}`); } + } + assert.deepStrictEqual(gasite, []); + // mastile chiar poarta secretele: altfel runner-ul nu ar avea ce ascunde, si cautarea de mai sus ar fi fost pe un stdout gol + const masti = (r) => r.stdout.split(/\r?\n/).filter((l) => l.startsWith('::add-mask::')).join('\n'); + for (const k of ['aere-api-key intreaga', 'aere-api-key partea secreta', 'cheia clasica', 'K din cheia ML-DSA', 'samanta ML-DSA']) assert.ok(cautaSecrete(masti(R2)).includes(k), 'mask missing: ' + k); + assert.ok(cautaSecrete(masti(Rd1)).includes('marcajul cheii stricate'), 'a key that does not parse is still masked'); + assert.ok(cautaSecrete(Rb.cereri.length ? JSON.stringify(Rb.cereri[0]) : '').includes('aere-api-key intreaga'), 'control: the 403 server did receive (and echo) the planted key'); +}); + +const Re = await ruleaza('(e) build necunoscut', { workspace: G, inputs: { 'source-path': 'pkg', build: 'docker' } }); +const Rf = await ruleaza('(f) boolean gresit', { workspace: G, inputs: { 'source-path': 'pkg', 'verify-rebuild': 'yes' } }); +const Rg = await ruleaza('(g) carlig pe runner', { workspace: G, inputs: { 'source-path': 'pkg' }, env: { GITHUB_ACTIONS: 'true', AERE_POS_TEST_HOOK: 'alter-after-attest' } }); +const Rh = await ruleaza('(h) radacina depozitului', { workspace: G, inputs: { 'source-path': '.' } }); +const Ri = await ruleaza('(i) out-dir in source-path', { workspace: G, inputs: { 'source-path': 'pkg', 'out-dir': 'pkg/out' } }); +const Rj = await ruleaza('(j) source-path lipsa', { workspace: G, inputs: {} }); +await test('refuzurile intrarilor, fiecare cu motivul lui: build necunoscut, boolean gresit, carlig de proba pe un runner, radacina depozitului, out-dir in source-path, source-path lipsa', () => { + const cere = (r, re) => { assert.notStrictEqual(r.cod, 0, r.eticheta); assert.match(r.mesaj, re, r.eticheta); assert.strictEqual(r.iesiriBrut, '', r.eticheta); }; + cere(Re, /build "docker" is not supported/); + cere(Rf, /input verify-rebuild must be true or false/); + cere(Rg, /AERE_POS_TEST_HOOK is a switch for the local tests and is refused on a GitHub runner/); + cere(Rh, /source-path is the repository root: this version attests a package in a SUBDIRECTORY/); + cere(Ri, /out-dir is inside source-path/); + cere(Rj, /source-path is required/); + assert.ok(!fs.existsSync(path.join(G, 'pkg', 'out')), 'the refused run left nothing inside source-path'); +}); + +// ---------------------------------------------------------------- setul minim de fisiere (ce descarca un runner) si dependintele +function aspectMinim() { + const L = tmp('aere-pos-act-aspect-'); + for (const f of ['tools/proof-of-software/pos.mjs', 'tools/proof-of-software/action/index.mjs', 'tools/proof-of-software/action/action.yml', 'sdk/index.mjs', 'sdk/package.json', 'sdk-pq-sign/index.mjs', 'sdk-pq-sign/package.json', 'sdk-pq-sign/package-lock.json']) { + fs.mkdirSync(path.dirname(path.join(L, f)), { recursive: true }); fs.copyFileSync(path.join(RADACINA, f), path.join(L, f)); + } + return L; +} +const L1 = aspectMinim(); +const Rk = await ruleaza('dependinte lipsa, npm ci offline', { workspace: G, inputs: { 'source-path': 'pkg' }, actiune: path.join(L1, 'tools', 'proof-of-software', 'action', 'index.mjs'), env: { AERE_POS_TEST_HOOK: 'npm-offline' } }); +await test('dependinte LIPSA (checkout-ul actiunii fara node_modules, ca pe un runner): actiunea incearca npm ci din package-lock.json; niciodata un ERR_MODULE_NOT_FOUND brut', () => { + assert.doesNotMatch(Rk.mesaj, /ERR_MODULE_NOT_FOUND|Cannot find package/); + assert.match(Rk.stdout, /installing the pinned dependencies of sdk-pq-sign from its package-lock\.json/); + if (Rk.cod === 0) { assert.match(Rk.stdout, /dependencies installed/); assert.strictEqual(Rk.iesiri['artifact-sha256'], SHA_REF); return; } + assert.match(Rk.mesaj, /could not install the pinned dependencies of sdk-pq-sign \(npm ci from its package-lock\.json\)/); + assert.strictEqual(Rk.iesiriBrut, ''); + if (!/ENOTCACHED|only-if-cached/.test(Rk.mesaj)) throw new Error('npm ci failed for another reason than an empty offline cache: ' + Rk.stderr.slice(0, 300)); + nemasurate.push('instalarea reala a dependintelor pe un runner (npm ci din registru): in proba --offline, cache-ul local nu are pachetele, refuzul iese curat'); +}); +const L2 = aspectMinim(); +fs.cpSync(path.join(RADACINA, 'sdk-pq-sign', 'node_modules'), path.join(L2, 'sdk-pq-sign', 'node_modules'), { recursive: true }); +const Rl = await ruleaza('set minim + dependinte', { workspace: G, inputs: { 'source-path': 'pkg', 'signing-key': CHEIE_TEXT }, actiune: path.join(L2, 'tools', 'proof-of-software', 'action', 'index.mjs'), secrete: true }); +await test('POZITIV: numai cele 8 fisiere (pos.mjs, action/, sdk, sdk-pq-sign) + dependintele fixate ajung: atestare semnata + reconstructie, acelasi artefact', () => { + assert.strictEqual(Rl.cod, 0, Rl.mesaj); + assert.match(Rl.stdout, /dependencies present/); + assert.strictEqual(Rl.iesiri['artifact-sha256'], SHA_REF); + assert.match(Rl.stdout, /OK {3}rebuild: npm pack of the attested tree reproduces/); +}); + +server.close(); +if (process.env.AERE_POS_PASTREAZA !== '1') { + let ramase = 0; + for (const d of facute) { try { fs.rmSync(d, { recursive: true, force: true, maxRetries: 3 }); } catch { ramase++; } } + console.log(`curatenie: ${facute.length - ramase} dosare temporare sterse, ${ramase} ramase`); +} +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +if (nemasurate.length) console.log(`NEMASURAT (${nemasurate.length}): ${nemasurate.join(' | ')}`); +if (esecuri.length) process.exitCode = 1; diff --git a/tools/proof-of-software/test/credential-control-negativ.mjs b/tools/proof-of-software/test/credential-control-negativ.mjs new file mode 100644 index 0000000..aad31a4 --- /dev/null +++ b/tools/proof-of-software/test/credential-control-negativ.mjs @@ -0,0 +1,14 @@ +// Controlul negativ al identitatii constructorului (credential.test.mjs), prin mecanismul comun din _control.mjs: fiecare paznic +// scos intr-o copie trebuie sa inroseasca exact proba lui. +// node aerenew/tools/proof-of-software/test/credential-control-negativ.mjs +import { controleaza } from './_control.mjs'; + +process.exitCode = controleaza('credential.test.mjs', [ + ['radacina de incredere nu se mai compara', "keyId(publicKeysOf(trustIssuer)) === keyId(b.issuer && b.issuer.keys)", 'true', '2. CONTROL'], + ['semnatarul acreditarii nu mai trebuie sa fie emitentul numit', 'sv.valid && semnatarEmitent,', 'sv.valid,', '6. CONTROL'], + ['cheile subiectului nu se mai compara cu semnatarul declaratiei', "!!att.signature && keyId(publicKeysOf(att.signature)) === keyId(b.subject && b.subject.keys)", '!!att.signature', '7. CONTROL'], + ['valabilitatea nu se mai judeca', 'Date.parse(t) >= Date.parse(b.validFrom) && Date.parse(t) <= Date.parse(b.validUntil)', 'true', '8. CONTROL'], + ['revocarile emitentului se ignora', 'if (rb.credential !== want) continue;', 'continue;', '9. revocarea'], + ['timpul lantului se ignora (bate data declarata)', 'const t = chainTime || att.statement.createdAt;', 'const t = att.statement.createdAt;', '10. timpul'], + ['data declarata achita din nou (B-18)', 'const declarat = !chainTime;', 'const declarat = false;', '12. B-18'], +]); diff --git a/tools/proof-of-software/test/credential.test.mjs b/tools/proof-of-software/test/credential.test.mjs new file mode 100644 index 0000000..97d40a5 --- /dev/null +++ b/tools/proof-of-software/test/credential.test.mjs @@ -0,0 +1,119 @@ +// Proof of Software 1.3.0: identitatea constructorului (acreditarea de dezvoltator emisa de o organizatie). Fiecare afirmatie cu +// perechea ei negativa. Offline: lantul e un obiect `cloud` injectat care raspunde ca Aere Cloud (numai pentru timpul de pe lant). +// node test/credential.test.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { attest, verify, issueCredential, revokeCredential, publicKeysOf } from '../pos.mjs'; +import * as pq from '../../../sdk-pq-sign/index.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +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 cere = (c, m) => { if (!c) throw new Error(m); }; +const check = (r, re) => r.checks.find((c) => re.test(c.name)); + +const D = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-cred-')); +const A = path.join(D, 'app.tgz'); fs.writeFileSync(A, 'artifact bytes'); +const org = pq.generateKeyPair(), alt = pq.generateKeyPair(), dev = pq.generateKeyPair(), strain = pq.generateKeyPair(); +const cred = issueCredential({ issuerKeys: org, issuerName: 'Example Org', subjectKeys: publicKeysOf(dev), subjectName: 'Ana Dev', validFrom: '2026-01-01T00:00:00Z', validUntil: '2027-01-01T00:00:00Z' }); +const acum = new Date('2026-06-01T00:00:00Z'); +const att = await attest({ artifacts: [A], name: 'app', version: '1.0.0', keys: dev, credential: cred, cwd: D, now: acum }); + +await test('1. atestare sub acreditare, verificata cu radacina de incredere a organizatiei: VALIDA, identitatea judecata pe fiecare punct', async () => { + const r = await verify(att, [A], { trustIssuer: publicKeysOf(org) }); + cere(r.valid, JSON.stringify(r.checks.filter((c) => c.pass === false))); + for (const re of [/carries the credential/, /signed by the issuer/, /trust root you gave/, /names the keys that signed/]) cere(check(r, re) && check(r, re).pass === true, 'lipseste sau nu trece: ' + re); + cere(/createdAt, declared by the builder/.test(check(r, /valid at/).name), 'timpul folosit trebuie numit'); + // 2026-09-29 (B-18): pe data declarata, "in valabilitate" nu se poate crede (o alege cine tine cheile): nejudecat, nu trecut + cere(check(r, /valid at/).pass === null, 'pe data declarata, valabilitatea trebuie raportata nejudecata'); +}); +await test('2. CONTROL: alta radacina de incredere (alta organizatie) -> INVALIDA', async () => { + const r = await verify(att, [A], { trustIssuer: publicKeysOf(alt) }); + cere(!r.valid && check(r, /trust root/).pass === false, 'trebuia sa pice pe emitent'); +}); +await test('3. fara radacina de incredere: emitentul NU e judecat, si se spune (nu e trecut drept incredere)', async () => { + const r = await verify(att, [A]); + cere(check(r, /issuer trust/) && check(r, /issuer trust/).pass === null, 'trebuia raportat nejudecat'); +}); +await test('4. CONTROL: attest cu o acreditare care numeste alte chei decat --keys e refuzat', async () => { + let aruncat = null; try { await attest({ artifacts: [A], keys: strain, credential: cred, cwd: D }); } catch (e) { aruncat = e.message; } + cere(/other keys/.test(aruncat || ''), 'trebuia refuzat: ' + aruncat); +}); +await test('5. CONTROL: acreditarea inlocuita in atestare (declaratia numeste alta) -> INVALIDA', async () => { + const alta = issueCredential({ issuerKeys: org, issuerName: 'Example Org', subjectKeys: publicKeysOf(dev), subjectName: 'Altcineva', validFrom: '2026-01-01T00:00:00Z', validUntil: '2027-01-01T00:00:00Z' }); + const r = await verify({ ...att, credential: alta }, [A], { trustIssuer: publicKeysOf(org) }); + cere(!r.valid && check(r, /carries the credential/).pass === false, 'trebuia prins'); +}); +await test('6. CONTROL: o acreditare care numeste organizatia dar e semnata de un strain -> INVALIDA (semnatarul nu e emitentul numit)', async () => { + const fals = issueCredential({ issuerKeys: strain, issuerName: 'Example Org', subjectKeys: publicKeysOf(dev), subjectName: 'Ana Dev', validFrom: '2026-01-01T00:00:00Z', validUntil: '2027-01-01T00:00:00Z' }); + fals.body.issuer.keys = publicKeysOf(org); + fals.signature = pq.sign(JSON.stringify(fals.body), strain); + const a2 = await attest({ artifacts: [A], keys: dev, credential: fals, cwd: D, now: acum }); + const r = await verify(a2, [A], { trustIssuer: publicKeysOf(org) }); + cere(!r.valid && check(r, /signed by the issuer/).pass === false, 'trebuia prins'); +}); +await test('7. CONTROL: declaratia semnata de alte chei decat cele din acreditare -> INVALIDA', async () => { + const a2 = JSON.parse(JSON.stringify(att)); a2.signature = pq.sign(JSON.stringify(a2.statement), strain); + const r = await verify(a2, [A], { trustIssuer: publicKeysOf(org) }); + cere(!r.valid && check(r, /names the keys that signed/).pass === false, 'trebuia prins'); +}); +await test('8. CONTROL: momentul atestarii in afara valabilitatii -> INVALIDA', async () => { + const a2 = await attest({ artifacts: [A], keys: dev, credential: cred, cwd: D, now: new Date('2027-03-01T00:00:00Z') }); + const r = await verify(a2, [A], { trustIssuer: publicKeysOf(org) }); + cere(!r.valid && check(r, /valid at/).pass === false, 'trebuia prins'); +}); +await test('9. revocarea: inaintea atestarii -> INVALIDA; dupa ea -> VALIDA; semnata de alt emitent -> ignorata si spusa', async () => { + const inainte = revokeCredential({ issuerKeys: org, credential: cred, revokedAt: '2026-05-01T00:00:00Z', reason: 'cheie pierduta' }); + const dupa = revokeCredential({ issuerKeys: org, credential: cred, revokedAt: '2026-07-01T00:00:00Z' }); + const straina = revokeCredential({ issuerKeys: alt, credential: cred, revokedAt: '2026-02-01T00:00:00Z' }); + const r1 = await verify(att, [A], { trustIssuer: publicKeysOf(org), revocations: [inainte] }); + cere(!r1.valid && check(r1, /not revoked/).pass === false, 'revocarea de dinainte trebuia sa pice'); + const r2 = await verify(att, [A], { trustIssuer: publicKeysOf(org), revocations: [dupa] }); + cere(r2.valid && check(r2, /not revoked/).pass === null, 'revocarea de dupa nu acuza atestarea, dar pe data declarata nici nu o achita (B-18)'); + const r3 = await verify(att, [A], { trustIssuer: publicKeysOf(org), revocations: [straina] }); + cere(r3.valid && check(r3, /a revocation/).pass === null, 'o revocare straina se ignora si se spune'); + const r4 = await verify(att, [A], { trustIssuer: publicKeysOf(org) }); + // AERE-SINTETIC: nu e un secret; 'credential: revocations' e numele unei verificari + cere(check(r4, /credential: revocations/).pass === null, 'fara revocari date, se spune ca nu s-a putut vedea'); +}); +await test('10. timpul de pe LANT bate data declarata: un constructor care isi antedateaza atestarea sub o acreditare expirata e prins', async () => { + // createdAt declarat 2026-06-01 (in valabilitate), dar lantul a vazut-o prima oara pe 2027-02-01 (dupa expirare) + const a2 = JSON.parse(JSON.stringify(att)); a2.notarization = { block: 123, txHash: '0x' + 'ab'.repeat(32) }; + const cloud = { proof: async () => ({ notarized: true, block: 123, txHash: '0x' + 'ab'.repeat(32), firstSeenAt: Date.parse('2027-02-01T00:00:00Z') / 1000, finality: 'post-quantum', pqAnchor: null }) }; + const r = await verify(a2, [A], { trustIssuer: publicKeysOf(org), cloud }); + cere(!r.valid && check(r, /valid at/).pass === false && /first seen on chain/.test(check(r, /valid at/).name), JSON.stringify(check(r, /valid at/))); + const cloudBun = { proof: async () => ({ notarized: true, block: 123, txHash: '0x' + 'ab'.repeat(32), firstSeenAt: Date.parse('2026-06-02T00:00:00Z') / 1000, finality: 'post-quantum', pqAnchor: null }) }; + const rb = await verify(a2, [A], { trustIssuer: publicKeysOf(org), cloud: cloudBun }); + cere(rb.valid && /first seen on chain/.test(check(rb, /valid at/).name), 'cu timpul lantului in valabilitate trebuia VALIDA'); +}); +await test('11. CLI cap la cap: pubkey, credential issue, attest --credential, verify --trust-issuer (0), alt emitent (1), revocare (1)', async () => { + const pos = path.join(AICI, '..', 'pos.mjs'); const f = (n) => path.join(D, n); + fs.writeFileSync(f('org.json'), JSON.stringify(org)); fs.writeFileSync(f('dev.json'), JSON.stringify(dev)); fs.writeFileSync(f('alt.json'), JSON.stringify(alt)); + const run = (args) => { try { execFileSync(process.execPath, [pos, ...args], { cwd: D, stdio: 'pipe' }); return 0; } catch (e) { return e.status; } }; + cere(run(['pubkey', '--keys', f('org.json'), '--out', f('org.pub.json')]) === 0 && run(['pubkey', '--keys', f('dev.json'), '--out', f('dev.pub.json')]) === 0 && run(['pubkey', '--keys', f('alt.json'), '--out', f('alt.pub.json')]) === 0, 'pubkey'); + cere(!('secretKey' in (JSON.parse(fs.readFileSync(f('org.pub.json'), 'utf8')).classical || {})) && !JSON.stringify(JSON.parse(fs.readFileSync(f('org.pub.json'), 'utf8'))).includes(org.pq.secretKey), 'fisierul public nu are voie sa poarte cheia secreta'); + cere(run(['credential', 'issue', '--issuer-keys', f('org.json'), '--issuer-name', 'Example Org', '--subject-pub', f('dev.pub.json'), '--subject-name', 'Ana Dev', '--valid-days', '30', '--out', f('cred.json')]) === 0, 'credential issue'); + cere(run(['attest', '--out', f('att.json'), '--keys', f('dev.json'), '--credential', f('cred.json'), A]) === 0, 'attest'); + cere(run(['verify', f('att.json'), '--trust-issuer', f('org.pub.json'), A]) === 0, 'verify cu emitentul bun'); + cere(run(['verify', f('att.json'), '--trust-issuer', f('alt.pub.json'), A]) === 1, 'CONTROL: verify cu alt emitent'); + cere(run(['credential', 'revoke', '--issuer-keys', f('org.json'), '--credential', f('cred.json'), '--at', '2020-01-01T00:00:00Z', '--out', f('rev.json')]) === 0, 'credential revoke'); + cere(run(['verify', f('att.json'), '--trust-issuer', f('org.pub.json'), '--revocations', f('rev.json'), A]) === 1, 'CONTROL: verify cu revocarea'); +}); +await test('12. B-18: cu cheile unei acreditari REVOCATE, o atestare antedatata inaintea revocarii NU mai trece drept nerevocata; cu timpul lantului dupa revocare e prinsa, inainte e trecuta', async () => { + const rev = revokeCredential({ issuerKeys: org, credential: cred, revokedAt: '2026-09-01T00:00:00Z', reason: 'the keys left the company' }); + // hotul semneaza in octombrie, declara iunie + const r = await verify(att, [A], { trustIssuer: publicKeysOf(org), revocations: [rev] }); + cere(check(r, /not revoked/).pass === null && check(r, /valid at/).pass === null, 'pe data declarata, revocarea si valabilitatea trebuie raportate nejudecate: ' + JSON.stringify(check(r, /not revoked/))); + const a2 = JSON.parse(JSON.stringify(att)); a2.notarization = { block: 7, txHash: '0x' + 'cd'.repeat(32) }; + const lant = (iso) => ({ proof: async () => ({ notarized: true, block: 7, txHash: '0x' + 'cd'.repeat(32), firstSeenAt: Date.parse(iso) / 1000, finality: 'post-quantum', pqAnchor: null }) }); + const rDupa = await verify(a2, [A], { trustIssuer: publicKeysOf(org), revocations: [rev], cloud: lant('2026-10-02T00:00:00Z') }); + cere(!rDupa.valid && check(rDupa, /not revoked/).pass === false, 'lantul a vazut-o dupa revocare: trebuia prinsa'); + const rInainte = await verify(a2, [A], { trustIssuer: publicKeysOf(org), revocations: [rev], cloud: lant('2026-06-02T00:00:00Z') }); + cere(rInainte.valid && check(rInainte, /not revoked/).pass === true && check(rInainte, /valid at/).pass === true, 'lantul a vazut-o inainte de revocare, in valabilitate: trecuta'); +}); +fs.rmSync(D, { recursive: true, force: true }); +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +process.exitCode = esecuri.length ? 1 : 0; diff --git a/tools/proof-of-software/test/fixturi-go/gomock/go.mod b/tools/proof-of-software/test/fixturi-go/gomock/go.mod new file mode 100644 index 0000000..b7af43c --- /dev/null +++ b/tools/proof-of-software/test/fixturi-go/gomock/go.mod @@ -0,0 +1,8 @@ +module github.com/golang/mock + +require ( + golang.org/x/mod v0.4.2 + golang.org/x/tools v0.1.1 +) + +go 1.11 diff --git a/tools/proof-of-software/test/fixturi-go/gomock/go.sum b/tools/proof-of-software/test/fixturi-go/gomock/go.sum new file mode 100644 index 0000000..94468a6 --- /dev/null +++ b/tools/proof-of-software/test/fixturi-go/gomock/go.sum @@ -0,0 +1,28 @@ +github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1 h1:wGiQel/hW0NnEkJUk8lbzkX2gFJU6PFxf1v5OlCfuOs= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/tools/proof-of-software/test/fixturi-go/sdk-go/go.mod b/tools/proof-of-software/test/fixturi-go/sdk-go/go.mod new file mode 100644 index 0000000..2417232 --- /dev/null +++ b/tools/proof-of-software/test/fixturi-go/sdk-go/go.mod @@ -0,0 +1,10 @@ +module aere.network/pqc + +go 1.26.5 + +require ( + github.com/cloudflare/circl v1.6.4 + golang.org/x/crypto v0.54.0 +) + +require golang.org/x/sys v0.47.0 // indirect diff --git a/tools/proof-of-software/test/fixturi-go/sdk-go/go.sum b/tools/proof-of-software/test/fixturi-go/sdk-go/go.sum new file mode 100644 index 0000000..08f4b08 --- /dev/null +++ b/tools/proof-of-software/test/fixturi-go/sdk-go/go.sum @@ -0,0 +1,6 @@ +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/tools/proof-of-software/test/model-control-negativ.mjs b/tools/proof-of-software/test/model-control-negativ.mjs new file mode 100644 index 0000000..556dc75 --- /dev/null +++ b/tools/proof-of-software/test/model-control-negativ.mjs @@ -0,0 +1,42 @@ +// Controlul negativ al probelor de proveninta a modelelor (model.test.mjs): fiecare paznic se strica pe rand IN pos.mjs (importurile +// relative ale lui pos.mjs nu permit o copie in alt dosar), proba tinta TREBUIE sa pice, iar fisierul se pune la loc in `finally` si +// se compara octet cu octet cu originalul. O plantare care nu se aplica e STRICAT, nu rosu. +// node aerenew/tools/proof-of-software/test/model-control-negativ.mjs +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const POS = path.join(AICI, '..', 'pos.mjs'); +const original = fs.readFileSync(POS); +const text = original.toString('utf8'); + +const PLANTARI = [ + ['fluxul pierde ultima bucata partiala', 'h.update(n === buf.length ? buf : buf.subarray(0, n))', 'if (n === buf.length) h.update(buf)', 'hash in flux'], + ['dosarele ascunse intra in model', "if (e.name.startsWith('.')) continue;", "if (e.name.startsWith('.') && false) continue;", 'ML-BOM CycloneDX 1.6'], + ['manifestul fara marimi', "randuri.map((r) => `${r.rel}\\t${r.sha}\\t${r.bytes}\\n`)", "randuri.map((r) => `${r.rel}\\t${r.sha}\\n`)", 'digestul manifestului'], + ['verificarea fara --base nu mai cere --base', "if (relativ && !base) ok('artifact names', false,", "if (relativ && !base && false) ok('artifact names', false,", 'FARA --base'], +]; + +let rele = 0; +try { + for (const [nume, vechi, nou, tinta] of PLANTARI) { + if (text.split(vechi).length !== 2) { console.log(` STRICAT ${nume}: ancora apare de ${text.split(vechi).length - 1} ori`); rele++; continue; } + fs.writeFileSync(POS, text.replace(vechi, nou)); + const r = spawnSync(process.execPath, [path.join(AICI, 'model.test.mjs')], { encoding: 'utf8' }); + const out = (r.stdout || '') + (r.stderr || ''); + const rulate = (out.match(/^ (OK|ESEC) /gm) || []).length; + if (rulate < 8) { console.log(` STRICAT ${nume}: au rulat ${rulate} probe din 8`); rele++; continue; } + const picate = out.split('\n').filter((l) => l.startsWith(' ESEC ')); + if (picate.some((l) => l.includes(tinta))) console.log(` ROSU cum trebuia ${nume} (${picate.length} esec, intre ele '${tinta}')`); + else { console.log(` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde`); rele++; } + } +} finally { + fs.writeFileSync(POS, original); +} +if (!fs.readFileSync(POS).equals(original)) { console.log(' pos.mjs NU a fost pus la loc octet cu octet'); rele++; } +const m = spawnSync(process.execPath, [path.join(AICI, 'model.test.mjs')], { encoding: 'utf8' }); +if (!/8 treceri, 0 esecuri/.test(m.stdout || '')) { console.log(' MARTORUL nu e verde'); rele++; } else console.log(' martor: pos.mjs neatins, 8/8 verde'); +console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `CONTROL NEGATIV TRECUT (${PLANTARI.length} din ${PLANTARI.length} rosii, pos.mjs pus la loc, martorul verde)`); +process.exitCode = rele ? 1 : 0; diff --git a/tools/proof-of-software/test/model.test.mjs b/tools/proof-of-software/test/model.test.mjs new file mode 100644 index 0000000..19787b8 --- /dev/null +++ b/tools/proof-of-software/test/model.test.mjs @@ -0,0 +1,102 @@ +// Proof of Software 1.2.0, proveninta modelelor AI: ML-BOM CycloneDX 1.6, nume relative la --base, hash in flux. Fiecare afirmatie cu +// perechea ei negativa. Offline. +// node aerenew/tools/proof-of-software/test/model.test.mjs +import assert from 'node:assert'; +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'; + +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 + ' -> ' + String(e.message || e).slice(0, 240)); } } + +// pragul fluxului coborat la 1 KiB INAINTE de import, ca un fisier de 3 MiB sa treaca prin ramura in flux +process.env.AERE_POS_FLUX_PESTE = '1024'; +const POS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pos.mjs'); +const { modelBom, sha256File, attest, verify, buildStatement } = await import('../pos.mjs'); +const sha = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex'); + +const D = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-model-')); +const M = path.join(D, 'tiny-lm'); +fs.mkdirSync(path.join(M, 'tokenizer'), { recursive: true }); +fs.mkdirSync(path.join(M, '.cache')); +const shard1 = crypto.randomBytes(3 * 1024 * 1024 + 17), shard2 = crypto.randomBytes(200 * 1024); +fs.writeFileSync(path.join(M, 'config.json'), JSON.stringify({ architectures: ['TinyLMForCausalLM'], model_type: 'tiny_lm', hidden_size: 64 })); +fs.writeFileSync(path.join(M, 'model-00001-of-00002.safetensors'), shard1); +fs.writeFileSync(path.join(M, 'model-00002-of-00002.safetensors'), shard2); +fs.writeFileSync(path.join(M, 'tokenizer', 'config.json'), JSON.stringify({ vocab_size: 512 })); +fs.writeFileSync(path.join(M, 'README.md'), '# tiny-lm\n'); +fs.writeFileSync(path.join(M, '.cache', 'ignored.bin'), 'not part of the model'); +const DATE = path.join(D, 'train.jsonl'); fs.writeFileSync(DATE, '{"text":"hello"}\n{"text":"world"}\n'); + +await test('hash in flux (fisier de 3 MiB peste pragul de 1 KiB) = hash-ul intregului fisier, calculat independent', () => { + assert.strictEqual(sha256File(path.join(M, 'model-00001-of-00002.safetensors')), sha(shard1)); + assert.strictEqual(sha256File(path.join(M, 'model-00002-of-00002.safetensors')), sha(shard2)); +}); + +const { bom, files, manifestSha256 } = modelBom({ dir: M, name: 'tiny-lm', version: '0.1', task: 'text-generation', datasets: ['train=' + DATE] }); +await test('ML-BOM CycloneDX 1.6: componenta machine-learning-model, arhitectura din config.json, fiecare fisier cu SHA-256 corect, dosarul ascuns sarit', () => { + assert.deepStrictEqual([bom.bomFormat, bom.specVersion, bom.version], ['CycloneDX', '1.6', 1]); + assert.match(bom.serialNumber, /^urn:uuid:[0-9a-f-]{36}$/); + const m = bom.metadata.component; + assert.deepStrictEqual([m.type, m['bom-ref'], m.name, m.version], ['machine-learning-model', 'model', 'tiny-lm', '0.1']); + assert.deepStrictEqual(m.modelCard.modelParameters, { task: 'text-generation', architectureFamily: 'tiny_lm', modelArchitecture: 'TinyLMForCausalLM', datasets: [{ ref: 'data:train' }] }); + const fisiere = bom.components.filter((c) => c.type === 'file'); + assert.deepStrictEqual(fisiere.map((c) => c.name), ['README.md', 'config.json', 'model-00001-of-00002.safetensors', 'model-00002-of-00002.safetensors', 'tokenizer/config.json']); + assert.strictEqual(fisiere.find((c) => c.name === 'model-00001-of-00002.safetensors').hashes[0].content, sha(shard1).slice(2)); + assert.ok(!bom.components.some((c) => /ignored/.test(c.name)), 'dosarul ascuns .cache a intrat in model'); + const refs = bom.components.map((c) => c['bom-ref']); assert.strictEqual(new Set(refs).size, refs.length, 'bom-ref duplicat'); + assert.deepStrictEqual(bom.dependencies[0].dependsOn.sort(), refs.sort()); + const d = bom.components.find((c) => c.type === 'data'); assert.strictEqual(d.hashes[0].content, sha(fs.readFileSync(DATE)).slice(2)); + assert.strictEqual(files.length, 5); +}); +await test('digestul manifestului se reface din regula scrisa in BOM, si se schimba la un octet atins (pereche negativa)', () => { + const linii = bom.components.filter((c) => c.type === 'file').map((c) => `${c.name}\t0x${c.hashes[0].content}\t${c.properties[0].value}\n`).join(''); + assert.strictEqual(sha(Buffer.from(linii, 'utf8')), manifestSha256); + assert.strictEqual(bom.metadata.component.properties.find((p) => p.name === 'aere:manifestSha256').value, manifestSha256); + const b = Buffer.from(shard2); b[7] ^= 1; const alt = path.join(D, 'alt'); fs.cpSync(M, alt, { recursive: true }); + fs.writeFileSync(path.join(alt, 'model-00002-of-00002.safetensors'), b); + assert.notStrictEqual(modelBom({ dir: alt }).manifestSha256, manifestSha256); +}); +await test('fara --base doua config.json se ciocnesc (refuz cu indicatia --base); cu --base au nume relative distincte', () => { + assert.throws(() => buildStatement({ artifacts: files, cwd: D }), /share the name .*use --base/); + const s = buildStatement({ artifacts: files, base: M, cwd: D }); + assert.strictEqual(s.artifactNames, 'relative-path'); + assert.deepStrictEqual(s.artifacts.map((a) => a.name).sort(), ['README.md', 'config.json', 'model-00001-of-00002.safetensors', 'model-00002-of-00002.safetensors', 'tokenizer/config.json']); + assert.throws(() => buildStatement({ artifacts: [DATE], base: M, cwd: D }), /not inside --base/); +}); +const BOMF = path.join(D, 'mlbom.json'); fs.writeFileSync(BOMF, JSON.stringify(bom)); +const att = await attest({ artifacts: files, base: M, sbom: BOMF, name: 'tiny-lm', version: '0.1', cwd: D }); +await test('atestare cu --base + ML-BOM, verificare cu --base: VALID', async () => { + const r = await verify(att, [...files, BOMF], { base: M }); + assert.ok(r.valid, JSON.stringify(r.checks.filter((c) => c.pass === false))); + assert.ok(r.checks.some((c) => c.name === 'sbom mlbom.json: sha256 matches' && c.pass === true)); +}); +await test('un octet schimbat intr-o greutate: INVALID, cu numele fisierului; o greutate lipsa: INVALID (lipsa nu e valida)', async () => { + const cop = path.join(D, 'copie'); fs.cpSync(M, cop, { recursive: true }); + const b = Buffer.from(shard1); b[b.length - 1] ^= 1; fs.writeFileSync(path.join(cop, 'model-00001-of-00002.safetensors'), b); + const fis = files.map((p) => path.join(cop, path.relative(M, p))); + const r = await verify(att, [...fis, BOMF], { base: cop }); + assert.strictEqual(r.valid, false); + assert.ok(r.checks.some((c) => c.pass === false && c.name === 'artifact model-00001-of-00002.safetensors: sha256 and size match'), JSON.stringify(r.checks.filter((c) => c.pass === false))); + const r2 = await verify(att, [...files.filter((p) => !/00002/.test(p)), BOMF], { base: M }); + assert.strictEqual(r2.valid, false); assert.ok(r2.checks.some((c) => c.pass === false && /00002.*present/.test(c.name))); +}); +await test('o atestare cu nume relative verificata FARA --base: INVALID cu motivul numit (nu potrivire pe bazenume)', async () => { + const r = await verify(att, [...files, BOMF], {}); + assert.strictEqual(r.valid, false); assert.ok(r.checks.some((c) => c.pass === false && /pass --base/.test(c.detail)), JSON.stringify(r.checks.filter((c) => c.pass === false)).slice(0, 300)); +}); +await test('CLI: model-bom scrie fisierul si numara fisierele; un --dataset fara NUME=FISIER e refuzat', () => { + const out = path.join(D, 'cli-mlbom.json'); + const t = execFileSync(process.execPath, [POS, 'model-bom', '--model-dir', M, '--out', out, '--name', 'tiny-lm'], { encoding: 'utf8' }); + assert.match(t, /ML-BOM written to .*: 5 file\(s\), 0 dataset\(s\)/); + assert.strictEqual(JSON.parse(fs.readFileSync(out, 'utf8')).metadata.component.type, 'machine-learning-model'); + let refuz = ''; try { execFileSync(process.execPath, [POS, 'model-bom', '--model-dir', M, '--out', out, '--dataset', 'fara-egal'], { encoding: 'utf8', stdio: 'pipe' }); } catch (e) { refuz = String(e.stderr); } + assert.match(refuz, /--dataset takes NAME=FILE/); +}); + +fs.rmSync(D, { recursive: true, force: true }); +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +process.exitCode = esecuri.length ? 1 : 0; diff --git a/tools/proof-of-software/test/pos.test.mjs b/tools/proof-of-software/test/pos.test.mjs new file mode 100644 index 0000000..6b5d932 --- /dev/null +++ b/tools/proof-of-software/test/pos.test.mjs @@ -0,0 +1,150 @@ +// 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'; + +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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 = fs.mkdtempSync(path.join(os.tmpdir(), '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); +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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 = fs.mkdtempSync(path.join(os.tmpdir(), '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= (scrie o tranzactie pe 2800)'); + +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +if (esecuri.length) process.exitCode = 1; diff --git a/tools/proof-of-software/test/sbom-control-negativ.mjs b/tools/proof-of-software/test/sbom-control-negativ.mjs new file mode 100644 index 0000000..4e2d259 --- /dev/null +++ b/tools/proof-of-software/test/sbom-control-negativ.mjs @@ -0,0 +1,10 @@ +// Controlul negativ al SBOM-ului re-derivat din lockfile-ul comis (sbom.test.mjs), prin mecanismul comun din _control.mjs. +// node aerenew/tools/proof-of-software/test/sbom-control-negativ.mjs +import { controleaza } from './_control.mjs'; + +process.exitCode = controleaza('sbom.test.mjs', [ + ['comparatia SBOM-ului cu lockfile-ul scoasa', 'const aceleasi = JSON.stringify(a) === JSON.stringify(b);', 'const aceleasi = true;', 'CONTROL: o componenta scoasa'], + // ancora pe partea STABILA a randului (1.4.0 i-a adaugat `props` la coada si ancora veche a murit: STRICAT, prins de chiar acest control) + ['hash-urile de integritate scoase din forma semantica', "hashes: (c.hashes || []).map((h) => `${h.alg}:${String(h.content).toLowerCase()}`).sort(),", 'hashes: [],', 'CONTROL: un hash de integritate'], + ['un arbore fara lockfile trecut drept re-derivat', "if (!fs.existsSync(path.join(src, 'package-lock.json'))) return { lipsa:", "if (false) return { lipsa:", 'fara package-lock'], +]); diff --git a/tools/proof-of-software/test/sbom-go-control-negativ.mjs b/tools/proof-of-software/test/sbom-go-control-negativ.mjs new file mode 100644 index 0000000..88bbc75 --- /dev/null +++ b/tools/proof-of-software/test/sbom-go-control-negativ.mjs @@ -0,0 +1,14 @@ +// Controlul negativ al SBOM-ului Go (sbom-go.test.mjs, Proof of Software 1.4.0), prin mecanismul comun din _control.mjs: fiecare +// paznic scos intr-o copie a lui pos.mjs trebuie sa inroseasca EXACT proba lui, cu suita chiar rulata. +// node aerenew/tools/proof-of-software/test/sbom-go-control-negativ.mjs +import { controleaza } from './_control.mjs'; + +process.exitCode = controleaza('sbom-go.test.mjs', [ + ['hash-ul h1 scos din forma semantica', 'props: aere(c.properties) });', 'props: [] });', 'CONTROL: un hash h1 schimbat'], + ['SBOM-ul Go judecat ca unul npm', 'const go = esteSbomGo(atestat);', 'const go = false;', 'verify --rebuild-from: arborele e cel atestat'], + ['o atestare fara npm pack declarata falsa la reconstructie (forma 1.3.0)', "checks.push({ name: 'rebuild: artifacts', pass: null, detail:", "checks.push({ name: 'rebuild: artifacts', pass: false, detail:", 'verify --rebuild-from: arborele e cel atestat'], + ['modulele fixate numai prin go.mod listate drept componente', '.filter((e) => e.h1).map((e) => {', '.filter((e) => true).map((e) => {', 'gomock: numai modulele cu continut'], + ['un arbore fara go.sum trecut drept re-derivat', "const goSum = blob('go.sum'); if (goSum == null) return { lipsa:", "const goSum = blob('go.sum') ?? ''; if (false) return { lipsa:", 'un arbore fara go.sum'], + ['numarul de serie aleator (SBOM-ul nu mai e determinist)', 'const b = crypto.createHash(\'sha256\').update(JSON.stringify({ metadata, components })).digest();', 'const b = crypto.randomBytes(32);', 'determinist'], + ['radacina scoasa din comparatie', 'return { radacina: radacina && radacina.purl, meta:', 'return { radacina: null, meta:', 'CONTROL: SBOM-ul spune alt modul radacina'], +]); diff --git a/tools/proof-of-software/test/sbom-go.test.mjs b/tools/proof-of-software/test/sbom-go.test.mjs new file mode 100644 index 0000000..5e58b19 --- /dev/null +++ b/tools/proof-of-software/test/sbom-go.test.mjs @@ -0,0 +1,111 @@ +// Proof of Software 1.4.0: SBOM-ul unui modul Go, derivat determinist din go.mod si go.sum COMISE, si judecat la verify --rebuild-from. +// Fixturile sunt fisiere REALE, scrise de unealta Go: go.mod/go.sum din sdk-go (al nostru) si din github.com/golang/mock@v1.6.0 (din +// memoria de module a lui Go, un go.sum cu multe module fixate numai prin go.mod). Fiecare afirmatie cu perechea ei negativa. Offline. +// node test/sbom-go.test.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { attest, verify, sbomGo, sbomGoFromTree, sbomSemantica } from '../pos.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const POS = path.resolve(AICI, '..', 'pos.mjs'); +const FX = path.join(AICI, 'fixturi-go'); +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 cere = (c, m) => { if (!c) throw new Error(m); }; +const g = (args, cwd) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); +const check = (r, re) => r.checks.find((c) => re.test(c.name)); +const citeste = (d) => ({ goMod: fs.readFileSync(path.join(FX, d, 'go.mod'), 'utf8'), goSum: fs.readFileSync(path.join(FX, d, 'go.sum'), 'utf8') }); + +const SDK = citeste('sdk-go'), MOCK = citeste('gomock'); +const bSdk = sbomGo({ ...SDK, version: 'v1.0.0' }), bMock = sbomGo({ ...MOCK, version: 'v1.6.0' }); +const prop = (c, n) => ((c.properties || []).find((x) => x.name === n) || {}).value; + +await test('sdk-go: trei module cu continut fixat in go.sum, cu purl pkg:golang si hash-ul h1 ca proprietate; radacina din go.mod', () => { + cere(bSdk.components.length === 3, 'componente: ' + bSdk.components.length); + cere(bSdk.components.map((c) => c.purl).join(' ') === 'pkg:golang/github.com/cloudflare/circl@v1.6.4 pkg:golang/golang.org/x/crypto@v0.54.0 pkg:golang/golang.org/x/sys@v0.47.0', bSdk.components.map((c) => c.purl).join(' ')); + cere(bSdk.metadata.component.purl === 'pkg:golang/aere.network/pqc@v1.0.0', bSdk.metadata.component.purl); + cere(bSdk.components.every((c) => /^h1:[A-Za-z0-9+/]{43}=$/.test(prop(c, 'aere:go-sum-h1')) && !(c.hashes || []).length), 'h1 ca proprietate, fara hashes SHA-256'); +}); +await test('gomock: numai modulele cu continut fixat sunt componente; cele fixate numai prin go.mod se numara (numaratoare independenta)', () => { + // numaratoarea independenta, alt cod decat cel masurat: perechi distincte "modul versiune", cu si fara randul de continut + const randuri = MOCK.goSum.split('\n').filter(Boolean).map((l) => l.split(' ')); + const cuContinut = new Set(randuri.filter((r) => !r[1].endsWith('/go.mod')).map((r) => r[0] + ' ' + r[1])); + const toate = new Set(randuri.map((r) => r[0] + ' ' + r[1].replace(/\/go\.mod$/, ''))); + cere(bMock.components.length === cuContinut.size && cuContinut.size === 5, `componente ${bMock.components.length}, asteptat ${cuContinut.size}`); + cere(Number(prop(bMock.metadata, 'aere:go-sum-go-mod-only')) === toate.size - cuContinut.size && toate.size - cuContinut.size > 10, `numai go.mod: ${prop(bMock.metadata, 'aere:go-sum-go-mod-only')} fata de ${toate.size - cuContinut.size}`); + cere(bMock.components.some((c) => c.purl === 'pkg:golang/golang.org/x/tools@v0.1.1'), 'x/tools v0.1.1 lipseste'); +}); +await test('determinist: aceleasi fisiere dau aceiasi octeti (fara timp; numarul de serie e UUID derivat din continut)', () => { + const alt = sbomGo({ ...SDK, version: 'v1.0.0' }); + cere(JSON.stringify(alt) === JSON.stringify(bSdk), 'doua derivari difera'); + cere(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(bSdk.serialNumber), bSdk.serialNumber); + cere(sbomGo({ ...SDK, version: 'v1.0.1' }).serialNumber !== bSdk.serialNumber, 'CONTROL: alta versiune, alt numar de serie'); +}); +await test('CONTROL POZITIV al metodei: hash-ul h1 citit din go.sum e chiar cel pe care Go l-a calculat la descarcarea modulului', () => { + const cache = (() => { try { return execFileSync('go', ['env', 'GOMODCACHE'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } })(); + const perechi = bSdk.components.map((c) => ({ c, f: cache && path.join(cache, 'cache', 'download', ...c.name.split('/'), '@v', c.version + '.ziphash') })).filter((x) => x.f && fs.existsSync(x.f)); + if (!perechi.length) { console.log(' SARIT (nicio arhiva a acestor module in memoria de module Go de pe aceasta masina)'); return; } + for (const { c, f } of perechi) cere(fs.readFileSync(f, 'utf8').trim() === prop(c, 'aere:go-sum-h1'), `${c.name}: ${fs.readFileSync(f, 'utf8').trim()} fata de ${prop(c, 'aere:go-sum-h1')}`); + console.log(` (${perechi.length} din ${bSdk.components.length} module comparate cu memoria de module Go)`); +}); +await test('CONTROL: un rand de go.sum care nu are forma lui, sau un go.mod fara modul -> refuzat, nu ghicit', () => { + let e1 = null; try { sbomGo({ goMod: SDK.goMod, goSum: SDK.goSum + 'rand stricat\n', version: 'v1' }); } catch (e) { e1 = e.message; } + let e2 = null; try { sbomGo({ goMod: 'go 1.21\n', goSum: SDK.goSum, version: 'v1' }); } catch (e) { e2 = e.message; } + cere(/go.sum line \d+/.test(e1 || '') && /names no module/.test(e2 || ''), `${e1} | ${e2}`); +}); + +// fluxul intreg, intr-un depozit git temporar: SBOM-ul scris de `sbom-go` din arborele comis, atestat, verificat cu --rebuild-from +const G = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-g-')); +g(['init', '-q'], G); g(['config', 'user.email', 'proba@aere.invalid'], G); g(['config', 'user.name', 'proba'], G); g(['config', 'core.autocrlf', 'false'], G); +const M = path.join(G, 'mod'); fs.mkdirSync(M, { recursive: true }); +fs.writeFileSync(path.join(M, 'go.mod'), SDK.goMod); fs.writeFileSync(path.join(M, 'go.sum'), SDK.goSum); +fs.writeFileSync(path.join(M, 'main.go'), 'package main\n\nfunc main() {}\n'); +fs.mkdirSync(path.join(G, 'fara'), { recursive: true }); fs.writeFileSync(path.join(G, 'fara', 'go.mod'), 'module example.com/fara\n\ngo 1.21\n'); +g(['add', '-A'], G); g(['commit', '-q', '-m', 'modul de proba'], G); +const C = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-c-')); g(['clone', '-q', G, C], os.tmpdir()); +const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-o-')); +const BIN = path.join(OUT, 'app-linux-amd64'); fs.writeFileSync(BIN, 'binarul construit altfel decat cu npm pack'); +const S = path.join(OUT, 'app.sbom.cdx.json'); +const cli = execFileSync(process.execPath, [POS, 'sbom-go', '--source-path', 'mod', '--version', 'v1.0.0', '--out', S], { cwd: G, stdio: ['ignore', 'pipe', 'pipe'] }).toString(); +const att = await attest({ artifacts: [BIN], sbom: S, name: 'app', version: 'v1.0.0', sourcePath: 'mod', cwd: G }); + +await test('sbom-go (linia de comanda) scrie din arborele COMIS exact SBOM-ul pe care il re-deriva verificarea', () => { + const r = sbomGoFromTree(G, g(['rev-parse', 'HEAD'], G), 'mod', 'v1.0.0'); + cere(/3 module\(s\) pinned/.test(cli), cli); + cere(fs.readFileSync(S, 'utf8') === JSON.stringify(r.bom, null, 1) + '\n', 'fisierul scris difera de derivarea din arbore'); +}); +await test('verify --rebuild-from: arborele e cel atestat, SBOM-ul spune ce fixeaza go.sum-ul comis, iar artefactele nereconstruite sunt ABSENTE, nu false (VALID)', async () => { + const r = await verify(att, [BIN, S], { rebuildFrom: C }); + const sb = check(r, /attested SBOM says what the committed go.sum pins/), art = check(r, /^rebuild: artifacts$/); + cere(r.valid && sb && sb.pass === true && art && art.pass === null, JSON.stringify(r.checks.filter((c) => c.pass !== true))); +}); +async function editat(nume, schimba) { + const bom = JSON.parse(fs.readFileSync(S, 'utf8')); schimba(bom); + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-e-')); const f = path.join(d, 'app.sbom.cdx.json'); fs.writeFileSync(f, JSON.stringify(bom, null, 1)); + const a = await attest({ artifacts: [BIN], sbom: f, name: 'app', version: 'v1.0.0', sourcePath: 'mod', cwd: G }); + const simplu = await verify(a, [BIN, f]); + const r = await verify(a, [BIN, f], { rebuildFrom: C }); + fs.rmSync(d, { recursive: true, force: true }); + cere(simplu.valid, `${nume}: fara reconstructie, digestul singur trece (de asta conteaza comparatia)`); + const sb = check(r, /attested SBOM says/); + cere(!r.valid && sb && sb.pass === false, `${nume}: trebuia prins; ${JSON.stringify(sb)}`); +} +await test('CONTROL: un modul scos din SBOM de mana, re-atestat -> prins la reconstructie', () => editat('scos', (b) => { b.components = b.components.filter((c) => !/circl/.test(c.name)); })); +await test('CONTROL: o versiune schimbata in SBOM (si purl-ul ei) -> prins', () => editat('versiune', (b) => { const c = b.components.find((x) => /x\/sys/.test(x.name)); c.version = 'v0.48.0'; c.purl = c.purl.replace('v0.47.0', 'v0.48.0'); c['bom-ref'] = c.purl; })); +await test('CONTROL: un hash h1 schimbat in SBOM -> prins', () => editat('h1', (b) => { const c = b.components[0]; c.properties = [{ name: 'aere:go-sum-h1', value: 'h1:' + 'A'.repeat(43) + '=' }]; })); +await test('CONTROL: SBOM-ul spune alt modul radacina decat go.mod-ul comis -> prins', () => editat('radacina', (b) => { b.metadata.component.name = 'example.com/altul'; b.metadata.component.purl = 'pkg:golang/example.com/altul@v1.0.0'; })); +await test('un arbore fara go.sum comis: SBOM-ul nu se poate re-deriva, si se spune (nu e trecut drept VALID)', () => { + const r = sbomGoFromTree(G, g(['rev-parse', 'HEAD'], G), 'fara', 'v1'); + cere(!r.bom && /no go.sum/.test(r.lipsa || ''), JSON.stringify(r).slice(0, 120)); +}); +await test('forma semantica: un SBOM npm (fara proprietati aere:) e judecat ca inainte; un SBOM Go poarta h1 in comparatie', () => { + const npm = { metadata: { component: { purl: 'pkg:npm/x@1' } }, components: [{ purl: 'pkg:npm/a@1', hashes: [{ alg: 'SHA-512', content: 'AB' }], properties: [{ name: 'cdx:npm:package:path', value: 'node_modules/a' }] }] }; + cere(JSON.stringify(sbomSemantica(npm).componente[0].props) === '[]', 'proprietatile npm nu intra'); + cere(sbomSemantica(bSdk).componente.every((c) => c.props.length === 1), 'fiecare componenta Go are h1 in forma semantica'); +}); +for (const d of [G, C, OUT]) fs.rmSync(d, { recursive: true, force: true }); +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +process.exitCode = esecuri.length ? 1 : 0; diff --git a/tools/proof-of-software/test/sbom.test.mjs b/tools/proof-of-software/test/sbom.test.mjs new file mode 100644 index 0000000..cc6bf68 --- /dev/null +++ b/tools/proof-of-software/test/sbom.test.mjs @@ -0,0 +1,83 @@ +// Proof of Software 1.3.0: SBOM-ul atestat e judecat fata de lockfile-ul COMIS (verify --rebuild-from). Fiecare afirmatie cu perechea +// ei negativa. Offline: un depozit git temporar cu un pachet si un package-lock.json scris aici; `npm sbom --package-lock-only` nu +// cere retea. +// node test/sbom.test.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { attest, verify, packNpmFromTree, sbomSemantica, sbomNpmFromTree } from '../pos.mjs'; + +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 cere = (c, m) => { if (!c) throw new Error(m); }; +const g = (args, cwd) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); +const check = (r, re) => r.checks.find((c) => re.test(c.name)); + +const G = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-sbom-g-')); +g(['init', '-q'], G); g(['config', 'user.email', 'proba@aere.invalid'], G); g(['config', 'user.name', 'proba'], G); g(['config', 'core.autocrlf', 'false'], G); +const P = path.join(G, 'pkg'); fs.mkdirSync(path.join(P, 'lib'), { recursive: true }); +fs.writeFileSync(path.join(P, 'package.json'), JSON.stringify({ name: 'proba-sbom', version: '0.1.0', license: 'MIT', main: 'lib/index.js', dependencies: { 'dep-a': '1.0.0', 'dep-b': '2.0.0' } }, null, 2) + '\n'); +fs.writeFileSync(path.join(P, 'lib', 'index.js'), 'module.exports = 1;\n'); +const integ = (s) => 'sha512-' + Buffer.from(s.padEnd(64, 'x')).toString('base64'); +const lock = { + name: 'proba-sbom', version: '0.1.0', lockfileVersion: 3, requires: true, + packages: { + '': { name: 'proba-sbom', version: '0.1.0', license: 'MIT', dependencies: { 'dep-a': '1.0.0', 'dep-b': '2.0.0' } }, + 'node_modules/dep-a': { version: '1.0.0', resolved: 'https://registry.npmjs.org/dep-a/-/dep-a-1.0.0.tgz', integrity: integ('dep-a-1.0.0'), license: 'MIT', dependencies: { 'dep-c': '3.0.0' } }, + 'node_modules/dep-b': { version: '2.0.0', resolved: 'https://registry.npmjs.org/dep-b/-/dep-b-2.0.0.tgz', integrity: integ('dep-b-2.0.0'), license: 'MIT' }, + 'node_modules/dep-c': { version: '3.0.0', resolved: 'https://registry.npmjs.org/dep-c/-/dep-c-3.0.0.tgz', integrity: integ('dep-c-3.0.0'), license: 'ISC' }, + }, +}; +fs.writeFileSync(path.join(P, 'package-lock.json'), JSON.stringify(lock, null, 2) + '\n'); +// un al doilea pachet, FARA lockfile comis +fs.mkdirSync(path.join(G, 'fara'), { recursive: true }); +fs.writeFileSync(path.join(G, 'fara', 'package.json'), JSON.stringify({ name: 'fara-lock', version: '0.0.1', license: 'MIT' }) + '\n'); +g(['add', '-A'], G); g(['commit', '-q', '-m', 'pachet de proba'], G); +const C = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-sbom-c-')); g(['clone', '-q', G, C], os.tmpdir()); + +const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-sbom-o-')); +const TGZ = packNpmFromTree(G, 'HEAD:pkg', OUT); +const scrieSbom = (nume, bom) => { const f = path.join(OUT, nume); fs.writeFileSync(f, JSON.stringify(bom, null, 2)); return f; }; +const r1 = sbomNpmFromTree(G, 'HEAD:pkg'); const r2 = sbomNpmFromTree(G, 'HEAD:pkg'); +const S = scrieSbom('proba-sbom.sbom.cdx.json', r1.bom); +const att = await attest({ artifacts: [TGZ], sbom: S, name: 'proba-sbom', version: '0.1.0', sourcePath: 'pkg', build: 'npm-pack', cwd: G }); + +await test('npm sbom din lockfile-ul comis: trei componente, si doua rulari difera in octeti (numar de serie, timp) dar nu in continut', () => { + cere(r1.bom && r1.bom.components.length === 3, 'componente: ' + (r1.bom && r1.bom.components.length) + (r1.lipsa ? ' ' + r1.lipsa : '')); + cere(JSON.stringify(r1.bom) !== JSON.stringify(r2.bom), 'doua rulari ar trebui sa difere in octeti (altfel testul de mai jos nu masoara nimic)'); + cere(JSON.stringify(sbomSemantica(r1.bom)) === JSON.stringify(sbomSemantica(r2.bom)), 'forma semantica trebuie sa fie aceeasi'); +}); +await test('verify --rebuild-from cu SBOM-ul dat: SBOM-ul spune ce spune lockfile-ul comis (VALID)', async () => { + const r = await verify(att, [TGZ, S], { rebuildFrom: C }); + cere(r.valid && check(r, /attested SBOM says what the committed lockfile says/) && check(r, /attested SBOM says/).pass === true, JSON.stringify(r.checks.filter((c) => c.pass !== true))); +}); +await test('un SBOM regenerat (alt numar de serie, alt timp) si re-atestat trece: judecata e pe continut, nu pe octeti', async () => { + const S2 = scrieSbom('proba-sbom.sbom.cdx.json', sbomNpmFromTree(G, 'HEAD:pkg').bom); + const a2 = await attest({ artifacts: [TGZ], sbom: S2, name: 'proba-sbom', version: '0.1.0', sourcePath: 'pkg', build: 'npm-pack', cwd: G }); + const r = await verify(a2, [TGZ, S2], { rebuildFrom: C }); cere(r.valid, 'trebuia VALID'); + fs.writeFileSync(S, JSON.stringify(r1.bom, null, 2)); +}); +async function editat(nume, schimba) { + const bom = JSON.parse(JSON.stringify(r1.bom)); schimba(bom); + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-sbom-e-')); const f = path.join(d, 'proba-sbom.sbom.cdx.json'); fs.writeFileSync(f, JSON.stringify(bom, null, 2)); + const a = await attest({ artifacts: [TGZ], sbom: f, name: 'proba-sbom', version: '0.1.0', sourcePath: 'pkg', build: 'npm-pack', cwd: G }); + const simplu = await verify(a, [TGZ, f]); + const r = await verify(a, [TGZ, f], { rebuildFrom: C }); + cere(simplu.valid, `${nume}: fara reconstructie, digestul singur trece (de asta conteaza comparatia)`); + cere(!r.valid && check(r, /attested SBOM says/).pass === false, `${nume}: trebuia prins; ${JSON.stringify(check(r, /attested SBOM says/))}`); +} +await test('CONTROL: o componenta scoasa din SBOM de mana, re-atestat -> prins la reconstructie', () => editat('scoasa', (b) => { b.components = b.components.filter((c) => !/dep-c/.test(c.name)); b.dependencies = (b.dependencies || []).map((d) => ({ ...d, dependsOn: (d.dependsOn || []).filter((x) => !/dep-c/.test(x)) })).filter((d) => !/dep-c/.test(d.ref)); })); +await test('CONTROL: o versiune schimbata in SBOM (si purl-ul ei) -> prins', () => editat('versiune', (b) => { const c = b.components.find((x) => x.name === 'dep-b'); c.version = '2.0.1'; c.purl = c.purl.replace('2.0.0', '2.0.1'); })); +await test('CONTROL: un hash de integritate schimbat in SBOM -> prins', () => editat('hash', (b) => { const c = b.components.find((x) => (x.hashes || []).length); c.hashes[0].content = 'ab'.repeat(64); })); +await test('SBOM-ul atestat nedat lui verify: comparatia nu se face, si se spune', async () => { + const r = await verify(att, [TGZ], { rebuildFrom: C }); + cere(check(r, /^rebuild: SBOM$/) && check(r, /^rebuild: SBOM$/).pass === null, 'trebuia raportat nefacut'); +}); +await test('un arbore fara package-lock.json comis: SBOM-ul nu se poate re-deriva, si se spune (nu e trecut drept VALID)', () => { + const r = sbomNpmFromTree(G, 'HEAD:fara'); + cere(!r.bom && /no package-lock/.test(r.lipsa || ''), JSON.stringify(r).slice(0, 120)); +}); +for (const d of [G, C, OUT]) fs.rmSync(d, { recursive: true, force: true }); +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +process.exitCode = esecuri.length ? 1 : 0; diff --git a/tools/proof-of-software/test/semnatar-control-negativ.mjs b/tools/proof-of-software/test/semnatar-control-negativ.mjs new file mode 100644 index 0000000..80d5de3 --- /dev/null +++ b/tools/proof-of-software/test/semnatar-control-negativ.mjs @@ -0,0 +1,11 @@ +// Controlul negativ al semnatarului (semnatar.test.mjs, B-18), prin mecanismul comun din _control.mjs: fiecare paznic scos intr-o +// copie a lui pos.mjs trebuie sa inroseasca exact proba lui. +// node aerenew/tools/proof-of-software/test/semnatar-control-negativ.mjs +import { controleaza } from './_control.mjs'; + +process.exitCode = controleaza('semnatar.test.mjs', [ + ['--signer nu mai compara cheile', "!!semnatar && semnatar === keyId(publicKeysOf(signer))", '!!semnatar', '3. ATAC'], + ['o atestare nesemnata trece de --signer', "!!semnatar && semnatar === keyId(publicKeysOf(signer))", 'true', '5. o atestare nesemnata'], + ['randul "signer" nejudecat nu mai apare', "else if (!(att.statement.builder && att.statement.builder.credential && trustIssuer)) checks.push({ name: 'signer', pass: null,", "else if (false) checks.push({ name: 'signer', pass: null,", '2. fara --signer'], + ['amprenta cheilor scoasa din numele verificarii', "by keys ${semnatar ? semnatar.slice(0, 18) : '?'}:", ':', '1. numele verificarii'], +]); diff --git a/tools/proof-of-software/test/semnatar.test.mjs b/tools/proof-of-software/test/semnatar.test.mjs new file mode 100644 index 0000000..f4886b2 --- /dev/null +++ b/tools/proof-of-software/test/semnatar.test.mjs @@ -0,0 +1,67 @@ +// Proof of Software, B-18 (2026-09-29): o semnatura valida spune ca NISTE chei au semnat, nu CARE. 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. Fiecare afirmatie cu perechea ei negativa. Offline. +// node test/semnatar.test.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { attest, verify, publicKeysOf, issueCredential } from '../pos.mjs'; +import * as pq from '../../../sdk-pq-sign/index.mjs'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +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 cere = (c, m) => { if (!c) throw new Error(m); }; +const check = (r, re) => r.checks.find((c) => re.test(c.name)); + +const D = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-semnatar-')); +const BUN = path.join(D, 'app.tgz'); fs.writeFileSync(BUN, 'artefactul constructorului'); +fs.mkdirSync(path.join(D, 'strain')); const RAU = path.join(D, 'strain', 'app.tgz'); fs.writeFileSync(RAU, 'artefactul unui strain'); +const dev = pq.generateKeyPair(), strain = pq.generateKeyPair(), org = pq.generateKeyPair(); +const aBun = await attest({ artifacts: [BUN], name: 'app', version: '1.0.0', keys: dev, cwd: D }); +const aRau = await attest({ artifacts: [RAU], name: 'app', version: '1.0.0', keys: strain, cwd: D }); + +await test('1. numele verificarii semnaturii poarta amprenta cheilor care au semnat (doua chei -> doua amprente)', async () => { + const r1 = await verify(aBun, [BUN]), r2 = await verify(aRau, [RAU]); + const n1 = check(r1, /^hybrid signature/).name, n2 = check(r2, /^hybrid signature/).name; + cere(/by keys 0x[0-9a-f]{16}/.test(n1) && n1 !== n2, `${n1} | ${n2}`); +}); +await test('2. fara --signer si fara acreditare judecata: cine a semnat e raportat NEJUDECAT, nu tacut', async () => { + const r = await verify(aRau, [RAU]); + cere(r.valid && check(r, /^signer$/) && check(r, /^signer$/).pass === null && /anyone can sign/.test(check(r, /^signer$/).detail), JSON.stringify(check(r, /^signer$/))); +}); +await test('3. ATAC: atestarea strainului, verificata cu --signer = cheile constructorului -> INVALIDA', async () => { + const r = await verify(aRau, [RAU], { signer: publicKeysOf(dev) }); + cere(!r.valid && check(r, /signed by the keys you gave/).pass === false, JSON.stringify(check(r, /signed by the keys you gave/))); +}); +await test('4. CONTROL: atestarea constructorului, cu --signer = cheile lui (fisierul public) -> VALIDA', async () => { + const r = await verify(aBun, [BUN], { signer: publicKeysOf(dev) }); + cere(r.valid && check(r, /signed by the keys you gave/).pass === true, JSON.stringify(r.checks.filter((c) => c.pass === false))); +}); +await test('5. o atestare nesemnata, cu --signer -> INVALIDA (nu "absenta")', async () => { + const a = await attest({ artifacts: [BUN], name: 'app', version: '1.0.0', cwd: D }); + const r = await verify(a, [BUN], { signer: publicKeysOf(dev) }); + cere(!r.valid && /not signed/.test(check(r, /signed by the keys you gave/).detail), JSON.stringify(check(r, /signed by the keys you gave/))); +}); +await test('6. cu o acreditare judecata (--trust-issuer), semnatarul e judecat de acreditare si randul "signer" nu mai apare', async () => { + const cred = issueCredential({ issuerKeys: org, issuerName: 'Org', subjectKeys: publicKeysOf(dev), subjectName: 'Dev', validFrom: '2026-01-01T00:00:00Z', validUntil: '2027-01-01T00:00:00Z' }); + const a = await attest({ artifacts: [BUN], name: 'app', version: '1.0.0', keys: dev, credential: cred, cwd: D, now: new Date('2026-06-01T00:00:00Z') }); + const r = await verify(a, [BUN], { trustIssuer: publicKeysOf(org) }); + cere(!check(r, /^signer$/) && check(r, /names the keys that signed/).pass === true, JSON.stringify(r.checks.map((c) => c.name))); +}); +await test('7. linia de comanda: verify --signer (0 pentru constructor, 1 pentru strain), si "not judged" numarat pe randul de verdict', async () => { + const pos = path.join(AICI, '..', 'pos.mjs'); const f = (n) => path.join(D, n); + fs.writeFileSync(f('dev.json'), JSON.stringify(dev)); + execFileSync(process.execPath, [pos, 'pubkey', '--keys', f('dev.json'), '--out', f('dev.pub.json')], { stdio: 'pipe' }); + fs.writeFileSync(f('bun.json'), JSON.stringify(aBun)); fs.writeFileSync(f('rau.json'), JSON.stringify(aRau)); + const run = (args) => { try { return { cod: 0, out: execFileSync(process.execPath, [pos, ...args], { stdio: 'pipe' }).toString() }; } catch (e) { return { cod: e.status, out: String(e.stdout || '') }; } }; + const b = run(['verify', f('bun.json'), '--signer', f('dev.pub.json'), BUN]), r = run(['verify', f('rau.json'), '--signer', f('dev.pub.json'), RAU]); + const fara = run(['verify', f('rau.json'), RAU]); + cere(b.cod === 0 && r.cod === 1 && fara.cod === 0 && /not judged/.test(fara.out), `${b.cod} ${r.cod} ${fara.cod} ${fara.out.split('\n').slice(-2).join(' ')}`); +}); +fs.rmSync(D, { recursive: true, force: true }); +console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); +process.exitCode = esecuri.length ? 1 : 0;