257 lines
19 KiB
Markdown
257 lines
19 KiB
Markdown
# 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 <public keys>` 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 `<commit>:<path>` 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
|
|
`<relative path>\t<sha256>\t<bytes>` 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 <clone>`, when you also hand it the attested SBOM, now re-derives the
|
|
SBOM from `<commit>:<path>/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 <a clone you chose> dist/* sbom.cdx.json
|
|
```
|
|
|
|
`sbom-go` writes a CycloneDX 1.6 SBOM from `<commit>:<path>/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 (`<commit>:<path>`), 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:<name>`, 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
|
|
(`<name>-<version>.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.
|