diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 6e64457..99da23f 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -10,6 +10,8 @@ on: - 'assets/**' - 'build/**' - '.github/workflows/build-iso.yml' + tags: + - 'v*.*.*' workflow_dispatch: inputs: releasever: @@ -24,6 +26,8 @@ jobs: name: Build live ISO runs-on: ubuntu-24.04 timeout-minutes: 90 + permissions: + contents: write steps: - name: Checkout @@ -52,7 +56,9 @@ jobs: # core problem). CI runners always start fresh, no version skew. dnf -y upgrade --refresh - # Install build tooling + # Install build tooling. sbsigntools provides sbsign for EFI + # binary signing; pesign is the alternate path. xorriso is + # required for ISO repack after EFI signing. dnf -y install \ lorax \ livecd-tools \ @@ -64,7 +70,11 @@ jobs: createrepo_c \ git \ which \ - shadow-utils + shadow-utils \ + sbsigntools \ + openssl \ + dosfstools \ + mtools cd /work @@ -105,8 +115,16 @@ jobs: # Move output ISO to expected dir mv ./veilor-os-43.iso build/out/ 2>/dev/null || mv ./*.iso build/out/ 2>/dev/null || true - # Rename + checksum - ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-$(date +%Y%m%d-%H%M%S).iso" + # Rename. For tag builds use the tag as the version stamp so + # release artifacts have a stable, predictable name. For + # branch / dispatch builds use a timestamp. + REF_NAME="${{ github.ref_name }}" + if [[ "${{ github.ref_type }}" == "tag" ]]; then + VERSION_TAG="$REF_NAME" + else + VERSION_TAG="$(date +%Y%m%d-%H%M%S)" + fi + ISO_NAME="veilor-os-${{ github.event.inputs.releasever || '43' }}-${VERSION_TAG}.iso" cd build/out for f in *.iso; do [[ -f $f && $f != $ISO_NAME ]] && mv "$f" "$ISO_NAME" @@ -114,6 +132,95 @@ jobs: sha256sum "$ISO_NAME" > "$ISO_NAME.sha256" ls -lh "$ISO_NAME" + # Stash final ISO name for later steps that run *outside* the + # container (signing, splitting, release). The runner sees + # /work/build/out, host sees ${{ github.workspace }}/build/out. + echo "$ISO_NAME" > /work/build/out/.iso-name + + - name: Resolve ISO filename + id: iso + run: | + ISO_NAME=$(cat build/out/.iso-name) + echo "name=$ISO_NAME" >> "$GITHUB_OUTPUT" + echo "path=build/out/$ISO_NAME" >> "$GITHUB_OUTPUT" + echo "[INFO] ISO resolved: $ISO_NAME" + + # ─── EFI signing (Secure Boot) ────────────────────────────────────── + # Only runs if MOK_PRIVATE_KEY + MOK_CERT secrets are present. + # Repacks the ISO in-place with sbsign'd BOOTX64.EFI / grubx64.efi. + # Without the secrets, ISO is shipped unsigned (testing builds). + - name: Sign EFI binaries (if MOK secrets present) + id: sbsign + env: + MOK_PRIVATE_KEY: ${{ secrets.MOK_PRIVATE_KEY }} + MOK_CERT: ${{ secrets.MOK_CERT }} + run: | + set -euo pipefail + if [[ -z "${MOK_PRIVATE_KEY:-}" || -z "${MOK_CERT:-}" ]]; then + echo "::warning::[INFO] MOK secrets absent — ISO unsigned for testing" + echo "signed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + sudo apt-get update + sudo apt-get install -y sbsigntool xorriso mtools dosfstools + + # Materialise key + cert from secrets to a tmp dir wiped on exit. + KEYDIR=$(mktemp -d) + trap 'rm -rf "$KEYDIR"' EXIT + printf '%s' "$MOK_PRIVATE_KEY" > "$KEYDIR/MOK.priv" + printf '%s' "$MOK_CERT" | base64 -d > "$KEYDIR/MOK.der" 2>/dev/null \ + || printf '%s' "$MOK_CERT" > "$KEYDIR/MOK.der" + # sbsign prefers PEM cert input — derive on the fly. + openssl x509 -inform DER -in "$KEYDIR/MOK.der" -outform PEM -out "$KEYDIR/MOK.pem" \ + || cp "$KEYDIR/MOK.der" "$KEYDIR/MOK.pem" + chmod 600 "$KEYDIR/MOK.priv" + + ISO_PATH="${{ steps.iso.outputs.path }}" + WORKDIR=$(mktemp -d) + trap 'rm -rf "$KEYDIR" "$WORKDIR"' EXIT + + # Extract EFI ESP image from ISO. xorriso prints embedded image + # locations; we pull the EFI/BOOT directory contents into a + # writable scratch dir, sign each .efi, and graft back in. + mkdir -p "$WORKDIR/iso-extract" + xorriso -osirrox on -indev "$ISO_PATH" \ + -extract /EFI "$WORKDIR/iso-extract/EFI" 2>&1 | tail -20 + + SIGNED_ANY=0 + for efi in $(find "$WORKDIR/iso-extract/EFI" -type f -iname '*.efi' 2>/dev/null); do + echo "[*] Signing $efi" + sbsign \ + --key "$KEYDIR/MOK.priv" \ + --cert "$KEYDIR/MOK.pem" \ + --output "${efi}.signed" \ + "$efi" + mv "${efi}.signed" "$efi" + SIGNED_ANY=1 + done + + if [[ $SIGNED_ANY -eq 0 ]]; then + echo "::warning::No EFI binaries found in ISO — skipping repack" + echo "signed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Repack: graft the signed EFI tree back into the ISO. Use + # xorriso's update mode so other ISO contents (squashfs, isolinux) + # remain untouched. + xorriso \ + -indev "$ISO_PATH" \ + -outdev "${ISO_PATH}.signed" \ + -boot_image any keep \ + -map "$WORKDIR/iso-extract/EFI" /EFI \ + -commit + mv "${ISO_PATH}.signed" "$ISO_PATH" + + # Refresh sha256 — content changed. + (cd build/out && sha256sum "${{ steps.iso.outputs.name }}" > "${{ steps.iso.outputs.name }}.sha256") + echo "[OK] EFI binaries signed; ISO repacked + checksum refreshed." + echo "signed=true" >> "$GITHUB_OUTPUT" + - name: Upload ISO artifact if: success() uses: actions/upload-artifact@v4 @@ -133,7 +240,102 @@ jobs: build/out/build.log build/out/build/anaconda/ - - name: Attach to release + # ─── Release artefact pipeline ────────────────────────────────────── + # Triggered on tag push (v*.*.*). The 2.86 GB ISO exceeds GitHub's + # 2 GB single-asset cap, so we split into 1.9 GB parts and let users + # reassemble with `cat`. Checksum is GPG-signed so users can verify + # the assembled ISO end-to-end. + - name: Split ISO for release + if: github.event_name == 'push' && github.ref_type == 'tag' + id: split + run: | + set -euo pipefail + cd build/out + ISO="${{ steps.iso.outputs.name }}" + # 1900M keeps each part safely below GitHub's 2 GB asset limit + # (limit is 2 GiB = 2147483648 bytes; 1900 MiB = 1992294400). + split -b 1900M -d -a 2 "$ISO" "${ISO}.part-" + # Rename suffix to letter form so it matches the documented + # convention .part-aa, .part-ab. -d uses 00/01; we want aa/ab + # to make the cat glob unambiguous (no leading zero confusion). + mv "${ISO}.part-00" "${ISO}.part-aa" + mv "${ISO}.part-01" "${ISO}.part-ab" 2>/dev/null || true + # In rare 3-part case (>3.8 GB ISO), handle .part-02 → .part-ac + [[ -f "${ISO}.part-02" ]] && mv "${ISO}.part-02" "${ISO}.part-ac" || true + ls -lh "${ISO}".part-* + echo "iso=$ISO" >> "$GITHUB_OUTPUT" + + - name: GPG-sign sha256 checksum + if: github.event_name == 'push' && github.ref_type == 'tag' + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + if [[ -z "${GPG_PRIVATE_KEY:-}" ]]; then + echo "::warning::GPG_PRIVATE_KEY secret missing — checksum will not be signed" + exit 0 + fi + GNUPGHOME=$(mktemp -d) + export GNUPGHOME + chmod 700 "$GNUPGHOME" + # Trust the imported key automatically — CI is non-interactive. + printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import + KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ {print $5; exit}') + echo "[*] Signing with key $KEYID" + cd build/out + ISO="${{ steps.iso.outputs.name }}" + gpg --batch --yes --pinentry-mode loopback \ + --local-user "$KEYID" \ + --armor --detach-sign \ + --output "${ISO}.sha256.asc" \ + "${ISO}.sha256" + ls -lh "${ISO}.sha256" "${ISO}.sha256.asc" + + - name: Create release and attach assets + if: github.event_name == 'push' && github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: veilor-os ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + fail_on_unmatched_files: false + files: | + build/out/*.iso.part-* + build/out/*.iso.sha256 + build/out/*.iso.sha256.asc + body: | + ## veilor-os ${{ github.ref_name }} + + ISO is split into 1.9 GB parts to fit within GitHub's 2 GB + per-asset upload cap. Reassemble after download: + + ```bash + cat ${{ steps.iso.outputs.name }}.part-* > ${{ steps.iso.outputs.name }} + sha256sum -c ${{ steps.iso.outputs.name }}.sha256 + ``` + + ### Verifying the checksum signature + + ```bash + # Import the veilor-os release signing key (one-time): + gpg --keyserver keys.openpgp.org --recv-keys + + # Verify: + gpg --verify ${{ steps.iso.outputs.name }}.sha256.asc \ + ${{ steps.iso.outputs.name }}.sha256 + ``` + + ### Secure Boot + + EFI binaries are signed with the veilor-os MOK if signing + secrets were configured at build time. To enroll the public + cert post-install, see `scripts/gen-mok-key.sh` header. + + # Legacy hook: keeps the manual `release.published` workflow path + # working. Tag-driven flow above is the new canonical entry point. + - name: Attach to release (legacy release event) if: github.event_name == 'release' uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/release-checksums.yml b/.github/workflows/release-checksums.yml new file mode 100644 index 0000000..89ba776 --- /dev/null +++ b/.github/workflows/release-checksums.yml @@ -0,0 +1,100 @@ +name: Release Checksums + +# PR-time validation gate for release-affecting files. Independent of +# lint.yml — meant to harden the brittle parts (ksvalidator on the +# generated CI kickstart, shellcheck across all maintained scripts, +# YAML sanity on every workflow). +# +# This workflow does NOT replace lint.yml; it runs alongside. + +on: + pull_request: + paths: + - 'kickstart/**' + - 'scripts/**' + - '.github/workflows/**' + push: + branches: [main] + paths: + - 'kickstart/**' + - 'scripts/**' + - '.github/workflows/**' + +jobs: + ksvalidate: + name: ksvalidator (CI-flavour kickstart) + runs-on: ubuntu-24.04 + container: + image: registry.fedoraproject.org/fedora:43 + steps: + - uses: actions/checkout@v4 + + - name: Install pykickstart + run: dnf -y install pykickstart sed + + - name: Generate CI kickstart and validate + run: | + set -euxo pipefail + # Mirror what build-iso.yml does so we're validating the file + # the actual builder consumes, not just the source kickstart. + sed -e '/veilor-fix/d' \ + -e '/^shutdown$/d' \ + kickstart/veilor-os.ks > kickstart/veilor-os-ci.ks + ksvalidator kickstart/veilor-os.ks + ksvalidator kickstart/veilor-os-ci.ks + + shellcheck: + name: shellcheck (release scripts) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: shellcheck repo scripts + uses: ludeeus/action-shellcheck@master + with: + severity: warning + # Same exclusions as lint.yml so behaviour is consistent. + ignore_paths: build/cache .github + + workflow-yaml: + name: workflow YAML sanity + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Validate every workflow parses as YAML + run: | + set -euo pipefail + python3 - <<'PY' + import sys, pathlib, yaml + ok = True + for p in pathlib.Path(".github/workflows").glob("*.y*ml"): + try: + yaml.safe_load(p.read_text()) + print(f"[OK] {p}") + except yaml.YAMLError as e: + print(f"[ERR] {p}: {e}", file=sys.stderr) + ok = False + sys.exit(0 if ok else 1) + PY + + release-asset-budget: + name: Release asset size budget + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Confirm split threshold is below GitHub's 2 GiB asset cap + run: | + set -euo pipefail + # GitHub per-asset upload limit is 2 GiB = 2147483648 bytes. + # split is invoked with -b 1900M = 1900 * 2^20 = 1992294400 bytes. + # Hard-fail if anyone bumps the split size beyond the cap. + if grep -E 'split -b [0-9]+M' .github/workflows/build-iso.yml >/dev/null; then + SIZE_M=$(grep -oE 'split -b [0-9]+M' .github/workflows/build-iso.yml | head -1 | grep -oE '[0-9]+') + if [[ "$SIZE_M" -gt 2047 ]]; then + echo "::error::split -b ${SIZE_M}M exceeds GitHub's 2 GiB per-asset cap" + exit 1 + fi + echo "[OK] split size ${SIZE_M}M is under the 2 GiB asset limit." + else + echo "::warning::No split -b NM directive found — release pipeline may have changed" + fi diff --git a/.gitignore b/.gitignore index 6679260..2853d6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/out/ build/cache/ +build/keys/ *.iso *.img *.log @@ -11,5 +12,7 @@ build/cache/ secrets/ *.key *.pem +*.priv +*.der test/veilor-vm.qcow2 test/veilor-vm.nvram* diff --git a/scripts/gen-mok-key.sh b/scripts/gen-mok-key.sh new file mode 100755 index 0000000..b338bfc --- /dev/null +++ b/scripts/gen-mok-key.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# gen-mok-key.sh — Generate a Machine Owner Key (MOK) pair for Secure Boot +# +# Purpose: +# Produces an RSA-4096 key pair used to sign EFI binaries (BOOTX64.EFI, +# shim, grub) and out-of-tree kernel modules so they pass UEFI Secure Boot +# verification once the public cert is enrolled in the firmware. +# +# Output (gitignored): +# build/keys/MOK.priv — PEM private key (sbsign / sign-file input) +# build/keys/MOK.der — DER public certificate (mokutil enrollment input) +# build/keys/MOK.pem — PEM public certificate (sbsign --cert input) +# +# Idempotent: if MOK.priv already exists, exits 0 without regenerating. +# Re-running with existing keys is safe — won't clobber a key already used +# to sign released ISOs. +# +# ─── User enrollment workflow (post-install) ───────────────────────────── +# 1. Copy build/keys/MOK.der to the installed system (USB / scp / etc.) +# 2. On the booted veilor-os system, as root: +# mokutil --import /path/to/MOK.der +# Set a one-time password when prompted. +# 3. Reboot. The shim's MokManager will appear on next boot — choose +# "Enroll MOK", confirm with the password from step 2, then continue +# boot. The cert is now in the kernel's .platform keyring. +# 4. Verify enrollment: +# mokutil --list-enrolled | grep -A2 'veilor' +# +# ─── Uploading to GitHub Actions secrets ───────────────────────────────── +# After running this script, populate the CI signing secrets with: +# gh secret set MOK_PRIVATE_KEY < build/keys/MOK.priv +# gh secret set MOK_CERT < build/keys/MOK.der +# +# Keep build/keys/ off-disk-backup-medium-of-record offline. Anyone with +# MOK.priv can sign code that boots on enrolled machines. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +KEY_DIR="$REPO_ROOT/build/keys" + +PRIV_KEY="$KEY_DIR/MOK.priv" +DER_CERT="$KEY_DIR/MOK.der" +PEM_CERT="$KEY_DIR/MOK.pem" + +# Subject for the X.509 cert. Tweak CN if you fork the project. +SUBJ="/CN=veilor-os Machine Owner Key/O=veilor-org/OU=secure-boot/" + +# Cert validity — 10 years. Long enough that we don't churn re-enrollment; +# short enough that a leaked key has a hard expiry. +DAYS=3650 + +if [[ -f "$PRIV_KEY" ]]; then + echo "[INFO] $PRIV_KEY already exists — skipping (idempotent)." + echo "[INFO] To regenerate, delete $KEY_DIR/ first." + exit 0 +fi + +mkdir -p "$KEY_DIR" +chmod 700 "$KEY_DIR" + +echo "[*] Generating RSA-4096 MOK keypair → $KEY_DIR/" + +# Single openssl invocation produces PEM private key + DER public cert. +# -nodes = no passphrase on the key (CI must use it non-interactively). +# Protect the resulting MOK.priv with filesystem perms only. +openssl req \ + -new \ + -x509 \ + -newkey rsa:4096 \ + -nodes \ + -sha256 \ + -days "$DAYS" \ + -subj "$SUBJ" \ + -keyout "$PRIV_KEY" \ + -outform DER \ + -out "$DER_CERT" + +# Also emit a PEM-encoded copy of the cert — sbsign accepts PEM more +# reliably than DER in some distros' build of the tool. +openssl x509 -inform DER -in "$DER_CERT" -outform PEM -out "$PEM_CERT" + +chmod 600 "$PRIV_KEY" +chmod 644 "$DER_CERT" "$PEM_CERT" + +echo "[OK] MOK keypair written:" +echo " private : $PRIV_KEY (mode 600)" +echo " cert DER: $DER_CERT (enroll via mokutil --import)" +echo " cert PEM: $PEM_CERT (sbsign --cert input)" +echo "" +echo "[next] Upload to CI:" +echo " gh secret set MOK_PRIVATE_KEY < $PRIV_KEY" +echo " gh secret set MOK_CERT < $DER_CERT"