# 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 # 11/11: 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; # an edited SBOM that names another lock file (1.5.0) -> caught node test/sbom-control-negativ.mjs # on a copy, 5 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 # 14/14 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; an edited SBOM with the # property removed -> not judged, and the reason names go.sum (1.5.0) node test/sbom-go-control-negativ.mjs # on a copy, 8 guards removed -> their own tests turn red ``` ## .NET and Gradle SBOMs, from the committed lock files (1.5.0) ``` node pos.mjs sbom-nuget --source-path ./src/MyApp --version 1.4.2 --out sbom.cdx.json # packages.lock.json + the ONE project file next to it node pos.mjs sbom-gradle --source-path ./myapp --version 1.4.2 --out sbom.cdx.json # gradle/verification-metadata.xml (+ gradle.lockfile) ``` Both read only the committed tree (`:`), with no toolchain and no network, and the same files always give the same bytes. `verify --rebuild-from` re-derives the SBOM the same way and compares what the two say. **.NET.** From `packages.lock.json` (written by NuGet with `RestorePackagesWithLockFile`): every package of every target framework (and framework/RID section) with its RESOLVED version and a `pkg:nuget` purl, project references as components `project:`, and the dependency graph as the lock file records it. NuGet's `contentHash` is kept as the property `aere:nuget-content-hash`, not as a SHA-512 of the package: NuGet computes it over the package without its repository signature, so it is not the hash of the `.nupkg` file you download (measured on signed packages). The root is named by the project file, so the directory must hold exactly one. **Gradle.** From `gradle/verification-metadata.xml` (Gradle's dependency verification, written with `--write-verification-metadata`) and, when committed, `gradle.lockfile` (`--write-locks`). Every component with a `pkg:maven` purl; the main artifact's checksums (`-.jar`/`.aar`) are the file's own, so they are CycloneDX hashes; every artifact (POMs, Gradle module files) is kept in `aere:gradle-artifact` with its checksums, `also-trust` values and PGP keys; the configurations each coordinate is locked in are in `aere:gradle-configurations`; the number of `trusted-artifacts` rules (which switch verification off for what they match) and the `verify-metadata`/`verify-signatures` settings are in the metadata. What it does NOT say: neither file records the dependency graph, so `dependencies` is empty and the metadata says so; the verification metadata lists everything the build resolved (parent POMs, plugins too). A coordinate locked in `gradle.lockfile` with no checksum in the verification metadata is listed and counted (`aere:gradle-no-checksum`), not hidden. The reader accepts only the XML Gradle writes: a DOCTYPE, CDATA, an unclosed tag or comment, crossed tags or an unquoted attribute are refused with the reason, not guessed. In both cases the SBOM says what the committed lock files pin or resolve, not what was compiled: the build uses them only in locked mode (`dotnet restore --locked-mode`, Gradle's strict dependency verification and locking). **The committed tree decides what an SBOM is judged against (1.5.0).** Until 1.4.0 the attested SBOM chose its own judge through its `aere:derived-from` property: an npm SBOM edited by hand that said `derived-from: go.sum` came out not judged ("the tree has no go.mod"), and a Go SBOM edited by hand with the property removed was judged as npm and came out not judged too, both with the verdict VALID. Now the property is a claim about the tree: a named file the committed tree does not have fails the check; an SBOM that names no file (npm sbom and other tools do not), or one the verifier does not know, is judged against `package-lock.json`; and when the tree has no `package-lock.json` but has `go.sum`, `packages.lock.json` or Gradle's verification metadata, it stays not judged and the reason names the file and the command whose SBOM would be judged. What remains: whoever attests can always hand an SBOM made by another tool, which this verifier cannot judge; the verdict line counts it among the not judged. The files these read belong to the tree being attested, that is to whoever attests, and the verifier reads them: every reader here costs linear time (measured on forms made for the purpose: a 80 KB tag, 80,000 blank lines, 8,000 unclosed tags, under a millisecond to a few tens of milliseconds each). ``` node test/sbom-nuget-gradle.test.mjs # 27/27 on real lock files written by the tools themselves (test/fixturi-nuget: # .NET SDK 10.0.302, restored offline from the local package cache; test/fixturi-gradle: # Gradle 8.14.3 --offline), contentHash equal to what NuGet recorded at restore (90/90), # SHA-256 equal to the jars in Gradle's cache (when present; skipped otherwise); a package # dropped, a version, a hash, an edge, a configuration changed -> caught; broken inputs refused node test/sbom-nuget-gradle-control-negativ.mjs # on a copy, 20 guards removed -> their own tests turn red ``` Maven (`pom.xml` without a lock file) is not done: Maven has no lock file of its own, and an SBOM derived from `pom.xml` ranges would say what was asked for, not what was resolved.