diff --git a/Cargo.lock b/Cargo.lock index aeb4270..8daa9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2045,8 +2045,8 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vaportpm-attest" -version = "0.1.0" -source = "git+https://github.com/lockboot/vaportpm#eb643add961b72c8aa43a7674ff824d9db02646b" +version = "0.3.0" +source = "git+https://github.com/lockboot/vaportpm?rev=7041f461eb33bc15ad914466042b7befe358d119#7041f461eb33bc15ad914466042b7befe358d119" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index af2b919..67a6609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,10 @@ libc = "0.2" # use — the detached .sig is a cross-repo wire contract. ed25519-compact = { version = "2", default-features = false } -vaportpm-attest = { git = "https://github.com/lockboot/vaportpm" } +# Pinned to the vaportpm v0.3.0 release commit: immutable SHA + a `version` guard (the build fails +# if the rev ever drifts to a commit whose crate version isn't 0.3.0). Tag is not GPG-signed yet; +# a signed release will follow. Bump both on the next vaportpm release. +vaportpm-attest = { git = "https://github.com/lockboot/vaportpm", rev = "7041f461eb33bc15ad914466042b7befe358d119", version = "=0.3.0" } reqwest = { version = "=0.12.24", default-features = false, features = ["blocking", "rustls-tls-webpki-roots-no-provider"] } rustls = { version = "=0.23.23", default-features = false, features = ["std", "tls12"] } diff --git a/Dockerfile.build b/Dockerfile.build index a9b6962..f9df7d5 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -15,6 +15,7 @@ RUN apt-get -qq update && \ fdisk \ findutils \ gdisk \ + gnupg \ gzip \ kmod \ make \ @@ -23,6 +24,7 @@ RUN apt-get -qq update && \ python3 \ python3-pip \ qemu-efi-aarch64 \ + rpm \ rpm2cpio \ sbsigntool \ util-linux \ diff --git a/Makefile b/Makefile index 7cc96bd..1ee9553 100644 --- a/Makefile +++ b/Makefile @@ -28,71 +28,13 @@ DEFAULT_STAGE2_URL = https://lockboot.s3.us-east-1.amazonaws.com/examples/stage2 user-data.json: wget -O "$@" $(DEFAULT_STAGE2_URL) -# ---- Docker images (shared lockboot family; built locally, never published) ---- -# BUILD_IMAGE compiles everything; HARNESS_IMAGE (from stage0) runs the qemu chain -# test. Both are built by the workspace `make image` from stage0 (the canonical -# Dockerfiles); a standalone CI clone builds BUILD_IMAGE itself via docker-build-base. -BUILD_IMAGE = lockboot:build -HARNESS_IMAGE = lockboot:harness -RUNTIME_IMAGE ?= lockboot:latest +# ---- Shared build harness (docker images + DOCKER_RUN plumbing) ---- +# Vendored byte-identically from stage0/build.mk (the canonical source) via the workspace +# `make sync-harness`; do not hand-edit. `make check-harness` guards against drift. +include build.mk -.PHONY: docker-build-base -docker-build-base: - docker build -f Dockerfile.build -t $(BUILD_IMAGE) . - -# ---- Docker run plumbing (keep identical across repos; mirrors stage0/Makefile) ---- -# Own build artifacts by whoever owns the checkout, not the caller's euid. Under -# `gh act` the caller is root but the bind-mounted tree is still yours, so stat -# keeps output user-owned instead of trampling the project dir with root files. -USER_ID := $(shell stat -c %u .) -GROUP_ID := $(shell stat -c %g .) - -KVM_GID := $(shell stat -c %g /dev/kvm 2>/dev/null || echo "") -KVM_MOUNT := $(shell test -e /dev/kvm && echo "-v /dev/kvm:/dev/kvm") -DOCKER_OPT_KVM := $(if $(KVM_GID),--group-add $(KVM_GID)) $(KVM_MOUNT) - -# Recursive-docker passthrough: the UKI rule and runtime-image build shell out to -# the HOST docker daemon (rootfs extraction / buildx), so forward the socket + gid. -DOCKER_SOCK_GID := $(shell stat -c %g /var/run/docker.sock 2>/dev/null || echo "") -DOCKER_SOCK_MOUNT := $(shell test -e /var/run/docker.sock && echo "-v /var/run/docker.sock:/var/run/docker.sock") -DOCKER_OPT_DOCKER := $(DOCKER_SOCK_MOUNT) $(if $(DOCKER_SOCK_GID),--group-add $(DOCKER_SOCK_GID)) - -DOCKER_SAMEUSER := -u $(USER_ID):$(GROUP_ID) - -# Host-path translation for docker-in-devcontainer. Inside the devcontainer /src is -# a host bind mount and the inner Docker talks to the HOST daemon, which cannot -# resolve /src/... paths; translate $(CURDIR) to the real host path (the bracketed -# subpath findmnt reports for the /src bind). On the host CURDIR is not under /src, -# so this is a pass-through and your workflow is unchanged. Keep identical across repos. -HOST_DIR := $(CURDIR) -ifneq ($(filter /src/%,$(CURDIR)),) - SRC_BIND := $(shell findmnt -fnro SOURCE --target /src 2>/dev/null | sed -n 's/.*\[\(.*\)\]$$/\1/p') - ifneq ($(SRC_BIND),) - HOST_DIR := $(SRC_BIND)$(CURDIR:/src%=%) - endif -endif - -# Mount the WORKSPACE (parent of this repo) at /src so builds reuse the shared -# workspace-level .cargo/.rustup (matching the devcontainer), instead of creating -# per-repo copies. The repo then lives at /src/$(REPO_NAME). -REPO_NAME := $(notdir $(HOST_DIR)) -HOST_WS := $(patsubst %/,%,$(dir $(HOST_DIR))) - -# Under CI / `gh act` (CI=true, runs as root) keep cargo/rustup caches ephemeral -# inside the container, so root-owned dirs never land in the bind-mounted project. -# Locally (no CI) the image's CARGO_HOME=/src/.cargo + RUSTUP_HOME=/src/.rustup win, -# i.e. the shared workspace caches. -CACHE_ENV := $(if $(CI),-e CARGO_HOME=/tmp/.cargo -e RUSTUP_HOME=/tmp/.rustup) - -DOCKER_RUN = docker run --rm \ - --privileged \ - -v $(HOST_WS):/src \ - -h lockboot \ - --add-host lockboot:127.0.0.1 \ - -e OWNER_UID=$(USER_ID) \ - -e OWNER_GID=$(GROUP_ID) \ - $(CACHE_ENV) \ - -w /src/$(REPO_NAME) +# stage1-only: runtime OCI image tag for the docker-runtime / buildx targets below. +RUNTIME_IMAGE ?= lockboot:latest docker-shell-base: docker-build-base $(DOCKER_RUN) -ti $(DOCKER_SAMEUSER) $(DOCKER_OPT_DOCKER) $(DOCKER_OPT_KVM) $(BUILD_IMAGE) bash @@ -143,6 +85,17 @@ tools/build-uki/mkuki: docker-build-base $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ bash -c "cargo build --release --locked -p mkuki --target x86_64-unknown-linux-musl && cp -v target/x86_64-unknown-linux-musl/release/mkuki $@" +# Regenerate tools/build-uki/fedora-deps.mk (pinned, GPG-verified kernel + systemd-boot stub). +# make update-fedora-deps FCOS=44.20260621.3.1 [SYSTEMD=259.6-1.fc44] [KERNEL=] +# Kernel NVR is read from the FCOS build manifest; the stub is pulled from Fedora (explicit SYSTEMD, +# else latest stable via Bodhi). Every RPM's Fedora GPG signature is verified before it is pinned. +.PHONY: update-fedora-deps +update-fedora-deps: docker-build-base + @[ -n "$(FCOS)" ] || [ -n "$(KERNEL)" ] || { echo "set FCOS= (and optionally SYSTEMD=)"; exit 1; } + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ + python3 tools/build-uki/update-fedora-deps.py \ + $(if $(FCOS),--fcos $(FCOS)) $(if $(SYSTEMD),--systemd $(SYSTEMD)) $(if $(KERNEL),--kernel $(KERNEL)) + ##################################################################### # Runtime container image (busybox + stage1) -> ghcr on uki-v* tags. diff --git a/build.mk b/build.mk new file mode 100644 index 0000000..38a24e7 --- /dev/null +++ b/build.mk @@ -0,0 +1,77 @@ +# ---- Lock.Boot shared build harness ---------------------------------------------------------- +# Runs every cargo/tool invocation inside the locally-built lockboot:build image, so builds are +# cc-free-by-design and byte-reproducible (rust-lld + musl, shared /src/.cargo + /src/.rustup). +# +# CANONICAL SOURCE: stage0/build.mk. This file is vendored byte-identically into each participating +# repo (stage1, vaportpm, ...) because CI checks out each repo ALONE (no workspace parent), so a +# shared harness cannot be a cross-repo include -- it must live in the repo. Do NOT hand-edit the +# copies: edit stage0/build.mk, then run `make sync-harness` from the workspace ($(CANON)=stage0), +# guarded by `make check-harness`. +# +# Each repo's Makefile does `include build.mk` and defines its own targets, invoking cargo as +# $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo ... +# with a `docker-build-base` prerequisite so the image is built on demand (incl. standalone CI). + +# ---- Docker images (shared lockboot family; built locally, never published) ---- +BUILD_IMAGE = lockboot:build +HARNESS_IMAGE = lockboot:harness + +.PHONY: docker-build-base +docker-build-base: + docker build -f Dockerfile.build -t $(BUILD_IMAGE) . + +# ---- Docker run plumbing (keep identical across repos) ---- +# Own build artifacts by whoever owns the checkout, not the caller's euid. Under +# `gh act` the caller is root but the bind-mounted tree is still yours, so stat +# keeps output user-owned instead of trampling the project dir with root files. +# On a normal host/devcontainer run this equals `id -u`/`id -g`, so nothing changes. +USER_ID := $(shell stat -c %u .) +GROUP_ID := $(shell stat -c %g .) + +KVM_GID := $(shell stat -c %g /dev/kvm 2>/dev/null || echo "") +KVM_MOUNT := $(shell test -e /dev/kvm && echo "-v /dev/kvm:/dev/kvm") +DOCKER_OPT_KVM := $(if $(KVM_GID),--group-add $(KVM_GID)) $(KVM_MOUNT) + +# Recursive-docker passthrough: rules that shell out to the HOST docker daemon (e.g. stage1's UKI +# rootfs extraction / runtime-image buildx) forward the socket + its gid. Defined here for every +# repo; harmless (expands empty) when a repo has no such rule. +DOCKER_SOCK_GID := $(shell stat -c %g /var/run/docker.sock 2>/dev/null || echo "") +DOCKER_SOCK_MOUNT := $(shell test -e /var/run/docker.sock && echo "-v /var/run/docker.sock:/var/run/docker.sock") +DOCKER_OPT_DOCKER := $(DOCKER_SOCK_MOUNT) $(if $(DOCKER_SOCK_GID),--group-add $(DOCKER_SOCK_GID)) + +DOCKER_SAMEUSER := -u $(USER_ID):$(GROUP_ID) + +# Host-path translation for docker-in-devcontainer. Inside the devcontainer /src is +# a host bind mount and the inner Docker talks to the HOST daemon, which cannot +# resolve /src/... paths; translate $(CURDIR) to the real host path (the bracketed +# subpath findmnt reports for the /src bind). On the host CURDIR is not under /src, +# so this is a pass-through and your workflow is unchanged. Keep identical across repos. +HOST_DIR := $(CURDIR) +ifneq ($(filter /src/%,$(CURDIR)),) + SRC_BIND := $(shell findmnt -fnro SOURCE --target /src 2>/dev/null | sed -n 's/.*\[\(.*\)\]$$/\1/p') + ifneq ($(SRC_BIND),) + HOST_DIR := $(SRC_BIND)$(CURDIR:/src%=%) + endif +endif + +# Mount the WORKSPACE (parent of this repo) at /src so builds reuse the shared +# workspace-level .cargo/.rustup (matching the devcontainer), instead of creating +# per-repo copies. The repo then lives at /src/$(REPO_NAME). +REPO_NAME := $(notdir $(HOST_DIR)) +HOST_WS := $(patsubst %/,%,$(dir $(HOST_DIR))) + +# Under CI / `gh act` (CI=true, runs as root) keep cargo/rustup caches ephemeral +# inside the container, so root-owned dirs never land in the bind-mounted project. +# Locally (no CI) the image's CARGO_HOME=/src/.cargo + RUSTUP_HOME=/src/.rustup win, +# i.e. the shared workspace caches. +CACHE_ENV := $(if $(CI),-e CARGO_HOME=/tmp/.cargo -e RUSTUP_HOME=/tmp/.rustup) + +DOCKER_RUN = docker run --rm \ + --privileged \ + -v $(HOST_WS):/src \ + -h lockboot \ + --add-host lockboot:127.0.0.1 \ + -e OWNER_UID=$(USER_ID) \ + -e OWNER_GID=$(GROUP_ID) \ + $(CACHE_ENV) \ + -w /src/$(REPO_NAME) diff --git a/tools/build-uki/Makefile b/tools/build-uki/Makefile index fed1b17..a7f9e5b 100644 --- a/tools/build-uki/Makefile +++ b/tools/build-uki/Makefile @@ -12,33 +12,26 @@ BUSYBOX_APK := busybox-static-$(BUSYBOX_VERSION).apk BUSYBOX_URL_x86_64 := $(ALPINE_BASE_URL)/x86_64/$(BUSYBOX_APK) BUSYBOX_URL_aarch64 := $(ALPINE_BASE_URL)/aarch64/$(BUSYBOX_APK) -# systemd-boot-unsigned from Fedora 41 (provides EFI stub) -# v256 supports PE vmlinuz on aarch64 (LoadImage/StartImage instead of direct jump) -# x86_64 provides: /usr/lib/systemd/boot/efi/linuxx64.efi.stub -# aarch64 provides: /usr/lib/systemd/boot/efi/linuxaa64.efi.stub -SYSTEMD_BOOT_VERSION := 256.17-1.fc41 -SYSTEMD_BOOT_SHA256_x86_64 := ec5ce2a3e685750a7743cf933ed9ff583fe4e90cef743c76349a158631b3dae5 -SYSTEMD_BOOT_SHA256_aarch64 := f188ea9f918be9c2314c4208c0c247bd6dc7eae96df85fa9e3c672619853ddcf +# ---- Fedora kernel + systemd-boot EFI stub ---- +# Versions + SHA256 pins live in fedora-deps.mk (GENERATED, GPG-verified); bump them with +# make update-fedora-deps FCOS= [SYSTEMD=] (target in ../../Makefile). +# The systemd stub provides the EFI stub (linuxx64/aa64.efi.stub, PE-vmlinuz capable on aarch64); +# kernel-modules-core carries the ena (AWS) + gve (GCP) NIC drivers. RPMs come from koji's SIGNED +# path and are GPG-verified against $(FEDORA_KEY) on every download (download-verify-rpm.sh). +include fedora-deps.mk + +FEDORA_KEY := keys/RPM-GPG-KEY-fedora-$(FEDORA_RELEASE)-primary + SYSTEMD_BOOT_RPM_x86_64 := systemd-boot-unsigned-$(SYSTEMD_BOOT_VERSION).x86_64.rpm SYSTEMD_BOOT_RPM_aarch64 := systemd-boot-unsigned-$(SYSTEMD_BOOT_VERSION).aarch64.rpm -SYSTEMD_BOOT_URL_x86_64 := https://kojipkgs.fedoraproject.org/packages/systemd/256.17/1.fc41/x86_64/$(SYSTEMD_BOOT_RPM_x86_64) -SYSTEMD_BOOT_URL_aarch64 := https://kojipkgs.fedoraproject.org/packages/systemd/256.17/1.fc41/aarch64/$(SYSTEMD_BOOT_RPM_aarch64) +SYSTEMD_BOOT_URL_x86_64 := $(SYSTEMD_BOOT_BASE_URL)/x86_64/$(SYSTEMD_BOOT_RPM_x86_64) +SYSTEMD_BOOT_URL_aarch64 := $(SYSTEMD_BOOT_BASE_URL)/aarch64/$(SYSTEMD_BOOT_RPM_aarch64) -# Fedora 41 kernel 6.12 (has both ena and gve drivers) -FEDORA_KERNEL_VERSION := 6.12.4-200.fc41 -FEDORA_KERNEL_BASE_URL := https://kojipkgs.fedoraproject.org/packages/kernel/6.12.4/200.fc41 - -# kernel-core (contains vmlinuz) -FEDORA_KERNEL_CORE_SHA256_x86_64 := f7df5e7d36b7ed4c311f358340fcf2ae414cd1b4b8ad8d8555bb5cae2403cfc3 -FEDORA_KERNEL_CORE_SHA256_aarch64 := 5494b00c95832eb52b39b306fd4ab912893824cd0419612fb52fc97643fa56cb FEDORA_KERNEL_CORE_RPM_x86_64 := kernel-core-$(FEDORA_KERNEL_VERSION).x86_64.rpm FEDORA_KERNEL_CORE_RPM_aarch64 := kernel-core-$(FEDORA_KERNEL_VERSION).aarch64.rpm FEDORA_KERNEL_CORE_URL_x86_64 := $(FEDORA_KERNEL_BASE_URL)/x86_64/$(FEDORA_KERNEL_CORE_RPM_x86_64) FEDORA_KERNEL_CORE_URL_aarch64 := $(FEDORA_KERNEL_BASE_URL)/aarch64/$(FEDORA_KERNEL_CORE_RPM_aarch64) -# kernel-modules-core (contains core .ko modules including gve and ena) -FEDORA_KERNEL_MODULES_SHA256_x86_64 := 7d9e7d9f925de56f04c103a65011285d0ded2fc45a79ca1b300828316916200c -FEDORA_KERNEL_MODULES_SHA256_aarch64 := 4bbe3e2922b044519d126cc1a06f0416d3f4f26523f0bae75d5fe355d4abb7ac FEDORA_KERNEL_MODULES_RPM_x86_64 := kernel-modules-core-$(FEDORA_KERNEL_VERSION).x86_64.rpm FEDORA_KERNEL_MODULES_RPM_aarch64 := kernel-modules-core-$(FEDORA_KERNEL_VERSION).aarch64.rpm FEDORA_KERNEL_MODULES_URL_x86_64 := $(FEDORA_KERNEL_BASE_URL)/x86_64/$(FEDORA_KERNEL_MODULES_RPM_x86_64) @@ -60,12 +53,12 @@ aarch64: $(addprefix aarch64/,$(TARGETS)) @tar --warning=no-unknown-keyword -xzOf $< bin/busybox.static > "$@" @chmod +x "$@" -# Download systemd-boot-unsigned RPM from Fedora +# Download + SHA256 + GPG-verify systemd-boot-unsigned RPM (signed Fedora koji path) x86_64/$(SYSTEMD_BOOT_RPM_x86_64): - @./download-and-verify.sh "$@" "$(SYSTEMD_BOOT_SHA256_x86_64)" "$(SYSTEMD_BOOT_URL_x86_64)" + @./download-verify-rpm.sh "$@" "$(SYSTEMD_BOOT_SHA256_x86_64)" "$(SYSTEMD_BOOT_URL_x86_64)" "$(FEDORA_KEY)" aarch64/$(SYSTEMD_BOOT_RPM_aarch64): - @./download-and-verify.sh "$@" "$(SYSTEMD_BOOT_SHA256_aarch64)" "$(SYSTEMD_BOOT_URL_aarch64)" + @./download-verify-rpm.sh "$@" "$(SYSTEMD_BOOT_SHA256_aarch64)" "$(SYSTEMD_BOOT_URL_aarch64)" "$(FEDORA_KEY)" # Extract EFI stub from systemd-boot RPM x86_64/stub.efi: x86_64/$(SYSTEMD_BOOT_RPM_x86_64) @@ -78,37 +71,32 @@ aarch64/stub.efi: aarch64/$(SYSTEMD_BOOT_RPM_aarch64) @rpm2cpio $< | cpio -i --quiet --to-stdout ./usr/lib/systemd/boot/efi/linuxaa64.efi.stub > $@ @chmod +x $@ -# Download Fedora kernel-core RPMs from kojipkgs +# Download + SHA256 + GPG-verify Fedora kernel-core RPMs (signed koji path) x86_64/$(FEDORA_KERNEL_CORE_RPM_x86_64): - @./download-and-verify.sh "$@" "$(FEDORA_KERNEL_CORE_SHA256_x86_64)" "$(FEDORA_KERNEL_CORE_URL_x86_64)" + @./download-verify-rpm.sh "$@" "$(FEDORA_KERNEL_CORE_SHA256_x86_64)" "$(FEDORA_KERNEL_CORE_URL_x86_64)" "$(FEDORA_KEY)" x86_64/kernel-core.rpm: x86_64/$(FEDORA_KERNEL_CORE_RPM_x86_64) @ln -sf $(notdir $<) $@ aarch64/$(FEDORA_KERNEL_CORE_RPM_aarch64): - @./download-and-verify.sh "$@" "$(FEDORA_KERNEL_CORE_SHA256_aarch64)" "$(FEDORA_KERNEL_CORE_URL_aarch64)" + @./download-verify-rpm.sh "$@" "$(FEDORA_KERNEL_CORE_SHA256_aarch64)" "$(FEDORA_KERNEL_CORE_URL_aarch64)" "$(FEDORA_KEY)" aarch64/kernel-core.rpm: aarch64/$(FEDORA_KERNEL_CORE_RPM_aarch64) @ln -sf $(notdir $<) $@ -# Download Fedora kernel-modules-core RPMs from kojipkgs +# Download + SHA256 + GPG-verify Fedora kernel-modules-core RPMs (signed koji path; has ena + gve) x86_64/$(FEDORA_KERNEL_MODULES_RPM_x86_64): - @./download-and-verify.sh "$@" "$(FEDORA_KERNEL_MODULES_SHA256_x86_64)" "$(FEDORA_KERNEL_MODULES_URL_x86_64)" + @./download-verify-rpm.sh "$@" "$(FEDORA_KERNEL_MODULES_SHA256_x86_64)" "$(FEDORA_KERNEL_MODULES_URL_x86_64)" "$(FEDORA_KEY)" x86_64/kernel-modules-core.rpm: x86_64/$(FEDORA_KERNEL_MODULES_RPM_x86_64) @ln -sf $(notdir $<) $@ aarch64/$(FEDORA_KERNEL_MODULES_RPM_aarch64): - @./download-and-verify.sh "$@" "$(FEDORA_KERNEL_MODULES_SHA256_aarch64)" "$(FEDORA_KERNEL_MODULES_URL_aarch64)" + @./download-verify-rpm.sh "$@" "$(FEDORA_KERNEL_MODULES_SHA256_aarch64)" "$(FEDORA_KERNEL_MODULES_URL_aarch64)" "$(FEDORA_KEY)" aarch64/kernel-modules-core.rpm: aarch64/$(FEDORA_KERNEL_MODULES_RPM_aarch64) @ln -sf $(notdir $<) $@ -# Retrieve package URLs from koji (for reference) -kernel-hash-%: - @echo "Fedora kernel: $(FEDORA_KERNEL_PACKAGE)-$(FEDORA_KERNEL_VERSION).$*" - @echo "Download from: https://koji.fedoraproject.org/koji/buildinfo?buildID=2599252" - clean: rm -rf x86_64 aarch64 diff --git a/tools/build-uki/build.sh b/tools/build-uki/build.sh index 3cd44dd..4e2ce51 100755 --- a/tools/build-uki/build.sh +++ b/tools/build-uki/build.sh @@ -120,6 +120,35 @@ REQUIRED_MODULES=( # Nitro Enclaves "drivers/misc/nsm.ko" "drivers/virt/nitro_enclaves/nitro_enclaves.ko" + + # Storage stack for stage2. init loads these explicitly, in dependency order, before + # the modules_disabled latch (the payload cannot load them itself, and once the latch + # is set an unresolved dependency can never be pulled on demand -- so we list deps too, + # not relying on modprobe auto-pull). What each provides / can be used for: + # dm-crypt block-device encryption (stage2 uses it for the encrypted /data) + # dm-verity integrity-checked read-only device (stage2's dm-verity'd erofs runtime) + # reed_solomon dm-verity forward-error-correction dependency + # overlay overlay filesystem (stage2's ephemeral overlay root) + # erofs/netfs read-only image filesystem (+ its netfs dependency) + # Extension point: authenticated (not just confidential) /data would additionally need + # dm-integrity + its async_xor -> async_tx (ASYNC_XOR journal) deps; omitted while stage2 + # uses confidentiality-only dm-crypt, to keep the measured initramfs minimal. + "drivers/md/dm-crypt.ko" + "drivers/md/dm-verity.ko" + "lib/reed_solomon/reed_solomon.ko" + "fs/overlayfs/overlay.ko" + "fs/erofs/erofs.ko" + "fs/netfs/netfs.ko" + + # NVMe disk driver, so stage2 can see the persistent volume (EC2 EBS is NVMe; + # the harness also attaches its disk as NVMe). Never needed to boot (initramfs + + # in-memory payload), so it was previously absent. Dep chain, loaded in order by + # init: hkdf -> nvme-auth, nvme-keyring -> nvme-core -> nvme. + "crypto/hkdf.ko" + "drivers/nvme/common/nvme-auth.ko" + "drivers/nvme/common/nvme-keyring.ko" + "drivers/nvme/host/nvme-core.ko" + "drivers/nvme/host/nvme.ko" ) for mod_path in "${REQUIRED_MODULES[@]}"; do diff --git a/tools/build-uki/download-verify-rpm.sh b/tools/build-uki/download-verify-rpm.sh new file mode 100755 index 0000000..b948015 --- /dev/null +++ b/tools/build-uki/download-verify-rpm.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Download a Fedora RPM and verify BOTH its SHA256 pin and its Fedora GPG signature. All four args are +# required -- there is deliberately no way to fetch an RPM here without signature verification. (Busybox +# is an Alpine .apk with no Fedora signature and uses download-and-verify.sh instead.) +set -euo pipefail + +OUT="${1:-}" +SHA="${2:-}" +URL="${3:-}" +KEY="${4:-}" +if [ -z "$OUT" ] || [ -z "$SHA" ] || [ -z "$URL" ] || [ -z "$KEY" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +HERE="$(dirname "$0")" +mkdir -p "$(dirname "$OUT")" + +[ -f "$OUT" ] || wget -q -O "$OUT" "$URL" + +if ! echo "${SHA} ${OUT}" | sha256sum -c - >/dev/null 2>&1; then + echo "SHA256 mismatch: $OUT" >&2 + rm -f "$OUT" + exit 1 +fi + +if ! "$HERE/verify-rpm-gpg.sh" "$OUT" "$KEY"; then + rm -f "$OUT" + exit 1 +fi diff --git a/tools/build-uki/fedora-deps.mk b/tools/build-uki/fedora-deps.mk new file mode 100644 index 0000000..36a7857 --- /dev/null +++ b/tools/build-uki/fedora-deps.mk @@ -0,0 +1,17 @@ +# GENERATED by update-fedora-deps.py -- do not hand-edit. +# Bump with: make update-fedora-deps FCOS= [SYSTEMD=] +# Kernel from Fedora CoreOS; systemd-boot stub from Fedora 44. All RPMs are +# GPG-verified against keys/RPM-GPG-KEY-fedora-44-primary at bump time and every build. + +FEDORA_RELEASE := 44 +FEDORA_KERNEL_VERSION := 7.0.12-201.fc44 +FEDORA_KERNEL_BASE_URL := https://kojipkgs.fedoraproject.org/packages/kernel/7.0.12/201.fc44/data/signed/6d9f90a6 +SYSTEMD_BOOT_VERSION := 259.6-1.fc44 +SYSTEMD_BOOT_BASE_URL := https://kojipkgs.fedoraproject.org/packages/systemd/259.6/1.fc44/data/signed/6d9f90a6 + +FEDORA_KERNEL_CORE_SHA256_x86_64 := 5c549be1c1157dd58693344777cc7722e8c13350b6fcc96cacf72cd64d52f358 +FEDORA_KERNEL_CORE_SHA256_aarch64 := ccbd289e27706d00a6587360f7cf6a3c5742bd124ebe7c511b98a2ca9e73fe8c +FEDORA_KERNEL_MODULES_SHA256_x86_64 := f9f000cbc1ba3b49c2cbd105cc1ccc10d2d9f9e10802e92d6c966c8d1375857c +FEDORA_KERNEL_MODULES_SHA256_aarch64 := f282c61f947a9dee282b66b89b528876b1e4d9be505f15133811953419fefa28 +SYSTEMD_BOOT_SHA256_x86_64 := 6a3707da87640b2fca178e5c7e44cf92fa6321f3389c166f713b4f9b0411ff20 +SYSTEMD_BOOT_SHA256_aarch64 := 190a2559e97d381d2de3be786d55b359df1c0b4067427c025ef31ce4177e0d2a diff --git a/tools/build-uki/init b/tools/build-uki/init index dd918a4..da8a042 100644 --- a/tools/build-uki/init +++ b/tools/build-uki/init @@ -68,6 +68,27 @@ mod vhost mod vhost_vsock mod nitro_enclaves +# Storage stack for stage2. Must load here, before the modules_disabled latch below +# makes module loading impossible for the payload. Dependencies are loaded explicitly, +# in order (not via modprobe auto-pull): once the latch is set, an unresolved dep can +# never be loaded on demand. Provides: dm-crypt (block encryption, e.g. /data), dm-verity +# (integrity-checked RO device) + reed_solomon (its FEC dep), overlay (overlay root), +# erofs + netfs (RO image fs + dep). Authenticated /data would re-add dm-integrity +# (+ async_tx/async_xor); omitted while stage2 uses confidentiality-only dm-crypt. +mod dm-crypt +mod overlay +mod reed_solomon +mod dm-verity +mod netfs +mod erofs + +# NVMe disk driver (EC2 EBS + the harness disk are NVMe). Deps in order. +mod hkdf +mod nvme-auth +mod nvme-keyring +mod nvme-core +mod nvme + ##################################################################### # Lock down kernel before handing off to stage1/stage2. # diff --git a/tools/build-uki/keys/README.md b/tools/build-uki/keys/README.md new file mode 100644 index 0000000..8a882a1 --- /dev/null +++ b/tools/build-uki/keys/README.md @@ -0,0 +1,23 @@ +# Fedora package signing keys + +The UKI's kernel and systemd-boot stub are Fedora RPMs, pulled from koji's **signed** path and +GPG-verified against the Fedora release signing key on every download (`../download-verify-rpm.sh` -> +`../verify-rpm-gpg.sh`) and at bump time (`../update-fedora-deps.py`). This directory holds the +trusted public keys, one per Fedora release, named `RPM-GPG-KEY-fedora--primary`. + +These keys are the **trust anchor** for the kernel/stub supply chain: a SHA256 pin only says "these +exact bytes", but the GPG signature proves the bytes are genuinely Fedora's. + +## Trusted keys + +| File | Fedora | Fingerprint | koji `signed/` id | +|------|:------:|-------------|-------------------| +| `RPM-GPG-KEY-fedora-44-primary` | 44 | `36F6 12DC F27F 7D1A 48A8 35E4 DBFC F71C 6D9F 90A6` | `6d9f90a6` | + +## Adding a new release's key (when bumping to fc45, etc.) + +1. Fetch it, e.g. `curl -fsSLO https://src.fedoraproject.org/rpms/fedora-repos/raw/f45/f/RPM-GPG-KEY-fedora-45-primary`. +2. **Verify the fingerprint** against the value Fedora publishes at + before trusting it (`gpg --show-keys --with-fingerprint `). +3. Commit it here and add a row above. `update-fedora-deps.py` derives the koji `signed/` subdir + from the key itself, and errors if the release's key is missing. diff --git a/tools/build-uki/keys/RPM-GPG-KEY-fedora-44-primary b/tools/build-uki/keys/RPM-GPG-KEY-fedora-44-primary new file mode 100644 index 0000000..f859ab3 --- /dev/null +++ b/tools/build-uki/keys/RPM-GPG-KEY-fedora-44-primary @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGeGrzsBEAC4UV5Ij9oz6h6abEKIRoiezttFfnLhwOAfE9tWtfIFMRmhY91u +L88PKf12n2xHBd3oc5ahBzGeTBhaMV+VJAppoQMSOIMI5q966D9GQ0LkJT+E5bwn +xGRJKp7qccevh2KFOUt2vHtFskhDOuAIupoKfo5FgI9PkvAVBsrUpO/22yjNv0V/ +aeDXxZhRX8m/8FKJ77VcZtBRPcp7M41bCmW9gV9IDpD81hAdTjYoQr1Y3KU0FTm5 +W4l1mf9mZcKMskOk08TyzQeC2YRB20EYRK439XCGJ4P7BFiOl96EbPpky2pHe2FV +AvX474o3QEecTK3KxZrsRjmXOqpjRPy5YyMfKEYBM9j3zBDvpDFk79Mfuw5n2Nr5 +U4Wn/rqfhKLUKkfpfCow97nzq8NqynwS09yVobIfjHCKRtjwun6ife+s7R4L2nAu +rTWPAHqzIjjW5nnjaFtoSulIadVKx+KibKajA6gRAc6K7xMyMTHfqZeTAIcawvX6 +h2d/nd8xCfogM5FTI5obNSUVNaMv5vQg6vcV1fb6oRgodF0Bi+1dssq5EMQpHFJM +nIQ5NVwuzSjCLt3X2mWUp0mfIt2K9oBpBct12uXho7Nm1bSC5UFNYsvw+rj6vTqZ +ilK9pyfcYmELv7a/NPkyuACsBFGoc66nBfrEvk57kW9FaJK9mjSqGftykQARAQAB +tDFGZWRvcmEgKDQ0KSA8ZmVkb3JhLTQ0LXByaW1hcnlAZmVkb3JhcHJvamVjdC5v +cmc+iQJSBBMBCAA8FiEENvYS3PJ/fRpIqDXk2/z3HG2fkKYFAmeGrzsCGw8FCwkI +BwIDIgIBBhUKCQgLAgQWAgMBAh4HAheAAAoJENv89xxtn5CmIU0P/iaFVxJjVi4P +yu8A04PbdGy2vuBBCceIjYn5HaMDwJMRjdJT6uMS494pSKNEl/JJ8K5rRdigfUV1 +2Z22X3kI5aNb4k2wpaPg5Xq0JQS9FvG4Pjm//kNy5WplmEA8HVg4MVkvySWiXay4 ++tkCelhE8aQDstYEm3uh+lZ6udgoInfprwFMn6H+8RXkakTW1z5NkuAA8PpMDA9o +SOFc4Hk6bhE6exEp4VNwBEkxwh4z9CGjarlXL4QEyM1UK60vtbXIHVjITjFfKVQP +j6ifdn5X69oSuK+1mUFXEV+l9pc1mVjTVTwOrG3EMBsoekFyICp1pPtfMo1dxBed +R8BFHqQFsFdmIG+59ycFznFOXzDRfaVn6OTEAk7T8nDqnpe/T4GlybLYic6KMKcM +nbMLaJZjHZ97qJb5Scpsd1TWB5TDERi4VPB7NAVC/EwxMPC3IJUbRej/s05gNjg0 ++2yyuV/U/DDnNGWnLTJDFLUaE8HhQBMvNSfmdMA47mo5CCuYmpzX/3M9vlVsv8/R +xJBxFLIj9VFCPFNgXPeu9gyyytXeWgsIpDzMNJil9tgbBuQ1dX5GFMkWtK/kPexM +KfiSU0JgJFfFSm0OKI/KXcRlbA1zP3IF+2YwbL+P5ePinHsDiAPLCQt/dWgw2tfB +ZZLj9c3Ukew6Qobuy3V1knl564qQ6wjf +=1m7R +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tools/build-uki/update-fedora-deps.py b/tools/build-uki/update-fedora-deps.py new file mode 100755 index 0000000..9f2db7b --- /dev/null +++ b/tools/build-uki/update-fedora-deps.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Regenerate fedora-deps.mk: the pinned kernel + systemd-boot-stub versions and SHA256s for the UKI. + +Bump the deps with e.g.: + + make update-fedora-deps FCOS=44.20260621.3.1 SYSTEMD=259.6-1.fc44 + +The kernel NVR comes from the Fedora CoreOS build manifest (what FCOS actually ships); the +systemd-boot stub is pulled straight from Fedora (FCOS uses grub, not sd-boot), pinned either +explicitly via --systemd or to the latest stable in that Fedora release via Bodhi. Every RPM is +downloaded from Koji and its Fedora GPG signature verified (verify-rpm-gpg.sh) before its SHA256 is +recorded, so the pins provably correspond to genuinely Fedora-signed packages -- not just "whatever +bytes were at that URL when someone ran the bump". + +Runs inside the build image (needs python3, rpm, gnupg, wget/urllib, the committed Fedora key). +""" +import argparse +import base64 +import hashlib +import json +import os +import re +import struct +import subprocess +import sys +import tempfile +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +KOJI = "https://kojipkgs.fedoraproject.org/packages" +FCOS_BASE = "https://builds.coreos.fedoraproject.org/prod/streams" +ARCHES = ("x86_64", "aarch64") + +# (make-var stem, binary rpm name, koji source-package name) +PACKAGES = ( + ("FEDORA_KERNEL_CORE", "kernel-core", "kernel"), + ("FEDORA_KERNEL_MODULES", "kernel-modules-core", "kernel"), + ("SYSTEMD_BOOT", "systemd-boot-unsigned", "systemd"), +) + + +def fetch(url): + with urllib.request.urlopen(url, timeout=60) as r: + return r.read() + + +def key_short_id(keyfile): + """Short (8-hex, lowercase) key id of an ASCII-armored v4 GPG key -- koji's signed// subdir.""" + lines, body, inblock = open(keyfile).read().splitlines(), [], False + for ln in lines: + if ln.startswith("-----BEGIN"): + inblock = True + continue + if ln.startswith("-----END"): + break + if inblock and ln.strip() and not ln.startswith("="): + body.append(ln.strip()) + data = base64.b64decode("".join(body)) + b0 = data[0] + if b0 & 0x40: # new-format packet + i = 1 + first = data[i] + if first < 192: + plen, i = first, i + 1 + elif first < 224: + plen, i = ((first - 192) << 8) + data[i + 1] + 192, i + 2 + else: + plen, i = struct.unpack(">I", data[i + 1:i + 5])[0], i + 5 + else: # old-format packet + lt, i = b0 & 0x03, 1 + sizes = {0: 1, 1: 2, 2: 4} + n = sizes[lt] + plen = int.from_bytes(data[i:i + n], "big") + i += n + pkt = data[i:i + plen] + fpr = hashlib.sha1(b"\x99" + struct.pack(">H", len(pkt)) + pkt).hexdigest() + return fpr[-8:].lower() + + +def kernel_nvr_from_fcos(version, stream): + """Read the kernel-core NVR (version-release) from an FCOS build's meta.json pkgdiff.""" + url = f"{FCOS_BASE}/{stream}/builds/{version}/x86_64/meta.json" + meta = json.loads(fetch(url)) + for entry in meta.get("pkgdiff", []): + # entry = [name, changetype, {"NewPackage": [name, "ver-rel", arch], ...}] for a change, + # or [name, changetype, [name, "ver-rel", arch]] for an add/remove. + if entry and entry[0] == "kernel-core": + info = entry[2] + pkg = info["NewPackage"] if isinstance(info, dict) else info + return pkg[1] + raise SystemExit( + f"kernel-core not found in {version} pkgdiff (kernel unchanged in this build?); " + f"pass --kernel explicitly." + ) + + +def systemd_nvr_from_bodhi(release): + """Latest stable systemd build (version-release) for Fedora , via Bodhi.""" + url = (f"https://bodhi.fedoraproject.org/updates/?packages=systemd" + f"&releases=F{release}&status=stable&rows_per_page=10") + data = json.loads(fetch(url)) + for upd in data.get("updates", []): + for build in upd.get("builds", []): + m = re.fullmatch(r"systemd-(.+)", build["nvr"]) + if m and f".fc{release}" in m.group(1): + return m.group(1) + raise SystemExit(f"could not find a stable systemd build for F{release} via Bodhi; pass --systemd.") + + +def split_nvr(nvr): + """'7.0.12-201.fc44' -> ('7.0.12', '201.fc44').""" + version, release = nvr.rsplit("-", 1) + return version, release + + +def release_number(rel): + m = re.search(r"\.fc(\d+)", rel) + if not m: + raise SystemExit(f"cannot derive Fedora release from '{rel}'") + return m.group(1) + + +def signed_base(srcname, version, release, sigkey): + # kojipkgs/packages/ serves the UNSIGNED build; the GPG-signed copies live under + # data/signed//. That is the only path whose RPMs carry a Fedora signature. + return f"{KOJI}/{srcname}/{version}/{release}/data/signed/{sigkey}" + + +def download_verify_sha256(binrpm, srcname, nvr, arch, keyfile, sigkey): + """Download one signed RPM from Koji, GPG-verify it, return its sha256 hex.""" + version, release = split_nvr(nvr) + fname = f"{binrpm}-{nvr}.{arch}.rpm" + url = f"{signed_base(srcname, version, release, sigkey)}/{arch}/{fname}" + with tempfile.TemporaryDirectory() as td: + path = os.path.join(td, fname) + print(f" fetching {url}") + with urllib.request.urlopen(url, timeout=120) as r, open(path, "wb") as f: + h = hashlib.sha256() + for chunk in iter(lambda: r.read(1 << 20), b""): + f.write(chunk) + h.update(chunk) + subprocess.run([os.path.join(HERE, "verify-rpm-gpg.sh"), path, keyfile], check=True) + return h.hexdigest() + + +def main(): + ap = argparse.ArgumentParser(description="Regenerate fedora-deps.mk (GPG-verified kernel + stub pins).") + ap.add_argument("--fcos", help="Fedora CoreOS build version, e.g. 44.20260621.3.1") + ap.add_argument("--stream", default="stable", help="FCOS stream (default: stable)") + ap.add_argument("--kernel", help="kernel NVR override, e.g. 7.0.12-201.fc44 (else read from --fcos)") + ap.add_argument("--systemd", help="systemd NVR, e.g. 259.6-1.fc44 (else latest stable via Bodhi)") + args = ap.parse_args() + + if args.kernel: + kernel_nvr = args.kernel + elif args.fcos: + kernel_nvr = kernel_nvr_from_fcos(args.fcos, args.stream) + else: + ap.error("need --fcos or --kernel ") + + release = release_number(split_nvr(kernel_nvr)[1]) + systemd_nvr = args.systemd or systemd_nvr_from_bodhi(release) + + keyfile = os.path.join(HERE, "keys", f"RPM-GPG-KEY-fedora-{release}-primary") + if not os.path.exists(keyfile): + raise SystemExit( + f"missing Fedora {release} signing key: {keyfile}\n" + f"Fetch it from Fedora, verify its fingerprint against the value published at " + f"https://fedoraproject.org/security/, and commit it (see keys/README.md)." + ) + + sigkey = key_short_id(keyfile) + print(f"kernel : {kernel_nvr} (FCOS {args.fcos or 'n/a'})") + print(f"systemd : {systemd_nvr}") + print(f"key : {keyfile} (signed/{sigkey})") + + nvr_for = {"kernel": kernel_nvr, "systemd": systemd_nvr} + shas = {} + for stem, binrpm, srcname in PACKAGES: + for arch in ARCHES: + print(f"[{stem}_{arch}]") + shas[f"{stem}_SHA256_{arch}"] = download_verify_sha256( + binrpm, srcname, nvr_for[srcname], arch, keyfile, sigkey) + + kver, krel = split_nvr(kernel_nvr) + sver, srel = split_nvr(systemd_nvr) + out = os.path.join(HERE, "fedora-deps.mk") + with open(out, "w") as f: + f.write( + "# GENERATED by update-fedora-deps.py -- do not hand-edit.\n" + f"# Bump with: make update-fedora-deps FCOS= [SYSTEMD=]\n" + f"# Kernel from Fedora CoreOS; systemd-boot stub from Fedora {release}. All RPMs are\n" + f"# GPG-verified against keys/RPM-GPG-KEY-fedora-{release}-primary at bump time and every build.\n\n" + f"FEDORA_RELEASE := {release}\n" + f"FEDORA_KERNEL_VERSION := {kernel_nvr}\n" + f"FEDORA_KERNEL_BASE_URL := {signed_base('kernel', kver, krel, sigkey)}\n" + f"SYSTEMD_BOOT_VERSION := {systemd_nvr}\n" + f"SYSTEMD_BOOT_BASE_URL := {signed_base('systemd', sver, srel, sigkey)}\n\n") + for stem, _, _ in PACKAGES: + for arch in ARCHES: + f.write(f"{stem}_SHA256_{arch} := {shas[f'{stem}_SHA256_{arch}']}\n") + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/tools/build-uki/verify-rpm-gpg.sh b/tools/build-uki/verify-rpm-gpg.sh new file mode 100755 index 0000000..6b0b5a6 --- /dev/null +++ b/tools/build-uki/verify-rpm-gpg.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Verify an RPM's GPG signature against a pinned Fedora key, hermetically (throwaway rpmdb, so the +# host/container keyring is never touched). Exits non-zero unless the RPM carries a valid signature +# made by exactly the given key. Used by both download-verify-rpm.sh (build time) and +# update-fedora-deps.py (bump time), so the trust check lives in one place. +set -euo pipefail + +RPM="${1:-}" +KEY="${2:-}" +if [ -z "$RPM" ] || [ -z "$KEY" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +[ -f "$RPM" ] || { echo "no such rpm: $RPM" >&2; exit 2; } +[ -f "$KEY" ] || { echo "no such gpg key: $KEY" >&2; exit 2; } + +DB="$(mktemp -d)" +trap 'rm -rf "$DB"' EXIT + +rpm --dbpath="$DB" --import "$KEY" +out="$(rpm --dbpath="$DB" -Kv "$RPM" 2>&1)" +echo "$out" + +# NOKEY = signed but not by our imported key; BAD / NOT OK = tampered or bad signature. +if echo "$out" | grep -qiE 'NOKEY|BAD|NOT OK'; then + echo "GPG verify FAILED (untrusted or bad signature): $RPM" >&2 + exit 1 +fi +# Require at least one actual signature line to report OK (a digest-only "OK" is not enough). +if ! echo "$out" | grep -qiE 'signature.*key id.*: *OK'; then + echo "GPG verify FAILED (no valid signature by the pinned key): $RPM" >&2 + exit 1 +fi