aere-proof-of-software/tools/proof-of-software/README.md
Aere Network 065f84e34a 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.
2026-09-29 22:46:57 +03:00

14 KiB

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

{ "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

- 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.