From ec9241731220df8642133b82e80b28dfa9af3cff Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Sat, 20 Jun 2026 00:40:59 +0000 Subject: [PATCH] Initial Secure boot setup --- .gitea/workflows/gitea-main-ci.yaml | 47 ++++++-- diskless.pkr.hcl | 14 +++ docs/TODO.md | 57 ++++++++++ docs/secure-boot-keys.md | 170 ++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 docs/secure-boot-keys.md diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml index f521a62..6baa6b4 100644 --- a/.gitea/workflows/gitea-main-ci.yaml +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -8,16 +8,19 @@ # shell-local post-processors extract artifacts, build the UKI (ukify), and # build+push the OCI release image. # -# This is an UNSIGNED (lab) build: sb_key/sb_cert are intentionally NOT passed, -# so build-oci.sh emits its loud unsigned-UKI warning. A production fleet must -# sign in CI/HSM -- see the "TODO: wire SB_KEY/SB_CERT" step below. +# Signed when the SB_KEY/SB_CERT secrets are set; otherwise an UNSIGNED (lab) +# build where build-oci.sh emits its loud unsigned-UKI warning. Signing runs +# shell-local on the runner (not in the build VM); the key is staged to +# RUNNER_TEMP and shredded after. See docs/secure-boot-keys.md. # # Required Gitea repo secrets: # REGISTRY_USERNAME - registry user for `oci_image` (oci_push=true) # REGISTRY_TOKEN - registry password/token # SSH_AUTHORIZED_KEY - public key for the sudo admin user; empty => no admin # user + root left unlocked (lab). Set it to lock root. -# # SB_KEY, SB_CERT - (future) Secure Boot signing key + cert for signed UKIs +# SB_KEY, SB_CERT - Secure Boot signing: the `db` PRIVATE key (PEM) + its +# cert (PEM). Empty => unsigned lab build. PK/KEK are NOT +# here (offline/HSM). Generate per docs/secure-boot-keys.md. # # Runner: needs KVM (qemu accelerator=kvm) and the build-host tooling installed # below (libguestfs, erofs-utils, cryptsetup, jq, ukify, buildah). @@ -176,11 +179,32 @@ jobs: --password "${{ secrets.PACKAGE_TOKEN }}" \ git.bitasylum.org + - + name: Materialize Secure Boot signing material + # sb_key/sb_cert are consumed by ukify as FILE PATHS (not values), and the + # signing post-processor runs shell-local on THIS runner (not in the build + # VM), so write the secrets to runner-temp files and pass their paths. The + # `db` PRIVATE key is the only signing key here; PK/KEK stay offline (see + # docs/secure-boot-keys.md). If SB_KEY is unset, this no-ops and the build + # falls back to an UNSIGNED lab UKI (the loud warning stays). + if: ${{ secrets.SB_KEY != '' }} + env: + SB_KEY: ${{ secrets.SB_KEY }} # db private key (PEM) — secret + SB_CERT: ${{ secrets.SB_CERT }} # db cert (PEM) — public, kept as a secret for convenience + run: | + umask 077 + d="$RUNNER_TEMP/sb"; mkdir -p "$d" + printf '%s\n' "$SB_KEY" > "$d/db.key" + printf '%s\n' "$SB_CERT" > "$d/db.crt" + echo "SB_KEY_FILE=$d/db.key" >> "$GITHUB_ENV" + echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV" + echo "Secure Boot signing material staged (signed UKI build)." + - name: packer build (UKI + OCI release image) - # UNSIGNED lab build: sb_key/sb_cert deliberately omitted. - # TODO: wire SB_KEY/SB_CERT secrets for a signed UKI: - # -var sb_key=${{ secrets.SB_KEY }} -var sb_cert=${{ secrets.SB_CERT }} + # Signed when SB_KEY/SB_CERT secrets are set (paths exported above); + # UNSIGNED lab build (loud warning) when they're empty. sb_key/sb_cert are + # PATHS, never values — see the materialize step and docs/secure-boot-keys.md. run: | packer build -color=false -on-error=abort \ -var image_version="${{ steps.vars.outputs.image_version }}" \ @@ -190,11 +214,20 @@ jobs: -var disable_dot1x=${{ env.DISABLE_DOT1X }} \ -var nvmet_traddr="${{ env.NVMET_TRADDR }}" \ -var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \ + -var sb_key="${SB_KEY_FILE:-}" \ + -var sb_cert="${SB_CERT_FILE:-}" \ -var kiosk_url="${{ env.KIOSK_URL }}" \ -var boot_splash=${{ env.BOOT_SPLASH }} \ -var enable_stig=${{ env.ENABLE_STIG }} \ . + - + name: Shred Secure Boot signing key + # Remove the private key from the runner FS even if the build failed. + # (RUNNER_TEMP is also wiped per-job on ephemeral runners — belt + braces.) + if: always() + run: shred -u "$RUNNER_TEMP/sb/db.key" 2>/dev/null || rm -f "$RUNNER_TEMP/sb/db.key" 2>/dev/null || true + - name: STIG deviations report (report-only) # The build downloaded the post-remediation scan (stig-arf.xml + diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index b2e850f..376d223 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -253,6 +253,20 @@ build { # dracut warns and silently falls back to gzip (a larger initramfs). "dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network NetworkManager zstd openssl", + # EFI / Secure Boot management tools, for MANUAL key enrollment over SSH on + # a workstation in Setup Mode: efitools (efi-updatevar, efi-readvar, + # cert-to-efi-sig-list, sign-efi-sig-list, KeyTool) + efivar/mokutil/ + # efibootmgr companions. See docs/secure-boot-keys.md ("Manual enrollment"); + # auto-enroll is TODO #10. They write to efivarfs + # (/sys/firmware/efi/efivars), NOT the read-only root, so they work at + # runtime; RPM-installed => already fapolicyd-trusted (no STIG trust edit). + # efitools is EPEL-only on el9: enable EPEL just to pull it, then drop the + # repo so a non-kiosk image's repo set is unchanged (kiosk builds re-add it). + "dnf -y install efivar mokutil efibootmgr", + "rpm -q efitools >/dev/null 2>&1 || { dnf -y install epel-release && dnf -y install efitools && EFITOOLS_EPEL=1; } || true", + "rpm -q efitools >/dev/null 2>&1 && echo '==> EFI Secure Boot tools installed (efitools + efivar/mokutil/efibootmgr)' || echo 'WARNING: efitools not installed (EPEL unavailable?) — manual SB enrollment tooling missing' >&2", + "[ \"$${EFITOOLS_EPEL:-0}\" = 1 ] && dnf -y remove epel-release >/dev/null 2>&1 || true", + # Plymouth boot splash. The custom scripted 'fleet' theme renders the # security-handshake checklist driven by the dracut hooks # (`plymouth update --status=...`). plymouth-plugin-label gives Image.Text diff --git a/docs/TODO.md b/docs/TODO.md index 2d8626f..eb8eff4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -624,6 +624,8 @@ smoke test. by the serving dnsmasq — drop the hardcoded `192.168.50.10`. - **Signing:** wire `sb_key`/`sb_cert` (CI/HSM secrets) for a signed UKI; enable Secure Boot on targets. Until then the loud unsigned warning must stay. + **Key generation + enrolment procedure: [secure-boot-keys.md](secure-boot-keys.md)** + (the PK/KEK/db trio; `db` = `sb_key`/`sb_cert`, PK/KEK stay offline). - **Bench-verify on the secure path** (swtpm + hostapd + FreeRADIUS, `disable_dot1x=false`) — the items deferred this session: - NM port secure path: `en*` UNMANAGED + `macsec0` MANAGED/renewing, MKA @@ -687,3 +689,58 @@ Tips the balance toward "do it" only if: we start managing the **serving hosts** roles to amortise — currently they're docker-compose), provisioning grows much more branchy, or we want molecule-tested hardening roles validated without a full ~17-min Packer build. + +## [ ] 10. (OPTIONAL) Secure Boot cert auto-enrollment for provisioning + +Speed up bringing up new workstations: bake the **public** PK/KEK/db certs into an +image, detect Secure Boot **Setup Mode** at boot, and auto-enroll them with +efitools (`efi-updatevar`/`KeyTool`) so a fresh/reset box adopts the fleet trust +root without a manual firmware-setup visit. Pairs with #8 (signing) + +[secure-boot-keys.md](secure-boot-keys.md). + +**What's safe:** the certs are public (no key material — only the **db** private +key signs, and it stays in CI/HSM, invariant #7); Setup Mode accepts +*unauthenticated* writes to PK/KEK/db, so public certs (ESL) are sufficient. It's +fleet-generic (no per-host state, invariant #1) and deterministic (reproducible +verity hash). + +**The real risk — provisioning-time TOFU (trust-on-first-use):** in Setup Mode +Secure Boot isn't enforcing, so the enroller runs as *unverified* code, and the +fleet's UEFI **HTTP boot is unauthenticated** in that state. An on-path attacker +during provisioning can serve a malicious image that enrolls **their** PK/KEK/db — +the box then "has Secure Boot" but trusts the attacker. Auto-enroll is only as +trustworthy as the provisioning channel, and it removes the physical-presence +approval gate. So treat it as a controlled-environment provisioning step, not an +always-on behaviour. + +**Optional separate provisioning UKI (preferred shape):** build a dedicated +**provisioning UKI** that carries the certs + the enroll logic, instead of arming +the enroller in the production image. Keeps the standing footgun out of the normal +image (a factory reset + tampered image can't silently re-root trust), and lets +the enroller be a one-shot, attended/isolated step. The production UKI never +enrolls. + - Build it as a separate Packer target / flag (e.g. `provisioning=true`) → + its own small UKI; signed or unsigned (it runs in Setup Mode anyway, but sign + it so it *also* boots once keys are enrolled / for re-runs). + - Or a tiny standalone EFI tool (KeyTool-style) served alongside, not a full OS. + +**Mitigations (do these regardless of UKI vs separate UKI):** + - Run provisioning on a **trusted, isolated VLAN** (or a physically controlled + bench) — the integrity Setup Mode can't give must come from the environment. + - **Gate** enrollment behind an explicit provisioning mode / one-shot token; + never arm it on every boot of the production image. + - **Read-back verify before declaring success:** `efi-readvar` the enrolled + PK/KEK/db and compare fingerprints to the values baked in; mismatch = fail + loudly (turns silent TOFU into tamper-evidence). + - **Audit** the enrollment event + read-back fingerprints to the collector. + - **Keep the Microsoft UEFI CA in `db`** (or enroll your db alongside it) unless + every NIC/GPU option ROM is covered by your keys — clearing MS db can break + signed **option ROMs** (NIC PXE/HTTP-boot, GPU init), bricking the boot path. + - **PCR 7 check:** enrollment changes the Secure Boot measurement in PCR 7; + confirm nothing the fleet seals to the TPM (the EAP-TLS key at `0x81010001`, + NV cert) is PCR-7-policy-bound before relying on this. + +**Decision before building:** auto-enroll trades assurance for convenience and is +reasonable for a controlled fleet; the highest-assurance path is attended/firmware +or manufacturer pre-provisioning. Default recommendation: **separate provisioning +UKI + isolated VLAN + read-back verify.** diff --git a/docs/secure-boot-keys.md b/docs/secure-boot-keys.md new file mode 100644 index 0000000..1a739ea --- /dev/null +++ b/docs/secure-boot-keys.md @@ -0,0 +1,170 @@ +# Secure Boot key generation & enrollment — diskless fleet + +The fleet boots a **signed UKI directly via systemd-stub** (no shim, no GRUB), so +UEFI Secure Boot verifies the UKI against keys **you** enrol in firmware. This +doc generates the three Secure Boot keys (the "trio": **PK, KEK, db**), enrols +them, and wires the `db` key into the build. + +> **Invariant #7:** signing keys never enter the build VM or this repo. The +> commands below produce key material that lives **offline / in an HSM**. Only the +> **db** key signs UKIs routinely, and the build receives it as a *path* +> (`sb_key`/`sb_cert`) on the CI/HSM host — never committed, never echoed in logs. +> Treat every `*.key` file here as secret; never check one into git. + +## The trio — roles and where each lives + +| Key | Role | Used by | Where it lives | +|---|---|---|---| +| **PK** (Platform Key) | Root of trust; controls who may update the KEK. Setting it locks Secure Boot into User Mode. | Firmware enrolment only | **Offline / HSM**, used once (and for KEK rotation) | +| **KEK** (Key Exchange Key) | Signs updates to `db`/`dbx`. | Firmware enrolment only | **Offline / HSM** | +| **db** (Signature Database) | Signs the bootable UKI. | **The fleet build** — `sb_key`/`sb_cert` ([../scripts/build-oci.sh](../scripts/build-oci.sh) → ukify/sbsign) | HSM/CI signing host | + +Trust chain at enrolment: **PK signs KEK → KEK signs db → db signs the UKI.** + +Only `db` is online (it signs every release; the UKI cmdline is immutable once +signed — invariant #6 — so re-signing is part of each build). PK and KEK are only +needed to rotate keys later, so they stay offline. + +## Tooling + +```bash +# Fedora / RHEL: dnf install openssl efitools sbsigntools +# Debian / Ubuntu: apt install openssl efitools sbsigntool +``` + +## 1. Generate the three key/cert pairs + +```bash +GUID=$(uuidgen); echo "$GUID" > GUID.txt # owner GUID for the EFI variables + +# Platform Key +openssl req -newkey rsa:2048 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 \ + -subj "/CN=Fleet Platform Key/" -out PK.crt +# Key Exchange Key +openssl req -newkey rsa:2048 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 \ + -subj "/CN=Fleet KEK/" -out KEK.crt +# Signature DB key <-- this pair becomes sb_key / sb_cert +openssl req -newkey rsa:2048 -nodes -keyout db.key -new -x509 -sha256 -days 3650 \ + -subj "/CN=Fleet db/" -out db.crt +``` + +`rsa:2048` is the maximally firmware-compatible size for the enrolled variables; +`4096` works on most modern UEFI but some implementations reject it for +PK/KEK/db. The PEM `db.key`/`db.crt` are exactly what sbsign/ukify consume. + +## 2. Convert to signed EFI variable updates (`.auth`) + +```bash +for v in PK KEK db; do cert-to-efi-sig-list -g "$GUID" $v.crt $v.esl; done + +sign-efi-sig-list -g "$GUID" -k PK.key -c PK.crt PK PK.esl PK.auth # PK self-signs +sign-efi-sig-list -g "$GUID" -k PK.key -c PK.crt KEK KEK.esl KEK.auth # PK signs KEK +sign-efi-sig-list -g "$GUID" -k KEK.key -c KEK.crt db db.esl db.auth # KEK signs db +``` + +## 3. Enrol into firmware + +Put the firmware in **Setup Mode** first (clear the existing keys in the UEFI +setup screen). Then enrol **db and KEK before PK** — setting PK last is what flips +the firmware out of Setup Mode and turns Secure Boot on: + +```bash +efi-updatevar -f db.auth db +efi-updatevar -f KEK.auth KEK +efi-updatevar -f PK.auth PK # do this LAST +``` + +Alternatively, enrol the `.auth`/`.esl` files via the firmware's own +"enrol key from file" UI or `KeyTool.efi` — useful for headless fleet hardware +where this is scripted into provisioning. + +> The fleet uses a UKI with **no shim and no Microsoft keys**, so no MOK is +> needed. If a host must also boot vendor-signed media, *add* your `db` alongside +> Microsoft's UEFI CA instead of clearing it. + +### Manual enrollment on a fleet workstation (over SSH) + +The build bakes the EFI management tools into the image (`efitools` + +`efivar`/`mokutil`/`efibootmgr`, installed in the main package provisioner), so +you can enrol keys **manually over SSH** on a booted workstation that is in Setup +Mode — handy for provisioning before the automated flow (TODO #10) exists. The +tools write to `efivarfs` (`/sys/firmware/efi/efivars`), not the read-only root, +so they work at runtime; they're RPM-installed so fapolicyd already trusts them. + +```bash +# on the workstation (as the sudo admin): +mokutil --sb-state # check Secure Boot / Setup Mode +efi-readvar # dump current PK/KEK/db/dbx +# copy your PK.auth/KEK.auth/db.auth (or .esl) onto the host, then in Setup Mode: +sudo efi-updatevar -f db.auth db +sudo efi-updatevar -f KEK.auth KEK +sudo efi-updatevar -f PK.auth PK # last — leaves Setup Mode +efi-readvar # read back + confirm fingerprints match +``` + +Only the **public** certs/`.esl`/`.auth` need to reach the workstation — never +copy `PK.key`/`KEK.key`/`db.key` onto a host. + +## 4. Wire `db` into the build + +```bash +packer build -var image_version=X.Y.Z \ + -var sb_key=/path/to/db.key \ + -var sb_cert=/path/to/db.crt \ + -var oci_image=/fleet-boot . +``` + +ukify signs `fleet-.efi` with `db`; the enrolled `db` cert lets firmware +verify it. Without these vars the build stays **unsigned** (lab-only, with the +loud warning). In CI, `sb_key`/`sb_cert` are secret paths on the HSM/signing host. + +## Signing in CI (Gitea Actions) + +`sb_key`/`sb_cert` **can** be Gitea repo secrets, with one wrinkle: ukify consumes +them as **file paths**, not values, and the signing post-processor runs +**shell-local on the runner** (not in the build VM — consistent with invariant +#7). So the job materializes the secrets to runner-temp files, passes the paths, +and shreds the key afterward. This is wired in +[../.gitea/workflows/gitea-main-ci.yaml](../.gitea/workflows/gitea-main-ci.yaml) +(steps: *Materialize Secure Boot signing material* → *packer build* → *Shred +Secure Boot signing key*). Set repo secrets **`SB_KEY`** (the `db` private key, +PEM) and **`SB_CERT`** (the `db` cert, PEM); leave them unset for an unsigned lab +build. + +Caveats: +- **Only `db` belongs in CI.** PK and KEK never leave the offline/HSM store. +- `SB_CERT` is a public cert (not secret); kept as a secret only for convenience. +- A CI secret puts the private key **in plaintext on the runner FS** for the + duration of the build — only as safe as the runner. Use a **trusted, + ephemeral/self-hosted** runner, ensure secrets are **not exposed to fork/PR + builds**, and rely on `RUNNER_TEMP` wipe + the shred step. Gitea masks secret + values in logs, and `build-oci.sh` passes the *path* (not content) to ukify, so + the key won't leak into logs. +- **Stronger (production target):** keep `db.key` in an **HSM/PKCS#11** token or + KMS and sign *through* it, so the raw key never touches the runner disk. The + CI-secret flow is a reasonable interim; the HSM flow is the invariant-#7 ideal + (TODO #8). + +## Key hygiene & lifecycle + +- **PK.key and KEK.key go offline / into an HSM and stay there.** They are only + needed to rotate keys; routine signing uses **db.key** only. +- **Production ideal:** keep `db.key` in an HSM/PKCS#11 token and have + sbsign/ukify sign *through* it rather than reading a file from disk. +- **Validity:** `-days 3650` is 10 years; pick a rotation policy. Rotating `db` + = sign new `db.esl` with KEK, push the `db` variable update, re-sign UKIs. +- **dbx** (the revocation list) is a fourth, optional variable — manage it to + blacklist a compromised `db` cert. +- **Lab shortcut:** [`sbctl`](https://github.com/Foxboron/sbctl) + (`sbctl create-keys` → `sbctl enroll-keys`) generates and enrols the whole trio + and tracks signed files — convenient for a dev box, but it keeps keys as local + files, so for the fleet's HSM/offline-PK model use the manual flow above for + PK/KEK. + +## Related + +- Invariants #6 (UKI cmdline immutable once signed), #7 (keys never in repo/VM), + #5 (roothash↔UKI hard gate) — [../CLAUDE.md](../CLAUDE.md). +- Signing is the production-posture step in [TODO.md](TODO.md) #8. +- FIPS is currently off and is to be revisited alongside signing/Secure Boot + ([stig-deviations.md](stig-deviations.md) #5).