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.
203 lines
14 KiB
Markdown
203 lines
14 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 # 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 <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 # 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.
|