Signed daemon releases
SF-039 gives every SuiFleet daemon release three properties:
- Reproducible: two independent builds of the same commit are byte-identical.
- Inventoried: each release carries an SPDX SBOM of exactly what is linked in.
- Signed and verified on the device: each device checks the signature against its own trust roots before installing. This is on top of the content hashes that deployments already authenticate on chain.
What a release bundle contains
| File | Contents |
|---|---|
suifleet-<version>-<commit12>-aarch64-unknown-linux-gnu.tar.gz |
suifleet/bin/suifleet, LICENSE, config.example.toml. Sorted entries, fixed mtime (the commit time), root:root, gzip without name or timestamp. |
sbom.spdx.json |
SPDX 2.3: the daemon (with its binary's SHA-256) and every package in its normal dependency graph for the selected target, exactly as cargo tree -e normal --target TARGET resolves it. Each package has its Cargo.lock SHA-256, declared license and purl. Build-script-only dependencies are not linked into the binary and are not listed. |
provenance.json |
Commit, SOURCE_DATE_EPOCH, target, rustc -vV, cargo and linker versions, Cargo.lock SHA-256, builder image ID, Debian archive backend version and binary SHA-256 |
release.json |
Name, version, commit, target, and the size and SHA-256 of the artifacts above (including Debian packages/checksums when present). This is what gets signed. |
release.sig.json |
One or more {key_id, signature}: ed25519 over suifleet-release-v1\n followed by the bytes of release.json |
Building
make release-artifacts # builds HEAD twice; fails unless byte-identical; output in dist/
How it builds:
scripts/release.pyexports the committed tree withgit archive, so uncommitted files never enter a release.- It builds inside the pinned release image (
docker/release.Dockerfile). - Embedded source paths are remapped, so the build location doesn't leak into the binary.
Builder pins: Ubuntu 22.04 digest; Rust 1.94.0 via a checksum-verified rustup-init 1.29.1; gcc-aarch64-linux-gnu 4:11.2.0-1ubuntu1 (gcc 11.4.0-1ubuntu1~22.04.3cross1); binutils-aarch64-linux-gnu 2.38-4ubuntu2.12; libc6-dev-arm64-cross 2.35-0ubuntu1cross3. For 32-bit x86: gcc-i686-linux-gnu 4:11.2.0-1ubuntu1 (gcc 11.4.0-1ubuntu1~22.04.3cross1); binutils-i686-linux-gnu 2.38-4ubuntu2.12; libc6-dev-i386-cross 2.35-0ubuntu1cross3.
Debian packages (SF-231): amd64 and arm64 bundles also contain versioned .deb files and SHA-256 files covered by the same signed manifest. The SBOM describes their included daemon/signer binary; distribution dependencies are declared in Debian control metadata. See Debian build/install and data retention. The release workflow builds both targets and publishes one archive containing each complete signed bundle, avoiding ambiguous shared SBOM/manifest filenames. No signed APT repository is provided. Protected production signing remains SF-042/#62.
Targets: aarch64-unknown-linux-gnu (the default), x86_64-unknown-linux-gnu (amd64) and i686-unknown-linux-gnu (32-bit x86, LINUX_X86.md): make release-artifacts RELEASE_TARGET=i686-unknown-linux-gnu. The target names the tarball, SBOM graph and provenance linker, and is signed in release.json.
The daemon uses rustls for both gRPC and Walrus HTTP, so the release links no OpenSSL. Bump these pins deliberately, and record the new versions here.
Trust roots, signing and rotation
The trust roots are the public keys in release/trust.toml. Devices install this file root-owned as /etc/suifleet/release-trust.toml. Private keys never enter the repository, the device image, or pull-request CI.
cargo run --example sign_release -- generate /secure/path/release-2026-10.key # prints the public key
cargo run --example sign_release -- public /secure/path/release-2026-10.key
cargo run --example sign_release -- sign /secure/path/release-2026-10.key release-2026-10 dist
- Add a key: add a
[[keys]]entry (id,public_key,revoked = false) torelease/trust.tomland ship it to devices. - Rotate: add the new key and sign releases with both keys (
signruns once per key and keeps other signatures). Once every device has the new trust file, setrevoked = trueon the old key. From then on, a release signed only by the old key is refused. - Revoke a compromised key: set
revoked = trueand ship the trust file. That key's signatures are ignored. Re-sign current releases with a trusted key.
Verifying on a device
suifleet --verify-release /path/to/bundle /etc/suifleet/release-trust.toml
Add the device's target triple as a third argument (suifleet --verify-release BUNDLE TRUST i686-unknown-linux-gnu) to also refuse a correctly signed bundle built for another target. It exits non-zero unless all of these hold:
- At least one signature verifies (strict ed25519) under a trusted, non-revoked key.
- Every file listed in
release.jsonis a regular file inside the bundle (no paths, symlinks or hidden names) with the signed size and SHA-256. - With a target argument, the signed target equals it.
Installers (for example a daemon self-update recipe, or the image build) must run this before installing a release. A deployment's on-chain artifact SHA-256 proves which bytes the operator chose; the release signature proves who built them.
CI
.github/workflows/release.yml runs only on v* tags or manual dispatch, never for pull requests.
- Build job: no secrets, read-only token. It builds the bundle twice from the commit, requires identical output, and uploads it unsigned.
- Preflight: fails closed unless the
releaseenvironment has a required reviewer. - Sign job: runs in the protected
releaseenvironment.- It writes the
RELEASE_SIGNING_KEYsecret (hex seed) to a private temporary file, signs asRELEASE_SIGNING_KEY_ID, and verifies the result with the daemon's verifier againstrelease/trust.toml. - It adds GitHub (Sigstore) build-provenance attestations for every file.
- For tags, it publishes a GitHub release.
- With no trusted keys committed, verification fails, and so does the release.
- It writes the
One-time setup (by a repository admin):
- Create the
releaseenvironment with a required reviewer. - Add the
RELEASE_SIGNING_KEYsecret and theRELEASE_SIGNING_KEY_IDvariable. - Commit the matching public key to
release/trust.toml.
Verified locally (2026-10-14)
- Reproducibility:
scripts/release.py build --reproduceproduced byte-identical bundles across two clean builds (4 files). - SBOM: its 181 packages exactly match
cargo tree -e normal --target aarch64-unknown-linux-gnu, and every registry package has a checksum. - Verifier: Rust tests cover a trusted signature, a tampered artifact (hash and size), a tampered manifest, untrusted and revoked signers, rotation with two signatures, unsafe paths, duplicate trust IDs, and domain separation.
- End to end on the real bundle: with a generated key, the real bundle verified. A flipped artifact byte, an edited manifest, and an untrusted key claiming a trusted ID were each rejected. With rotation, A revoked plus B trusted was accepted, and a bundle signed only by the revoked key was refused.
Not yet done:
- The release workflow has not run, because it needs the environment and secret above.
- The aarch64 binary has not run on hardware (SF-029/SF-031).
- The Yocto image does not yet install the trust file (SF-025/SF-029).
The protected signing environment, provisioned trust roots, the first release workflow run (including rejection without a protected environment) and Pi trust delivery are tracked as external acceptance in #62 and #32. Merging this implementation is not evidence that they passed.
Source: Edit this page