From 273c6c130a37e0f93dd7cdc80dd22585e3cdb30d Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:31:49 -0400 Subject: [PATCH 1/4] ci: replace sunset magic-nix-cache with a GCS-backed Nix binary cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit magic-nix-cache (Determinate-sunset) threw 418/ResourceExhausted throttling on large builds — it disabled the substituter mid-run during the desktop build. Replace it everywhere with a persistent cache: - .github/actions/setup-nix: composite that installs Nix + (if NIX_CACHE_URL/ PUBKEY vars set) adds the GCS HTTPS prefix as a substituter. Graceful: with no vars it just falls back to cache.nixos.org (already more reliable than before). - scripts/nix-cache-push.sh: best-effort push of built closures to gs:///nix-cache via signed file:// copy + gsutil rsync (no-op without NIX_CACHE_BUCKET/SECRET_KEY). build-custom-image.sh calls it after each build, so both the GitHub and GCP lanes seed the cache → later custom builds pull warm. - swapped all 5 workflows (release-images, build-custom, image-tests, nix-build-images, nix-ci) onto the composite; removed every magic-nix-cache use. - docs/NIX_CACHE_SETUP.md: one-time setup (keypair, public prefix, vars/secret). All YAML + scripts validate. --- .github/actions/setup-nix/action.yml | 36 ++++++++++++++++++++ .github/workflows/build-custom.yml | 9 +++-- .github/workflows/image-tests.yml | 12 ++++--- .github/workflows/nix-build-images.yml | 6 ++-- .github/workflows/nix-ci.yml | 7 ++-- .github/workflows/release-images.yml | 22 ++++++++---- docs/NIX_CACHE_SETUP.md | 46 ++++++++++++++++++++++++++ scripts/build-custom-image.sh | 9 +++++ scripts/nix-cache-push.sh | 39 ++++++++++++++++++++++ 9 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 .github/actions/setup-nix/action.yml create mode 100644 docs/NIX_CACHE_SETUP.md create mode 100755 scripts/nix-cache-push.sh diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 0000000..311ddcc --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,36 @@ +name: setup-nix +description: > + Install Nix (Determinate) and, if the SourceOS GCS binary cache is configured, + add it as a substituter. Replaces the sunset magic-nix-cache-action. Graceful: + with no cache vars set it just installs Nix (falls back to cache.nixos.org). +inputs: + cache-url: + description: "Public HTTPS base of the GCS Nix cache, e.g. https://storage.googleapis.com//nix-cache" + required: false + default: "" + cache-pubkey: + description: "Trusted public key for the cache (form name:base64). Pair with the push secret." + required: false + default: "" +runs: + using: composite + steps: + - name: Compose nix extra-conf (cache substituter, if configured) + id: conf + shell: bash + run: | + if [ -n "${{ inputs.cache-url }}" ] && [ -n "${{ inputs.cache-pubkey }}" ]; then + { + echo 'extra-conf<> "$GITHUB_OUTPUT" + echo "[setup-nix] GCS cache substituter enabled" + else + echo 'extra-conf=' >> "$GITHUB_OUTPUT" + echo "[setup-nix] no cache vars — using cache.nixos.org only" + fi + - uses: DeterminateSystems/nix-installer-action@v14 + with: + extra-conf: ${{ steps.conf.outputs.extra-conf }} diff --git a/.github/workflows/build-custom.yml b/.github/workflows/build-custom.yml index 605807c..16c81cb 100644 --- a/.github/workflows/build-custom.yml +++ b/.github/workflows/build-custom.yml @@ -37,8 +37,10 @@ jobs: permissions: { contents: read, id-token: write } steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Free disk space run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android || true; df -h @@ -56,6 +58,9 @@ jobs: - name: Build custom image id: build + env: + NIX_CACHE_BUCKET: ${{ vars.NIX_CACHE_BUCKET }} + NIX_CACHE_SECRET_KEY: ${{ secrets.NIX_CACHE_SECRET_KEY }} run: | PREFIX="gs://${GCS_BUCKET}/user-builds/${{ github.event.inputs.uid }}/${{ github.event.inputs.build_id }}" printf '%s' '${{ github.event.inputs.spec }}' > /tmp/spec.json diff --git a/.github/workflows/image-tests.yml b/.github/workflows/image-tests.yml index 2281c67..c61a3ac 100644 --- a/.github/workflows/image-tests.yml +++ b/.github/workflows/image-tests.yml @@ -51,8 +51,10 @@ jobs: | sudo tee /etc/udev/rules.d/99-kvm4all.rules >/dev/null sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm || true ls -l /dev/kvm || echo "no /dev/kvm (test will use slower TCG)" - - uses: DeterminateSystems/nix-installer-action@v14 - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Free disk space run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android || true; df -h - name: Run boot test @@ -88,8 +90,10 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ diff --git a/.github/workflows/nix-build-images.yml b/.github/workflows/nix-build-images.yml index c343044..32cc62f 100644 --- a/.github/workflows/nix-build-images.yml +++ b/.github/workflows/nix-build-images.yml @@ -69,8 +69,10 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Free disk space run: | diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml index 39f952d..c5bd1b5 100644 --- a/.github/workflows/nix-ci.yml +++ b/.github/workflows/nix-ci.yml @@ -37,9 +37,10 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 - - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: nix flake check run: nix flake check --system x86_64-linux --no-build 2>&1 | tee /tmp/flake-check.log || true diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml index 68766b5..88c17d7 100644 --- a/.github/workflows/release-images.yml +++ b/.github/workflows/release-images.yml @@ -44,8 +44,10 @@ jobs: permissions: { contents: read, id-token: write } steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Free disk space run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android; df -h - name: Build ISO @@ -71,8 +73,10 @@ jobs: permissions: { contents: read, id-token: write } steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 - - uses: DeterminateSystems/magic-nix-cache-action@v8 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Free disk space run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android || true; df -h - name: Build ISO @@ -97,7 +101,10 @@ jobs: permissions: { contents: read } steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Build m1n1 boot bundle run: | bash scripts/build-m1n1-bundle.sh out "${{ github.event.inputs.tag || github.ref_name }}" @@ -117,7 +124,10 @@ jobs: permissions: { contents: read } steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@v14 + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} - name: Build SourceOS Asahi OS package run: | sudo -E env "PATH=$PATH" bash scripts/build-asahi-package.sh out "${{ github.event.inputs.tag || github.ref_name }}" || \ diff --git a/docs/NIX_CACHE_SETUP.md b/docs/NIX_CACHE_SETUP.md new file mode 100644 index 0000000..7576331 --- /dev/null +++ b/docs/NIX_CACHE_SETUP.md @@ -0,0 +1,46 @@ +# SourceOS Nix binary cache — one-time setup + +Replaces the sunset `magic-nix-cache` (which threw `418 / ResourceExhausted` +throttling on large builds) with a persistent, GCS-backed Nix binary cache. +Builds **pull** warm over public HTTPS and **push** their closures so later +builds — especially custom image builds — don't rebuild from scratch. + +Until the steps below are done, the pipelines simply install Nix and fall back +to `cache.nixos.org` (a strict reliability improvement over the throttling +action). The cache turns on once the vars/secret are present — nothing breaks +in the meantime. + +## How it works +- **Pull**: `.github/actions/setup-nix` adds `https://storage.googleapis.com//nix-cache` + as an extra substituter (public-read prefix) + the trusted public key. +- **Push**: `scripts/nix-cache-push.sh` copies built store paths to a signed + `file://` cache and `gsutil rsync`s them to `gs:///nix-cache/` + (using the workflow's existing WIF GCP auth). `build-custom-image.sh` calls it + after every build, so both the GitHub and GCP build lanes seed the cache. + +## One-time setup +1. **Generate a cache key pair** (ed25519, Nix's own format): + ```sh + nix-store --generate-binary-cache-key sourceos-nix-cache-1 cache-secret.key cache-public.key + cat cache-public.key # e.g. sourceos-nix-cache-1:AbC123...= + ``` +2. **Make the cache prefix public-read** (pull is anonymous HTTPS): + ```sh + gsutil iam ch allUsers:objectViewer gs://sourceos-artifacts-socioprophet + # (or scope a bucket policy to the nix-cache/ prefix) + ``` +3. **Set repo Actions variables** (Settings → Secrets and variables → Actions → Variables): + - `NIX_CACHE_URL` = `https://storage.googleapis.com/sourceos-artifacts-socioprophet/nix-cache` + - `NIX_CACHE_PUBKEY` = the `sourceos-nix-cache-1:…` line from step 1 + - `NIX_CACHE_BUCKET` = `sourceos-artifacts-socioprophet` +4. **Set the push secret** (Settings → Secrets → Actions → Secrets): + - `NIX_CACHE_SECRET_KEY` = contents of `cache-secret.key` +5. Optional but recommended — **seed the editions** once so the first user + builds are fast: run `build-custom` for desktop/server/edge (the closures get + pushed automatically). + +## Notes +- Push is best-effort: missing secret/bucket → no-op, never fails a build. +- The GCP build-VM lane (`gcp-build-custom-startup.sh`) inherits the same env + via instance metadata if you pass `NIX_CACHE_*` there too (follow-up). +- Pull is anonymous, so PRs from forks still benefit from a warm cache. diff --git a/scripts/build-custom-image.sh b/scripts/build-custom-image.sh index bfdbbeb..3f1af3f 100755 --- a/scripts/build-custom-image.sh +++ b/scripts/build-custom-image.sh @@ -132,11 +132,13 @@ EOF log "composed build: target=$TARGET edition=$EDITION arch=$ARCH host=$HOSTNAME pkgs=[$(jq -rc '.packages // []' <<<"$SPEC")]" # ── Build ──────────────────────────────────────────────────────────────────── +CACHE_PATHS=() # store paths to push to the Nix binary cache (warms later builds) if [[ "$TARGET" == "iso" ]]; then log "nix build install-iso (long step)..." nix build --no-link --print-out-paths \ "$WORK/build#packages.${ARCH}-linux.image" --print-build-logs > "$WORK/outpath" || die "nix build failed" RESULT="$(cat "$WORK/outpath")" + CACHE_PATHS+=("$RESULT") # NB: the store path itself ends in `.iso` but is a DIRECTORY (iso/ inside); # match files only, first hit, no pipe (avoids matching the dir + SIGPIPE). ISO="$(find -L "$RESULT" -type f -name '*.iso' -print -quit)" @@ -149,6 +151,7 @@ elif [[ "$TARGET" == "netboot" ]]; then log "nix build netboot kernel + initramfs (long step)..." base="$WORK/build#nixosConfigurations.netboot.config.system.build" KDIR="$(nix build --no-link --print-out-paths "$base.kernel" --print-build-logs)" || die "kernel build failed" + CACHE_PATHS+=("$KDIR") KERNEL="$(find -L "$KDIR" -type f \( -name 'bzImage' -o -name 'Image' \) -print -quit)" [[ -n "$KERNEL" ]] || die "no kernel found in $KDIR" RAMDISK_DIR="$(nix build --no-link --print-out-paths "$base.netbootRamdisk")" || die "ramdisk build failed" @@ -184,4 +187,10 @@ if [[ -n "$GCS_PREFIX" ]]; then log "netboot base URL: $GCS_PREFIX/" fi fi + +# ── Warm the Nix binary cache (best-effort; no-op without NIX_CACHE_* env) ───── +if [[ "${#CACHE_PATHS[@]}" -gt 0 ]]; then + _selfdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + bash "$_selfdir/nix-cache-push.sh" "${CACHE_PATHS[@]}" || true +fi log "done." diff --git a/scripts/nix-cache-push.sh b/scripts/nix-cache-push.sh new file mode 100755 index 0000000..a06a104 --- /dev/null +++ b/scripts/nix-cache-push.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# nix-cache-push.sh — push built store paths (+ closure) to the SourceOS GCS Nix +# binary cache so later builds pull them warm instead of rebuilding. +# +# Best-effort: a no-op (exit 0) unless both NIX_CACHE_BUCKET and +# NIX_CACHE_SECRET_KEY are set, and never fails the build. +# +# NIX_CACHE_BUCKET=sourceos-artifacts-socioprophet \ +# NIX_CACHE_SECRET_KEY="$(cat cache.key)" \ +# bash scripts/nix-cache-push.sh ... +# +# Pull side is configured by .github/actions/setup-nix (public HTTPS substituter +# at gs://$BUCKET/nix-cache, served via https://storage.googleapis.com/...). +set -uo pipefail + +BUCKET="${NIX_CACHE_BUCKET:-}" +KEY="${NIX_CACHE_SECRET_KEY:-}" +log() { printf '[nix-cache] %s\n' "$*"; } + +[ -z "$BUCKET" ] && { log "NIX_CACHE_BUCKET unset — skipping push"; exit 0; } +[ -z "$KEY" ] && { log "NIX_CACHE_SECRET_KEY unset — skipping push"; exit 0; } +command -v gsutil >/dev/null 2>&1 || { log "gsutil absent — skipping push"; exit 0; } +command -v nix >/dev/null 2>&1 || { log "nix absent — skipping push"; exit 0; } +[ "$#" -eq 0 ] && { log "no paths given — nothing to push"; exit 0; } + +tmpkey="$(mktemp)"; printf '%s' "$KEY" > "$tmpkey" +localdir="$(mktemp -d)" +cleanup() { rm -f "$tmpkey"; rm -rf "$localdir"; } +trap cleanup EXIT + +# Copy the installables + their full runtime closure into a signed file:// cache. +if ! nix copy --to "file://$localdir?secret-key=$tmpkey" "$@" 2>&1; then + log "nix copy failed (non-fatal) — skipping rsync"; exit 0 +fi +# Sync to GCS (uses ambient GCP auth from the workflow's WIF login). +gsutil -m -q rsync -r "$localdir" "gs://$BUCKET/nix-cache/" \ + && log "pushed closure of [$*] to gs://$BUCKET/nix-cache/" \ + || log "gsutil rsync failed (non-fatal)" +exit 0 From 5d5ba444402c5a05ba743d4a61a01fa13c6374d7 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:42:23 -0400 Subject: [PATCH 2/4] feat(provenance): self-managed minisign signing + SLSA/in-toto + OSImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every built image now gets a self-sovereign signature + provenance bundle — NO third-party signing authority (no Apple Developer ID, no Google/Play signing, no notarization). The key is an ed25519 minisign keypair we generate ourselves. scripts/sign-and-provenance.sh (best-effort, never fails the build): - minisign-signs each artifact with SOURCEOS_SIGN_SECRET_KEY (graceful no-op without it — still emits unsigned provenance). Falls back to `nix run nixpkgs#minisign` when minisign isn't on PATH. - emits an in-toto Statement (statementRef urn:srcos:attestation:…), a SLSA v1 provenance predicate (slsaPredicateRef urn:srcos:slsa:…), a Nix-closure SBOM, and for ISOs a sourceos-spec v2 OSImage doc whose provenance refs resolve to the above. OSImage validated against schemas.srcos.ai/v2/OSImage.json (ajv). - build-custom-image.sh calls it after the build and uploads the sidecars; the GCP lane passes the key (+version, +cache) via instance metadata. scripts/verify-image.sh: anyone-can-verify with the public key; also cross-checks the in-toto subject digest. Tampered artifacts are rejected (tested). build-custom.yml: SOURCEOS_SIGN_SECRET_KEY secret + SOURCEOS_SIGN_PUBKEY var + VERSION/SOURCE_REV. docs/SIGNING_SETUP.md: one-time keygen (minisign -W) + publish. Closes the gap where OSImage.provenance had no real attestation behind it. --- .github/workflows/build-custom.yml | 6 + docs/SIGNING_SETUP.md | 68 +++++++++ scripts/build-custom-image.sh | 14 ++ scripts/gcp-build-custom-startup.sh | 8 + scripts/sign-and-provenance.sh | 222 ++++++++++++++++++++++++++++ scripts/verify-image.sh | 46 ++++++ 6 files changed, 364 insertions(+) create mode 100644 docs/SIGNING_SETUP.md create mode 100755 scripts/sign-and-provenance.sh create mode 100755 scripts/verify-image.sh diff --git a/.github/workflows/build-custom.yml b/.github/workflows/build-custom.yml index 16c81cb..751ede9 100644 --- a/.github/workflows/build-custom.yml +++ b/.github/workflows/build-custom.yml @@ -61,11 +61,17 @@ jobs: env: NIX_CACHE_BUCKET: ${{ vars.NIX_CACHE_BUCKET }} NIX_CACHE_SECRET_KEY: ${{ secrets.NIX_CACHE_SECRET_KEY }} + # Self-managed signing key (minisign, no third-party authority). If + # the secret is unset the build still produces unsigned provenance. + SOURCEOS_SIGN_SECRET_KEY: ${{ secrets.SOURCEOS_SIGN_SECRET_KEY }} + SOURCEOS_SIGN_PUBKEY: ${{ vars.SOURCEOS_SIGN_PUBKEY }} run: | PREFIX="gs://${GCS_BUCKET}/user-builds/${{ github.event.inputs.uid }}/${{ github.event.inputs.build_id }}" printf '%s' '${{ github.event.inputs.spec }}' > /tmp/spec.json SPEC_FILE=/tmp/spec.json OUT=out GCS_PREFIX="$PREFIX" \ TARGET="${{ github.event.inputs.target || 'iso' }}" \ + BUILD_ID="${{ github.event.inputs.build_id }}" BUILD_UID="${{ github.event.inputs.uid }}" \ + VERSION="${{ vars.SOURCEOS_VERSION || '26.11' }}" SOURCE_REV="${GITHUB_SHA}" \ bash scripts/build-custom-image.sh echo "artifact=$(cat out/artifact-url.txt)" >> "$GITHUB_OUTPUT" diff --git a/docs/SIGNING_SETUP.md b/docs/SIGNING_SETUP.md new file mode 100644 index 0000000..aa17304 --- /dev/null +++ b/docs/SIGNING_SETUP.md @@ -0,0 +1,68 @@ +# SourceOS image signing + provenance — one-time setup + +Every built image gets a **self-sovereign** signature + provenance bundle. There +is **no third-party signing authority** — no Apple Developer ID, no Google/Play +app signing, no notarization service. The key is an ed25519 keypair *you* +generate with [`minisign`](https://jedisct1.github.io/minisign/); anyone can +verify a download with the public key alone. + +## What each build emits (beside the artifact) + +| File | Purpose | Referenced by | +|------|---------|---------------| +| `.minisig` | ed25519 detached signature | `OSImage.provenance.signatureRef` | +| `.intoto.json` | in-toto Statement (the attestation) | `OSImage.provenance.statementRef` (`urn:srcos:attestation:…`) | +| `.slsa.json` | SLSA v1 provenance predicate | `OSImage.provenance.slsaPredicateRef` (`urn:srcos:slsa:…`) | +| `.sbom.json` | Nix-closure SBOM (ISO only) | `OSImage.provenance.sbomRef` | +| `.osimage.json` | `OSImage` doc, sourceos-spec v2 (ISO only) | — | + +Graceful: with **no** signing key the build still emits provenance (just +unsigned — `signatureRef` is omitted) and never fails. + +## One-time setup + +1. **Generate an unencrypted (CI-friendly) keypair.** `-W` writes a key with no + password so CI can use it non-interactively: + ```sh + minisign -G -W -p sourceos-pub.key -s sourceos-sec.key + # or, with nix and no minisign installed: + nix run nixpkgs#minisign -- -G -W -p sourceos-pub.key -s sourceos-sec.key + ``` + Keep `sourceos-sec.key` secret. **Never commit it** (it belongs with the + SOPS material under `/etc/sourceos/`, not in git). + +2. **Set the signing secret** (repo → Settings → Secrets and variables → + Actions → Secrets): + - `SOURCEOS_SIGN_SECRET_KEY` = contents of `sourceos-sec.key` + +3. **Publish the public key** so users can verify: + - Set Actions variable `SOURCEOS_SIGN_PUBKEY` = the last line of + `sourceos-pub.key` (the `RWQ…` key line). + - Also host `sourceos-pub.key` on the download site so anyone can check a + download without trusting us at fetch time. + +4. **GCP build lane** (paid tier): the backend passes the same key to the build + VM as instance metadata `sourceos-sign-secret-key` (plus + `sourceos-version`, and the cache metadata). Unset → unsigned, same as CI. + +## Verifying a download + +```sh +# pubkey can be the RWQ… line or a key file; or set $SOURCEOS_SIGN_PUBKEY +scripts/verify-image.sh sourceos-desktop-x86_64.iso "RWQ...the-public-key-line..." +``` +Exits 0 only if the signature verifies; it also cross-checks the in-toto +subject digest against the file when `.intoto.json` is present. + +Raw minisign equivalent: +```sh +minisign -V -P "RWQ...pubkey..." -m sourceos-desktop-x86_64.iso # needs the .minisig beside it +``` + +## Notes +- Rotating the key: generate a new pair, update the secret + the published + pubkey; old downloads stay verifiable with the old pubkey. +- The SLSA predicate currently marks `reproducible: false` and does not attest + the full build environment/materials — it records the source repo + commit, + build parameters, and the artifact digest. Tightening to a hermetic, + reproducible attestation is a later step. diff --git a/scripts/build-custom-image.sh b/scripts/build-custom-image.sh index 3f1af3f..f28dbb5 100755 --- a/scripts/build-custom-image.sh +++ b/scripts/build-custom-image.sh @@ -174,6 +174,15 @@ else fi log "built artifacts in $OUT:"; ls -lh "$OUT" +# ── Sign + provenance (self-managed minisign key; graceful no-op without it) ─── +# Emits .minisig + in-toto statement + SLSA predicate (+ SBOM/OSImage +# for the ISO). No third-party signing authority is involved. +_selfdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="$OUT" TARGET="$TARGET" EDITION="$EDITION" ARCH="$ARCH" NAME="${NAME:-}" \ + HOSTNAME_="$HOSTNAME" BUILD_ID="${BUILD_ID:-}" BUILD_UID="${UID:-}" \ + STORE_PATHS="${CACHE_PATHS[*]:-}" GCS_PREFIX="$GCS_PREFIX" \ + bash "$_selfdir/sign-and-provenance.sh" || true + # ── Upload ─────────────────────────────────────────────────────────────────── if [[ -n "$GCS_PREFIX" ]]; then log "uploading to $GCS_PREFIX/ ..." @@ -186,6 +195,11 @@ if [[ -n "$GCS_PREFIX" ]]; then echo "$GCS_PREFIX/netboot-manifest.json" > "$OUT/artifact-url.txt" log "netboot base URL: $GCS_PREFIX/" fi + # Provenance sidecars (best-effort; only those that were produced). + shopt -s nullglob + prov_files=( "$OUT"/*.minisig "$OUT"/*.intoto.json "$OUT"/*.slsa.json "$OUT"/*.sbom.json "$OUT"/*.osimage.json ) + shopt -u nullglob + [[ "${#prov_files[@]}" -gt 0 ]] && gsutil cp "${prov_files[@]}" "$GCS_PREFIX/" && log "uploaded ${#prov_files[@]} provenance sidecar(s)" fi # ── Warm the Nix binary cache (best-effort; no-op without NIX_CACHE_* env) ───── diff --git a/scripts/gcp-build-custom-startup.sh b/scripts/gcp-build-custom-startup.sh index 4b7912e..0401b6c 100755 --- a/scripts/gcp-build-custom-startup.sh +++ b/scripts/gcp-build-custom-startup.sh @@ -25,6 +25,11 @@ SPEC="$(md sourceos-spec)" UID_="$(md sourceos-uid)" BUILD_ID="$(md sourceos-build-id)" PREFIX="$(md sourceos-gcs-prefix)" +# Optional: self-managed signing + cache (graceful if unset). +SIGN_KEY="$(md sourceos-sign-secret-key || true)" +CACHE_KEY="$(md sourceos-nix-cache-secret-key || true)" +CACHE_BUCKET="$(md sourceos-nix-cache-bucket || true)" +OS_VERSION="$(md sourceos-version || true)"; OS_VERSION="${OS_VERSION:-26.11}" status() { printf '%s' "$1" | gsutil cp - "$PREFIX/status.json" || true; } teardown() { gcloud --quiet compute instances delete "$NAME" --zone="$ZONE" || true; } @@ -48,6 +53,9 @@ cd /root/source-os printf '%s' "$SPEC" > /tmp/spec.json if SPEC_FILE=/tmp/spec.json OUT=/root/out GCS_PREFIX="$PREFIX" \ + BUILD_ID="$BUILD_ID" BUILD_UID="$UID_" VERSION="$OS_VERSION" \ + SOURCEOS_SIGN_SECRET_KEY="$SIGN_KEY" \ + NIX_CACHE_SECRET_KEY="$CACHE_KEY" NIX_CACHE_BUCKET="$CACHE_BUCKET" \ bash scripts/build-custom-image.sh; then ART="$(cat /root/out/artifact-url.txt 2>/dev/null || true)" status "$(printf '{"status":"complete","lane":"gcp","artifact":"%s"}' "$ART")" diff --git a/scripts/sign-and-provenance.sh b/scripts/sign-and-provenance.sh new file mode 100755 index 0000000..0da6642 --- /dev/null +++ b/scripts/sign-and-provenance.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# sign-and-provenance.sh — Self-sovereign signing + provenance for a built image. +# +# Produces, alongside a built artifact: +# .minisig ed25519 detached signature (minisign, self-managed key) +# .intoto.json in-toto Statement (the attestation; OSImage.statementRef) +# .slsa.json SLSA v1 provenance predicate (OSImage.slsaPredicateRef) +# .sbom.json Nix-closure SBOM (OSImage.sbomRef) [iso only] +# .osimage.json OSImage doc conforming to sourceos-spec v2 [iso only] +# +# NO third-party signing authority — no Apple Developer ID, no Google/Play +# signing, no notarization. The signing key is an ed25519 keypair you generate +# yourself with `minisign -W` (see docs/SIGNING_SETUP.md). Verification is +# anyone-can-check with the public key. +# +# Graceful by design: with no SOURCEOS_SIGN_SECRET_KEY the artifact is left +# unsigned but provenance is still emitted (signatureRef omitted). This script +# never fails the build — it always exits 0. +# +# Env: +# OUT output dir holding the artifact(s) (required) +# TARGET iso | netboot (default iso) +# EDITION desktop | server | edge (default desktop) +# ARCH x86_64 | aarch64 (default x86_64) +# NAME iso filename in $OUT (iso target) +# BUILD_ID, BUILD_UID build/user identifiers (for URNs) +# HOSTNAME_ composed hostname (informational) +# VERSION os version, e.g. 26.11 (default 0.0.0-dev) +# SOURCE_REV source commit (default $GITHUB_SHA or unknown) +# SOURCE_REPO source repo URL +# STORE_PATHS space-separated Nix store paths (for SBOM closure) +# GCS_PREFIX if set, *Ref fields that can be URLs point here +# SOURCEOS_SIGN_SECRET_KEY minisign secret key contents (unencrypted, -W) [secret] +# SOURCEOS_SIGN_PUBKEY minisign public key line (informational ref) +set -uo pipefail # NB: no -e; this script is best-effort and must not fail builds. + +OUT="${OUT:-out}" +TARGET="${TARGET:-iso}" +EDITION="${EDITION:-desktop}" +ARCH="${ARCH:-x86_64}" +VERSION="${VERSION:-0.0.0-dev}" +SOURCE_REV="${SOURCE_REV:-${GITHUB_SHA:-unknown}}" +SOURCE_REPO="${SOURCE_REPO:-https://github.com/SourceOS-Linux/source-os}" +SPEC_VERSION="2.1.0" +NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +log() { printf '[sign-prov] %s\n' "$*"; } + +command -v jq >/dev/null 2>&1 || { log "jq missing — skipping provenance"; exit 0; } + +# minisign may not be on PATH; fall back to `nix run` when nix is present. +minisign_run() { + if command -v minisign >/dev/null 2>&1; then minisign "$@" + elif command -v nix >/dev/null 2>&1; then nix run nixpkgs#minisign -- "$@" + else return 127; fi +} + +# Map edition → OSImage.hostProfile (substrate persona, NOT a cybernetic role). +case "$EDITION" in + desktop) HOST_PROFILE=workstation;; + server) HOST_PROFILE=vm-base;; + edge) HOST_PROFILE=edge-appliance;; + *) HOST_PROFILE=vm-base;; +esac + +# Lowercase, URN-safe local id: --. +_raw_id="${BUILD_ID:-${SOURCE_REV}}" +LOCAL_ID="$(printf '%s' "${EDITION}-${ARCH}-${_raw_id}" \ + | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._-' '-' | sed -E 's/-+/-/g; s/^-+//; s/-+$//')" +[[ -n "$LOCAL_ID" ]] || LOCAL_ID="build" + +# ── Per-artifact signing ─────────────────────────────────────────────────────── +# Signs a single file (best-effort). Echoes the .minisig path on success. +sign_file() { + local f="$1" + [[ -n "${SOURCEOS_SIGN_SECRET_KEY:-}" ]] || return 1 + local keyf; keyf="$(mktemp)" + printf '%s\n' "$SOURCEOS_SIGN_SECRET_KEY" > "$keyf" + # Unencrypted (-W) key → no password prompt; /dev/null 2>&1; then + rm -f "$keyf"; printf '%s' "$f.minisig"; return 0 + fi + rm -f "$keyf"; return 1 +} + +digest_of() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; } + +# Collect the subject files for this target. +SUBJECTS=() +if [[ "$TARGET" == "iso" ]]; then + [[ -n "${NAME:-}" && -f "$OUT/$NAME" ]] && SUBJECTS+=("$OUT/$NAME") +else + for f in kernel initrd; do [[ -f "$OUT/$f" ]] && SUBJECTS+=("$OUT/$f"); done +fi +[[ "${#SUBJECTS[@]}" -gt 0 ]] || { log "no artifacts to attest in $OUT — skipping"; exit 0; } + +# Sign each subject; build the in-toto subject[] array as we go. +SIGNED=0 +SUBJECT_JSON="$(jq -n '[]')" +for f in "${SUBJECTS[@]}"; do + d="$(digest_of "$f")" + base="$(basename "$f")" + if sig="$(sign_file "$f")"; then SIGNED=1; log "signed $base"; else log "unsigned $base (no key / minisign unavailable)"; fi + SUBJECT_JSON="$(jq --arg n "$base" --arg d "$d" '. + [{name:$n, digest:{sha256:$d}}]' <<<"$SUBJECT_JSON")" +done + +PRIMARY="${SUBJECTS[0]}" +PRIMARY_DIGEST="$(digest_of "$PRIMARY")" +PRIMARY_BASE="$(basename "$PRIMARY")" + +STATEMENT_URN="urn:srcos:attestation:${LOCAL_ID}" +SLSA_URN="urn:srcos:slsa:${LOCAL_ID}" + +# signatureRef / sbomRef: URLs when uploading, else relative filenames. +ref_for() { if [[ -n "${GCS_PREFIX:-}" ]]; then printf '%s/%s' "$GCS_PREFIX" "$1"; else printf '%s' "$1"; fi; } + +# ── SLSA v1 provenance predicate ─────────────────────────────────────────────── +SLSA_FILE="$OUT/${PRIMARY_BASE}.slsa.json" +jq -n \ + --arg repo "$SOURCE_REPO" --arg rev "$SOURCE_REV" --arg now "$NOW" \ + --arg edition "$EDITION" --arg arch "$ARCH" --arg target "$TARGET" \ + --arg hostname "${HOSTNAME_:-}" --arg builder "sourceos/build-custom-image" \ + '{ + buildType: "https://schemas.srcos.ai/buildtypes/nix-image/v1", + builder: { id: $builder }, + invocation: { + configSource: { uri: $repo, digest: { gitCommit: $rev } }, + parameters: { edition: $edition, arch: $arch, target: $target, hostname: $hostname } + }, + metadata: { + buildStartedOn: $now, + completeness: { parameters: true, environment: false, materials: false }, + reproducible: false + }, + materials: [ { uri: $repo, digest: { gitCommit: $rev } } ] + }' > "$SLSA_FILE" 2>/dev/null && log "wrote $(basename "$SLSA_FILE")" + +# ── in-toto attestation statement (wraps the SLSA predicate) ─────────────────── +STATEMENT_FILE="$OUT/${PRIMARY_BASE}.intoto.json" +jq -n \ + --argjson subject "$SUBJECT_JSON" \ + --slurpfile predicate "$SLSA_FILE" \ + --arg urn "$STATEMENT_URN" \ + '{ + "_type": "https://in-toto.io/Statement/v1", + "id": $urn, + "subject": $subject, + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": $predicate[0] + }' > "$STATEMENT_FILE" 2>/dev/null && log "wrote $(basename "$STATEMENT_FILE")" + +# ── SBOM (Nix closure of the build store paths) — iso only ───────────────────── +SBOM_REF="" +if [[ "$TARGET" == "iso" && -n "${STORE_PATHS:-}" ]] && command -v nix >/dev/null 2>&1; then + SBOM_FILE="$OUT/${PRIMARY_BASE}.sbom.json" + WORK_SBOM="$(mktemp)" + # shellcheck disable=SC2086 + if nix path-info --json -r $STORE_PATHS > "$WORK_SBOM" 2>/dev/null \ + && jq -n --slurpfile c "$WORK_SBOM" --arg now "$NOW" --arg name "$PRIMARY_BASE" \ + '{ bomFormat:"SourceOS-NixClosure", specVersion:"1.0", metadata:{timestamp:$now, component:{name:$name}}, + components: ($c[0] | (if type=="object" then (to_entries|map(.value)) else . end) + | map({ "store-path": (.path // .key // ""), narHash: (.narHash // ""), narSize: (.narSize // 0) })) }' \ + > "$SBOM_FILE" 2>/dev/null; then + SBOM_REF="$(ref_for "$(basename "$SBOM_FILE")")" + log "wrote $(basename "$SBOM_FILE")" + fi +fi + +# ── OSImage document (sourceos-spec v2) — iso target only ────────────────────── +# Netboot kernel/initrd are not a single bootable "image artifact" in the schema's +# artifact enum, so OSImage is emitted for the downloadable ISO only. +if [[ "$TARGET" == "iso" ]]; then + SIG_REF="" + [[ "$SIGNED" -eq 1 ]] && SIG_REF="$(ref_for "${PRIMARY_BASE}.minisig")" + OSIMAGE_FILE="$OUT/${PRIMARY_BASE}.osimage.json" + prov="$(jq -n --arg s "$STATEMENT_URN" --arg p "$SLSA_URN" --arg sb "$SBOM_REF" --arg sg "$SIG_REF" \ + '{statementRef:$s, slsaPredicateRef:$p} + + (if $sb != "" then {sbomRef:$sb} else {} end) + + (if $sg != "" then {signatureRef:$sg} else {} end)')" + jq -n \ + --arg id "urn:srcos:osimage:${LOCAL_ID}" \ + --arg specVersion "$SPEC_VERSION" \ + --arg shortId "so1-${HOST_PROFILE}" \ + --arg hostProfile "$HOST_PROFILE" \ + --arg arch "$ARCH" \ + --arg edition "$EDITION" \ + --arg version "$VERSION" \ + --arg rev "$SOURCE_REV" \ + --arg repo "$SOURCE_REPO" \ + --arg created "$NOW" \ + --argjson provenance "$prov" \ + '{ + id: $id, + type: "OSImage", + specVersion: $specVersion, + shortId: $shortId, + family: "sourceos", + epoch: 1, + hostProfile: $hostProfile, + artifact: "iso", + architecture: $arch, + osRelease: { + ID: "sourceos", + VERSION_ID: $version, + IMAGE_ID: ("sourceos-" + $edition), + IMAGE_VERSION: $version + }, + ociAnnotations: { + "org.opencontainers.image.version": $version, + "org.opencontainers.image.revision": $rev, + "org.opencontainers.image.source": $repo, + "org.opencontainers.image.created": $created, + "com.socioprophet.os.channel": "custom" + }, + substrateCapabilities: [ "content-addressed-store", "self-update", "nlboot-capable" ], + provenance: $provenance + }' > "$OSIMAGE_FILE" 2>/dev/null && log "wrote $(basename "$OSIMAGE_FILE")" +fi + +log "provenance complete (signed=$SIGNED) for $PRIMARY_BASE" +exit 0 diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh new file mode 100755 index 0000000..7a46e96 --- /dev/null +++ b/scripts/verify-image.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# verify-image.sh — Verify a downloaded SourceOS image against its signature +# and provenance. Self-sovereign: needs only the public key (no account, no +# third-party service). +# +# Usage: +# verify-image.sh [pubkey] +# the downloaded file, e.g. sourceos-desktop-x86_64-custom.iso +# (its .minisig must sit beside it) +# [pubkey] minisign public key line or file; defaults to $SOURCEOS_SIGN_PUBKEY +# +# Exit 0 only if the signature verifies. Also checks the in-toto subject digest +# if .intoto.json is present. +set -euo pipefail + +ART="${1:?usage: verify-image.sh [pubkey]}" +PUB="${2:-${SOURCEOS_SIGN_PUBKEY:-}}" +SIG="$ART.minisig" +[[ -f "$ART" ]] || { echo "no such file: $ART" >&2; exit 2; } +[[ -f "$SIG" ]] || { echo "no signature beside artifact: $SIG" >&2; exit 2; } +[[ -n "$PUB" ]] || { echo "no public key (pass as arg or set SOURCEOS_SIGN_PUBKEY)" >&2; exit 2; } + +minisign_run() { + if command -v minisign >/dev/null 2>&1; then minisign "$@" + elif command -v nix >/dev/null 2>&1; then nix run nixpkgs#minisign -- "$@" + else echo "minisign not found (install minisign, or have nix on PATH)" >&2; return 127; fi +} + +# minisign accepts either a public-key file (-p) or an inline key (-P). +if [[ -f "$PUB" ]]; then PUB_ARGS=(-p "$PUB"); else PUB_ARGS=(-P "$PUB"); fi + +echo "verifying signature of $(basename "$ART") ..." +minisign_run -V "${PUB_ARGS[@]}" -m "$ART" + +# Optional: confirm the in-toto attestation's subject digest matches the file. +INTOTO="$ART.intoto.json" +if [[ -f "$INTOTO" ]] && command -v jq >/dev/null 2>&1; then + want="$(jq -r --arg n "$(basename "$ART")" '.subject[] | select(.name==$n) | .digest.sha256' "$INTOTO")" + have="$(sha256sum "$ART" | awk '{print $1}')" + if [[ -n "$want" && "$want" == "$have" ]]; then + echo "attestation subject digest matches ✅ ($have)" + elif [[ -n "$want" ]]; then + echo "ATTESTATION DIGEST MISMATCH ❌ want=$want have=$have" >&2; exit 1 + fi +fi +echo "OK — $(basename "$ART") is authentic." From f941a014fa4c76a78e955969777323fe0e78679f Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:45:28 -0400 Subject: [PATCH 3/4] fix(tests): exit contract grep tolerates lib.mkForce on mesh role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing failure on main: 57c9cab added `lib.mkForce` to hosts/exit-x86_64/default.nix's `sourceos.mesh.role = "exit"` to resolve a NixOS assertion conflict, but the contract grep matched the literal `role = "exit"` and so broke. Make the grep tolerate the optional mkForce wrapper — it validates the role value, not the exact syntax. Was blocking nix flake check on every PR. --- tests/exit-x86_64-contract.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/exit-x86_64-contract.nix b/tests/exit-x86_64-contract.nix index fb4730a..77b4021 100644 --- a/tests/exit-x86_64-contract.nix +++ b/tests/exit-x86_64-contract.nix @@ -6,7 +6,7 @@ pkgs.runCommand "exit-x86_64-contract" { grep -q '../../profiles/linux-stable/default.nix' ${../hosts/exit-x86_64/default.nix} grep -q 'networking.hostName = "exit-x86_64"' ${../hosts/exit-x86_64/default.nix} grep -q 'sourceos.mesh = {' ${../hosts/exit-x86_64/default.nix} - grep -q 'role = "exit"' ${../hosts/exit-x86_64/default.nix} + grep -qE 'role = (lib\.mkForce )?"exit"' ${../hosts/exit-x86_64/default.nix} grep -q 'exitdPackage = self.packages' ${../hosts/exit-x86_64/default.nix} mkdir -p $out echo validated > $out/result.txt From 4f66baee3407b342d36741792c7df7a7898d56e1 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:49:20 -0400 Subject: [PATCH 4/4] fix(ci): silence SC2034 on intentional diagnostic probes Second pre-existing nix-ci failure, previously masked by the exit-contract failure: `shellcheck -S warning` flagged LIMA_KEY/IS_INTERNAL (deploy-stage2.sh) and EFI_SIZE (install-on-device.sh) as unused. These are intentional diagnostic probes in destructive disk scripts; annotate with shellcheck disable=SC2034 rather than touch the control flow. Verified clean with shellcheck 0.11.0. --- scripts/deploy-stage2.sh | 2 ++ scripts/install-on-device.sh | 1 + 2 files changed, 3 insertions(+) diff --git a/scripts/deploy-stage2.sh b/scripts/deploy-stage2.sh index 9ba9405..9aba96c 100644 --- a/scripts/deploy-stage2.sh +++ b/scripts/deploy-stage2.sh @@ -92,6 +92,7 @@ if [[ $DRY_RUN -eq 0 ]]; then print(vm['sshLocalPort'] if vm else '')") [[ -n "${LIMA_PORT}" ]] || die "Could not determine lima SSH port" + # shellcheck disable=SC2034 # diagnostic capture, kept intentionally LIMA_KEY="$(limactl shell "${LIMA_VM}" -- \ bash -c 'cat /proc/1/environ 2>/dev/null | tr \\0 \\n | grep SSH_AUTH' 2>/dev/null || true)" @@ -136,6 +137,7 @@ diskutil info "${USB_DEV}" >/dev/null 2>&1 || die "Device ${USB_DEV} not found" DISK_NAME=$(diskutil info "${USB_DEV}" | grep "Device / Media Name" | awk -F': ' '{print $2}' | xargs) DISK_SIZE=$(diskutil info "${USB_DEV}" | grep "Disk Size" | awk -F': ' '{print $2}' | awk '{print $1,$2}') +# shellcheck disable=SC2034 # informational probe, kept intentionally IS_INTERNAL=$(diskutil info "${USB_DEV}" | grep "Solid State" | head -1) echo diff --git a/scripts/install-on-device.sh b/scripts/install-on-device.sh index 2ad3c5f..94b0ee9 100644 --- a/scripts/install-on-device.sh +++ b/scripts/install-on-device.sh @@ -114,6 +114,7 @@ fi if [[ "${FORMAT}" == "yes" ]]; then # Detect whether EFI partition needs formatting (0-byte size = no filesystem). + # shellcheck disable=SC2034 # probe kept for diagnostics; format decision uses EFI_FS below EFI_SIZE=$(lsblk -bno SIZE "${EFI_DEV}" 2>/dev/null || blockdev --getsize64 "${EFI_DEV}" 2>/dev/null || echo "0") EFI_FS=$(blkid -s TYPE -o value "${EFI_DEV}" 2>/dev/null || true) FORMAT_EFI="no"