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.
133 lines
7.6 KiB
Markdown
133 lines
7.6 KiB
Markdown
# 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: <owner>/<repo>/tools/proof-of-software/action@<full-commit-sha>
|
|
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 <artifact-path>`), 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/<statement-hash>` |
|
|
|
|
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 `<commit>:<path>` 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.
|