Supply chain
How a downloaded RS-Key release proves it came from this source tree, was built by this project’s CI, and pulls in only reviewed dependencies. And how you verify each claim yourself.
No private keys are involved here. The build provenance is keyless (sigstore/Fulcio, signed against this workflow’s GitHub OIDC identity, recorded in the public Rekor transparency log). The only signing key in the project is the secure-boot key. That is a different thing entirely. It seals an image so the RP2350 bootrom will run it. It has nothing to do with the supply chain. See production.md for that.
What every release carries
| Layer | Artifact | What it proves |
|---|---|---|
| Reproducible build | the 14 .uf2 flavors | the binary is a pure function of the source at the tag. Anyone can rebuild it — which says the BUILD is deterministic, not that the machine code preserves what the source proves |
| Repro gate | (CI, blocking) | the release job fails if any flavor doesn’t rebuild bit-identical, so a non-reproducible image is never published |
| Checksums + signature | SHA256SUMS + SHA256SUMS.sigstore.json | the hashes were signed by this repo’s release workflow (keyless cosign). Up to v0.4.10 the same file is named SHA256SUMS.cosign.bundle |
| Build provenance | a GitHub attestation, plus rs-key-<tag>.intoto.jsonl on the release | which reusable workflow, at which commit, on which runner built each .uf2. SLSA v1 Build L3, keyless via attest-build-provenance. The API copy is authoritative; the file is for offline checking |
| SBOM | rs-key-<tag>-sbom.cdx.json | the CycloneDX bill of materials for the firmware crate |
| Dependency audit | supply-chain/ (in-repo) | every dependency is covered by an imported audit or a recorded exemption (cargo-vet) |
The toolchain that builds it
A reproducible build says the binary is a pure function of the source and the
toolchain. This is the toolchain half: every tool a shipped byte passes through,
and every tool whose word a published verification result rests on, with the file
that pins each one. The table is generated from
assurance/toolchain.toml
and re-read out of flake.lock, Cargo.lock, .github/workflows/ and
assurance/platform.toml on every check.sh run.
Generated by scripts/toolchain_gate.py from assurance/toolchain.toml and the files that pin it. Each pin below was re-read out of its provenance on the run that wrote this table; a row whose file no longer says the same thing fails check.sh.
| Tool | Role | Pinned by | Pin |
|---|---|---|---|
arm-none-eabi-as | assembler | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
arm-none-eabi-gcc | compiler | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
arm-none-eabi-nm | packager | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
cargo | packager | flake.lock:fenix | 3a556b6fbd42412b6f3f0ea8d35959b1826f86ff |
cargo-kani | prover | workflow:.github/workflows/ci.yml:KANI_VERSION | 0.67.0 |
cbmc | checker | nothing | open obligation PLAT-TOOL-003 |
cortex-m | assembler | Cargo.lock:cortex-m | 8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9 |
flip-link | linker | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
jre8 | runtime | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
picotool | packager | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
rust-lld | linker | flake.lock:fenix | 3a556b6fbd42412b6f3f0ea8d35959b1826f86ff |
rustc | compiler | flake.lock:fenix | 3a556b6fbd42412b6f3f0ea8d35959b1826f86ff |
tlaplus | checker | flake.lock:nixpkgs | 331800de5053fcebacf6813adb5db9c9dca22a0c |
Root inputs of flake.lock deliberately outside this TCB:
flake-utils— eachDefaultSystem plumbing (flake.nix:36). It emits no binary into any build; it decides which systems the outputs are instantiated for.nixpkgs-sdl2— SDL2 alone, for the tools/emu –display window (flake.nix:9-15). Nothing from this pin enters a firmware build, a Kani run or a TLC run; it exists because unstable’s sdl2-compat emits SDL3 window events the Rust crate aborts on.
The FFI boundaries. Every place a name in this tree binds to a symbol Rust does not define, read out of the tree on the run that wrote this table: an item an extern block declares (import), an item defined here for a foreign caller (export), and a foreign translation unit a build.rs compiles into the image (unit). The set is held both ways — a crossing no [[boundary]] row claims fails check.sh, and so does a row for a crossing that is no longer there.
| Boundary | Kind | Derived from | Foreign side produced by |
|---|---|---|---|
bignum_modexp_private_exponent | import | crates/rsk-rsa/src/lib.rs, extern "C", #[cfg(target_os = "none")] | arm-none-eabi-gcc |
bignum_modexp_public_exponent | import | crates/rsk-rsa/src/lib.rs, extern "C", #[cfg(target_os = "none")] | arm-none-eabi-gcc |
rsa_private_exp_crt | import | crates/rsk-rsa/src/lib.rs, extern "C", #[cfg(target_os = "none")] | arm-none-eabi-gcc |
__kvcnt_end | import | firmware/src/main.rs, extern "C", unconditional | rust-lld |
__kvcnt_start | import | firmware/src/main.rs, extern "C", unconditional | rust-lld |
__kvmain_end | import | firmware/src/main.rs, extern "C", unconditional | rust-lld |
__kvmain_start | import | firmware/src/main.rs, extern "C", unconditional | rust-lld |
_stack_end | import | firmware/src/main.rs, extern "C", unconditional | flip-link |
crates/rsk-rsa/csrc/bignum_asm.S | unit | crates/rsk-rsa/build.rs, cc::Build | arm-none-eabi-as |
crates/rsk-rsa/csrc/bignum_high_level.c | unit | crates/rsk-rsa/build.rs, cc::Build | arm-none-eabi-gcc |
Coverage against the six TCB categories. Enumerated here, each with a pin held against its source file:
- assembler —
arm-none-eabi-as,cortex-m - compiler —
arm-none-eabi-gcc,rustc - ffi — the 10 boundary(s) in the table above, each joined to the pinned tool that produces its foreign half
- linker —
flip-link,rust-lld
Not enumerated by this generator at all, and not by any other:
- bootrom — burned into the RP2350; no file here records a version or a hash for it (assurance/platform.toml’s PLAT-ROM-001 stands in its place)
- llvm-passes — rustc’s LLVM version is written in no file of this tree and its pass pipeline in none anywhere;
rustc -vVwould answer for one machine
What the table proves is that each tool it names carries a pin equal to the value in the file that pins it. It does not prove that the tool which actually ran was that version, that a contributor’s PATH holds it, or that a /nix/store path still matches its narHash.
And the boundaries are the boundaries of this tree. A dependency declares its own, in its own source, where no reader of this checkout can see them: 25 of Cargo.lock’s 276 checksummed crates carry extern "C" or #[no_mangle], cortex-m and cortex-m-rt among them. For those, what is recorded here is the pin that fixes the crate, not the symbols it crosses on.
The release procedure, command by command
The table above pins the tools. This one records the procedure: every command
a release runs, the inputs each one consumes, and — one column at a time — what
that command actually settles. It is generated from
release.yml,
release-build.yml
and
firmware.nix
on every check.sh run: every command, flavor, action pin and asset name below
is read out of those three files rather than typed here.
Deriving the commands is not the same as holding the pipeline, and the earlier
wording claimed it was. A key beside the commands decides whether the steps run
at all, on which machine, and with which token — and a derivation that reads
commands never sees one. Sixteen such edits were measured past the first version
of this row, from if: false on the job to a set +e inside the reproducibility
gate. They are refused by the same row now, by rule rather than by derivation:
the job’s own keys, its runner and its scopes are pinned, a step that may not run
or whose block is not bash is refused, and so is a caller that runs steps of its
own or releases a tag outside v*. What is neither derived nor pinned is listed
under What this table does not read, at the end.
Generated by scripts/release_gate.py from .github/workflows/release.yml, .github/workflows/release-build.yml, nix/firmware.nix and scripts/pt.sh. Every command and every input below was read out of those files on the run that wrote this table; a file that no longer says the same thing fails check.sh.
This is a recipe, not a record. It says what a release runs, over which inputs. It is bound to no tag, no commit and no artifact — see the open obligations at the end.
Two words that are not each other.
bit-for-bit— the same inputs produce the same output bytes. A property of the build, established below.source->binary— the emitted machine code does what the source says. A property of the compiler, established by nothing here.PLAT-TOOLCHAIN-001inassurance/platform.tomlowns it and ispending; it writes the same gapsource-to-binaryin prose.
A miscompilation reproduces exactly as well as a correct compilation does, so no rebuild can settle the second word. The subject column is held against the shape of each step’s own command, and the second word is not a value that column can take.
What a subject means
The closed vocabulary the Subject column below is drawn from. Printed here because a gloss nothing reads is a gloss anything can be written into: rewriting one to say what source->binary says, under a key this gate was comparing and never opening, was the third way past the rule above.
| Subject | What claiming it means |
|---|---|
admission | which commits may become a release at all |
bit-for-bit | the same inputs produce the same output bytes |
integrity | the bytes you have are the bytes that were published |
inventory | what went into the artifact |
none | produces or publishes an artifact; establishes nothing on its own |
origin | which workflow, at which commit, on which runner built it |
The steps a release runs
| # | Step | Subject | What it settles |
|---|---|---|---|
| 1 | actions/checkout | none | Fetches the tagged tree, deep enough that the tag gate below can read origin/main out of it. |
| 2 | DeterminateSystems/nix-installer-action | none | Installs Nix. The build’s determinism rests on the sandbox this provides, not on the runner image. |
| 3 | nix-community/cache-nix-action | none | A cache keyed on flake.lock. A cache hit and a cache miss must produce the same bytes; the rebuild step is what says they do. |
| 4 | actions/cache | none | The cargo registry cache. Outside the Nix sandbox, so it feeds no shipped byte — nix build vendors from Cargo.lock. |
| 5 | sigstore/cosign-installer | none | Installs the signer. Its pin is part of the trust base: this is the code that holds the OIDC token. |
| 6 | resolve tag | admission | Shape, charset, and an ancestor-of-main test. Defence in depth only: an actor who can push a tag also controls this file at that ref, so the primary control is a repository tag ruleset. |
| 7 | build the 14 reproducible firmware flavors | none | Produces the images. A build alone establishes nothing — it is the rebuild below that turns it into evidence. |
| 8 | reproducibility gate — rebuild all 14, require bit-identical | bit-for-bit | Recompiles every flavor already in the store and fails on a hash mismatch, so a non-reproducible image is never published. Says the BUILD is a function of its inputs; says nothing about what the machine code means. |
| 9 | generate the CycloneDX SBOM | inventory | The firmware crate’s dependency tree, scoped to the shipped target. |
| 10 | checksums | integrity | A digest of every .uf2 and of the SBOM. It does not cover itself, its own signature or the provenance bundle — the last two are written after this step, and the attestation is what stands behind them. |
| 11 | attest build provenance | origin | GitHub build provenance for the .uf2 files this step is handed, bound to THIS reusable workflow’s identity — the difference between SLSA Build L3 and L2. |
| 12 | attach the provenance bundle as a release asset | none | An offline copy of the attestation. The API and Rekor stay authoritative; immutable releases forbid adding it later, so it is attached at create time. |
| 13 | sign SHA256SUMS (keyless cosign) | integrity | Keyless cosign over the checksum file. No private key exists: the signer is this workflow’s OIDC identity. |
| 14 | extract release notes from CHANGELOG | none | Extracts the version’s CHANGELOG section. Cosmetic, and capped because an oversized body fails the job after the tag is already pushed. |
| 15 | create the GitHub Release | none | Publishes dist/*. Refuses to publish a no-touch asset, which would ship a build with the physical-consent gate removed. |
The commands those steps run, as this gate reads them: \ continuations joined, and a comment cut where a shell would cut one — a # inside a quoted string is part of the command, so an awk '…' program’s own text stays whole and nothing hides behind a quote.
# 1. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# 2. uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23
# 3. uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
# 4. uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
# 5. uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
# 6. resolve tag
case "$TAG" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "refusing: tag is not vMAJOR.MINOR.PATCH" >&2; exit 1 ;;
esac
case "$TAG" in
*[!0-9A-Za-z.+-]*)
echo "refusing: tag has characters not allowed in a release tag" >&2
exit 1 ;;
esac
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$TAG^{commit}" origin/main; then
echo "refusing: $TAG does not point at a commit on main" >&2
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# 7. build the 14 reproducible firmware flavors
tag="${{ steps.tag.outputs.tag }}"
mkdir -p dist
for pkg in firmware firmware-pqc firmware-fips firmware-fips-pqc firmware-strong-pin firmware-strong-pin-pqc firmware-always-uv firmware-always-uv-pqc firmware-strict-up firmware-strict-up-pqc firmware-display firmware-2mb firmware-16mb firmware-strict-config; do
echo "::group::nix build .#$pkg"
out="$(nix build ".#$pkg" --no-link --print-out-paths)"
label="${pkg#firmware}"; label="${label#-}"
[ -z "$label" ] && label="default"
cp "$out/$pkg.uf2" "dist/rs-key-${tag}-${label}.uf2"
echo "::endgroup::"
done
ls -l dist
# 8. reproducibility gate — rebuild all 14, require bit-identical
for pkg in firmware firmware-pqc firmware-fips firmware-fips-pqc firmware-strong-pin firmware-strong-pin-pqc firmware-always-uv firmware-always-uv-pqc firmware-strict-up firmware-strict-up-pqc firmware-display firmware-2mb firmware-16mb firmware-strict-config; do
echo "::group::nix build .#$pkg --rebuild"
nix build ".#$pkg" --rebuild --no-link
echo "::endgroup::"
done
echo "all 14 flavors rebuilt bit-identical"
# 9. generate the CycloneDX SBOM
tag="${{ steps.tag.outputs.tag }}"
nix develop -c cargo cyclonedx --manifest-path firmware/Cargo.toml --target thumbv8m.main-none-eabihf --format json
sbom="firmware/firmware.cdx.json"
if [ ! -f "$sbom" ]; then
for f in firmware/*.cdx.json; do
[ -e "$f" ] || continue
sbom="$f"
break
done
fi
if [ ! -f "$sbom" ]; then echo "no SBOM produced" >&2; exit 1; fi
cp "$sbom" "dist/rs-key-${tag}-sbom.cdx.json"
# 10. checksums
cd dist
sha256sum ./*.uf2 ./*.cdx.json > SHA256SUMS
cat SHA256SUMS
# 11. uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
# 12. attach the provenance bundle as a release asset
cp "${{ steps.attest.outputs.bundle-path }}" "dist/rs-key-${{ steps.tag.outputs.tag }}.intoto.jsonl"
# 13. sign SHA256SUMS (keyless cosign)
cosign sign-blob --bundle dist/SHA256SUMS.sigstore.json dist/SHA256SUMS
# 14. extract release notes from CHANGELOG
ver="${{ steps.tag.outputs.version }}"
awk -v v="$ver" '
$0 ~ "^## \\[" v "\\]" { f = 1; print; next }
/^## \[/ { if (f) exit }
f { print }
' CHANGELOG.md > release-notes-full.md
cp release-notes-full.md release-notes.md
if [ ! -s release-notes.md ]; then
echo "RS-Key ${{ steps.tag.outputs.tag }}" > release-notes.md
fi
if [ "$(wc -c < release-notes.md)" -gt 118000 ]; then
awk '
/^### / && $0 !~ /^### TL;DR/ { exit }
{ print }
' release-notes.md > release-notes.short
if [ "$(wc -c < release-notes.short)" -lt 200 ]; then
awk '
{ total += length($0) + 1 }
total > 115000 { exit }
/^- \*\*/ { last = NR }
{ line[NR] = $0; kept = NR }
END {
stop = (last > 1) ? last - 1 : kept
for (i = 1; i <= stop; i++) print line[i]
}
' release-notes.md > release-notes.short
fi
mv release-notes.short release-notes.md
{
echo
echo "_Shortened to fit GitHub's release-body limit._"
echo "_Every entry is in [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ steps.tag.outputs.tag }}/CHANGELOG.md) at this tag._"
} >> release-notes.md
fi
if grep -qE '<!-- @increase-anti-rollback-epoch([^>]*)-->' release-notes-full.md; then
marker="$(grep -oE '<!-- @increase-anti-rollback-epoch([^>]*)-->' release-notes-full.md | head -n1)"
if ! grep -qF "$marker" release-notes.md; then
printf '\n%s\n' "$marker" >> release-notes.md
fi
fi
{
echo
echo "---"
echo "Verify a download: https://github.com/${{ github.repository }}/blob/${{ steps.tag.outputs.tag }}/docs/supply-chain.md"
} >> release-notes.md
cat release-notes.md
# 15. create the GitHub Release
tag="${{ steps.tag.outputs.tag }}"
if ls dist/*no-touch* >/dev/null 2>&1; then
echo "refusing to publish a no-touch firmware asset" >&2
exit 1
fi
gh release create "$tag" --title "RS-Key $tag" --notes-file release-notes.md dist/*
The image each flavor is
The build step and the reproducibility gate iterate the same list, and a difference between them is a check.sh failure. Every published image is nix/firmware.nix’s package of the same name.
| Flavor | Published as | Build selection |
|---|---|---|
firmware | rs-key-<tag>-default.uf2 | no flags, no knobs |
firmware-pqc | rs-key-<tag>-pqc.uf2 | --features advertise-pqc |
firmware-fips | rs-key-<tag>-fips.uf2 | --features fips-profile |
firmware-fips-pqc | rs-key-<tag>-fips-pqc.uf2 | --features fips-profile,advertise-pqc |
firmware-strong-pin | rs-key-<tag>-strong-pin.uf2 | --features strong-pin |
firmware-strong-pin-pqc | rs-key-<tag>-strong-pin-pqc.uf2 | --features strong-pin,advertise-pqc |
firmware-always-uv | rs-key-<tag>-always-uv.uf2 | --features always-uv |
firmware-always-uv-pqc | rs-key-<tag>-always-uv-pqc.uf2 | --features always-uv,advertise-pqc |
firmware-strict-up | rs-key-<tag>-strict-up.uf2 | --features strict-up |
firmware-strict-up-pqc | rs-key-<tag>-strict-up-pqc.uf2 | --features strict-up,advertise-pqc |
firmware-display | rs-key-<tag>-display.uf2 | --features display, flashSize = "16M", ledKind = "none" |
firmware-2mb | rs-key-<tag>-2mb.uf2 | flashSize = "2M", kvmain = "896K" |
firmware-16mb | rs-key-<tag>-16mb.uf2 | flashSize = "16M" |
firmware-strict-config | rs-key-<tag>-strict-config.uf2 | --features strict-config |
Inside the sandbox
What nix build .#<flavor> runs, out of nix/firmware.nix:
# buildPhase
export PATH="${toolchain}/lib/rustlib/$(rustc -vV | sed -n 's/^host: //p')/bin:$PATH"
cargo build --release --offline --frozen -p firmware --target ${target} --config "target.${target}.rustflags=[\"--remap-path-prefix=$NIX_BUILD_TOP=/build\",\"--remap-path-prefix=${toolchain}=/toolchain\"]" ${lib.escapeShellArgs cargoFlags}
# installPhase
mkdir -p "$out"
bash scripts/pt.sh "target/${target}/release/firmware" "$out/${name}.elf"
picotool uf2 convert "$out/${name}.elf" -t elf "$out/${name}.uf2"
Inputs
The files that decide what a release is. A digest here covers the parts the tables above do not print, and it moves when its file moves and at no other time.
| File | sha256 |
|---|---|
.github/workflows/release.yml | 943eae75595831de403c51f1468be0d255b182a20fb155ab9a9eca956c54270f |
.github/workflows/release-build.yml | 1ddbf12b1acfd1e09793a9b574128e2e692cf76351b5e5614545086894655584 |
nix/firmware.nix | 93468064b0328a06e8059b418e3e220df3ee4f273b4445052494f832eb5e26d8 |
scripts/pt.sh | c55ba6255421a664c13ba8b1e3b05b01af842d3ba8e95cd986a7a46005c877ed |
The CI code that runs them, pinned by commit:
| Action | Pin | Release |
|---|---|---|
actions/checkout | 3d3c42e5aac5ba805825da76410c181273ba90b1 | v7.0.1 |
DeterminateSystems/nix-installer-action | 3138316df39ed29be04236d7ffc686fa525866aa | v23 |
nix-community/cache-nix-action | 7df957e333c1e5da7721f60227dbba6d06080569 | v7.0.2 |
actions/cache | 55cc8345863c7cc4c66a329aec7e433d2d1c52a9 | v6.1.0 |
sigstore/cosign-installer | 6f9f17788090df1f26f669e9d70d6ae9567deba6 | v4.1.2 |
actions/attest-build-provenance | 4d101475d8b20a2381f78447822ac1eab6504dd8 | v4.2.2 |
The toolchain closure — flake.lock, Cargo.lock and the tools they pin — is the table above this section, and is deliberately not restated here: one copy, one place it can rot.
Published assets: SHA256SUMS, SHA256SUMS.sigstore.json, rs-key-<tag>-16mb.uf2, rs-key-<tag>-2mb.uf2, rs-key-<tag>-always-uv-pqc.uf2, rs-key-<tag>-always-uv.uf2, rs-key-<tag>-default.uf2, rs-key-<tag>-display.uf2, rs-key-<tag>-fips-pqc.uf2, rs-key-<tag>-fips.uf2, rs-key-<tag>-pqc.uf2, rs-key-<tag>-sbom.cdx.json, rs-key-<tag>-strict-config.uf2, rs-key-<tag>-strict-up-pqc.uf2, rs-key-<tag>-strict-up.uf2, rs-key-<tag>-strong-pin-pqc.uf2, rs-key-<tag>-strong-pin.uf2, rs-key-<tag>.intoto.jsonl.
What this table does not read
- a step’s
with:andenv:blocks. They are inside the digest above and are printed nowhere, so a statement in the table must not rest on one —subject-path,fetch-depthandCOSIGN_YESare settings this page describes only in the round. - the workflow’s own header comments. They are prose about the procedure, not the procedure.
- what a pinned action DOES. The
uses:steps run code out of another repository; the table above records which commit of it a release runs, and nothing whatsoever about what that commit does. - what a command MEANS. The
subjectcolumn is decided by a command’s shape; a step that is present, spelled right and wrong about the world is outside every rule here.
Open obligations
- no entry on this page is bound to a release artifact. Binding one needs a
v*tag and arelease-build.ymlrun: the tag, the commit, the runner, the per-.uf2sha256 and the Rekor entry all come into existence there and in no checkout. Cost: one tagged release, by the maintainer, plus a manifest field per bound value and a gate rule holding it against the attestation API. Until then this page is a recipe: what a release runs, not what one ran. source->binaryis established by nothing here.PLAT-TOOLCHAIN-001inassurance/platform.tomlowns it and is stillpending. A reproducible build is a statement about determinism; a miscompilation reproduces exactly as well as a correct compilation does.
Verifying a download
1. Reproducible build
Rebuild from the tagged source and compare. This is the strongest check, because it needs no trust in the publisher at all:
git checkout <tag>
nix build .#firmware # or .#firmware-pqc, .#firmware-fips, …
sha256sum result/firmware.uf2 # compare against SHA256SUMS
CI already enforces this: the release job rebuilds all fourteen flavors with
nix build --rebuild and fails on any bit-level difference before publishing.
2. Checksum signature (keyless cosign)
cosign verify-blob \
--bundle SHA256SUMS.sigstore.json \
--certificate-identity-regexp '^https://github.com/TheMaxMur/RS-Key/\.github/workflows/release-build\.yml@.*$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS
sha256sum -c SHA256SUMS # then check the artifacts against it
The certificate identity is release-build.yml, not release.yml: cosign
runs inside the reusable builder. Sigstore stamps the cert with the reusable
workflow’s identity (job_workflow_ref).
3. Build provenance (GitHub attestation)
gh attestation verify rs-key-<tag>-default.uf2 \
--repo TheMaxMur/RS-Key \
--signer-workflow TheMaxMur/RS-Key/.github/workflows/release-build.yml
This confirms the .uf2 was built by the release-build.yml reusable
workflow in this repo. The attestation records the workflow, commit and
runner, so a hand-built upload won’t verify. Pinning --signer-workflow to the
reusable builder is the SLSA Build L3 check: it proves a specific, trusted
workflow produced the artifact, not merely that something in the repo did.
(Dropping --signer-workflow still verifies an attestation exists for this repo,
a weaker Build-L2-style check.) The provenance is a GitHub attestation
(Sigstore-signed, logged in Rekor) kept in the attestation API rather than as a
release asset, so it stays available even though the published release is
immutable.
Dependency review — cargo-vet
cargo-deny already blocks bad licenses and known advisories. cargo-vet
answers a different question (has anyone actually reviewed this crate’s code?)
by requiring every dependency to be covered by a recorded audit.
The audit set lives in supply-chain/:
imported audits from Mozilla, Google, ISRG and Zcash, plus our own
exemptions for everything they don’t cover. The gate runs in check.sh:
nix develop -c cargo vet --locked
Honest scope. RS-Key is an embedded tree: embassy, the RP2350 HAL, defmt
and many RustCrypto crates are not in the big organizations’ audit sets, so they
are recorded as exemptions (grandfathered in, not yet line-reviewed). The
point isn’t that every line is audited. It’s that a new, unreviewed crate
cannot enter the tree silently. It fails cargo vet until it’s audited,
imported, or explicitly exempted. To see the current state and shrink the
exemption list:
nix develop -c cargo vet # what's audited vs exempted
nix develop -c cargo vet suggest # diffs to review next
The host tools/tui workspace is separate and not yet under cargo-vet. It is
covered by Dependabot and cargo-deny.
Transparency-log monitoring
The Rekor log that backs every signature above is tamper-evident, not
tamper-proof: it records misuse, but only if someone looks. A scheduled
workflow,
rekor-monitor.yml,
does the looking. Every hour it runs
sigstore/rekor-monitor and files
a GitHub issue listing any Rekor entry signed as an RS-Key GitHub-Actions
identity (any …/RS-Key/.github/workflows/* subject under the GitHub OIDC
issuer).
This is the detection half that complements the verification above: the
attestations prove a legitimate release is genuine, while the monitor surfaces
an illegitimate signature, one made with our identity by something we didn’t
run (a compromised OIDC token, repo, or runner). Each real release adds a couple
of expected entries from release-build.yml, so a known issue or two per release
is normal. The alarm is an entry you don’t recognise.
What’s deliberately not here
- No
cargo-vetof a fully line-reviewed tree. See the honest scope above. - No image encryption / signed-for-boot release artifacts. The published
.uf2s are unsigned for secure boot by design. You seal them with your own key (production.md). The signatures on this page attest the build, not the boot.
See also: releases.md for the release index, and COMPLIANCE.md for the licensing posture.