From d501f22165868afdf56e8a85555f2548d9f9a932 Mon Sep 17 00:00:00 2001 From: HarryR <303926+HarryR@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:33:11 +0000 Subject: [PATCH] Add stage0: measured kernel-less UEFI network bootloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stage0 is the kernel-less sibling of stage1: a pure-UEFI application the firmware boots directly, which fetches a `_stage0` user-data document from the cloud metadata service, downloads a UEFI payload over the network, measures it into the TPM (PCR 14), and chain-loads it — no Linux kernel in the chain. Key design points: - Reuses vaportpm-attest unchanged via its TpmTransport seam (git dep); a Tcg2Transport ships raw TPM commands over EFI_TCG2_PROTOCOL.SubmitCommand (src/tcg2.rs), so the same pcr_extend used on Linux runs here. - Payload download uses raw EFI_TCP4 (src/tcp4.rs), because OVMF/EDK2 HttpDxe won't drain a multi-segment response body; hostname URLs resolve over EFI_DNS4 (src/dns4.rs). Metadata still uses EFI_HTTP at fixed link-local IPs. - Two admission policies (src/config.rs, src/sig.rs): pinned sha256, or an ed25519 detached signature (.sig) verified against a release pubkey pinned in metadata — letting payloads roll forward without editing metadata. - Payloads are not db-signed. stage0 loads them through a temporary EFI_SECURITY2_ARCH_PROTOCOL.FileAuthentication override (src/secauth.rs, shim's security_policy_install trick), so the deployment keeps its ephemeral-key/locked-varstore lockdown while still chain-loading late-bound payloads. Only PCR 14 is measured — the attestation surface is just "stage0 ran and loaded this hash". Build/test (all in the build container; never on the host): make tools/build-stage0//stage0.efi make test-stage0- # builds+signs a test payload, serves it over a # DNS name, boots stage0 end to end under QEMU Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 + Cargo.toml | 5 + Dockerfile.build | 1 + Dockerfile.dev | 1 + Makefile | 82 ++++- README.md | 1 + crates/stage0-test-payload/Cargo.lock | 272 ++++++++++++++++ crates/stage0-test-payload/Cargo.toml | 24 ++ crates/stage0-test-payload/src/main.rs | 84 +++++ crates/stage0/Cargo.lock | 351 ++++++++++++++++++++ crates/stage0/Cargo.toml | 39 +++ crates/stage0/README.md | 112 +++++++ crates/stage0/src/config.rs | 110 +++++++ crates/stage0/src/dns4.rs | 231 +++++++++++++ crates/stage0/src/http.rs | 430 +++++++++++++++++++++++++ crates/stage0/src/main.rs | 176 ++++++++++ crates/stage0/src/metadata.rs | 108 +++++++ crates/stage0/src/secauth.rs | 168 ++++++++++ crates/stage0/src/sig.rs | 33 ++ crates/stage0/src/tcg2.rs | 70 ++++ crates/stage0/src/tcp4.rs | 410 +++++++++++++++++++++++ tools/build-stage0/build.sh | 112 +++++++ tools/qemu-test/boot.sh | 122 ++++++- 23 files changed, 2936 insertions(+), 11 deletions(-) create mode 100644 crates/stage0-test-payload/Cargo.lock create mode 100644 crates/stage0-test-payload/Cargo.toml create mode 100644 crates/stage0-test-payload/src/main.rs create mode 100644 crates/stage0/Cargo.lock create mode 100644 crates/stage0/Cargo.toml create mode 100644 crates/stage0/README.md create mode 100644 crates/stage0/src/config.rs create mode 100644 crates/stage0/src/dns4.rs create mode 100644 crates/stage0/src/http.rs create mode 100644 crates/stage0/src/main.rs create mode 100644 crates/stage0/src/metadata.rs create mode 100644 crates/stage0/src/secauth.rs create mode 100644 crates/stage0/src/sig.rs create mode 100644 crates/stage0/src/tcg2.rs create mode 100644 crates/stage0/src/tcp4.rs create mode 100755 tools/build-stage0/build.sh diff --git a/.gitignore b/.gitignore index 33a35d4..b68bab4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,10 @@ __pycache__ old .docker user-data.json +user-data.stage0.json +# Ignore stage0 build outputs (x86_64/, aarch64/) and the release key (keys/), +# but keep tools/build-stage0/build.sh tracked. +tools/build-stage0/*/ .bashrc .lesshst +stage0-trace.* diff --git a/Cargo.toml b/Cargo.toml index b53cea5..d41e010 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] members = ["crates/stage1", "crates/example-stage2"] +# stage0 is a UEFI (no_std, *-unknown-uefi) application and cannot be built for +# the musl target this workspace defaults to. It is its own workspace so that +# `cargo build --all` (used to build the Linux stages) does not try to compile +# it for the host/musl target. +exclude = ["crates/stage0", "crates/stage0-test-payload"] resolver = "2" [workspace.package] diff --git a/Dockerfile.build b/Dockerfile.build index f8a0d28..c8a2fb4 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -18,6 +18,7 @@ RUN apt-get -qq update && \ gzip \ kmod \ make \ + openssl \ ovmf \ python3 \ python3-pip \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 83cf1e3..94abb65 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -21,6 +21,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get -qq update && \ dnsmasq \ swtpm swtpm-tools \ tpm2-tools xxd \ + tcpdump \ gh jq python3-venv RUN groupadd -g 1000 vscode-dc && useradd -u 1000 -g 1000 -d /src -s /bin/bash vscode-dc diff --git a/Makefile b/Makefile index 6870de7..ee2d0f0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PRECIOUS: tools/build-uki/keys/% tools/build-uki/% +.PRECIOUS: tools/build-uki/keys/% tools/build-uki/% \ + tools/build-stage0/%/stage0.efi tools/build-stage0/%/payload.efi tools/build-stage0/%/boot.disk all: build @@ -23,6 +24,7 @@ tools/build-uki/keys/%: clean: rm -rf tools/build-uki/x86_64/boot.disk tools/build-uki/x86_64/stage1 tools/build-uki/x86_64/tmp tools/build-uki/x86_64/*.img tools/build-uki/x86_64/*.efi tools/build-uki/x86_64/config-* tools/build-uki/x86_64/efi-vars.ovmf rm -rf tools/build-uki/aarch64/boot.disk tools/build-uki/aarch64/stage1 tools/build-uki/aarch64/tmp tools/build-uki/aarch64/*.img tools/build-uki/aarch64/*.efi tools/build-uki/aarch64/config-* tools/build-uki/aarch64/efi-vars.ovmf + rm -rf tools/build-stage0/x86_64 tools/build-stage0/aarch64 distclean: clean $(MAKE) -C tools/build-uki clean @@ -149,6 +151,84 @@ tools/build-uki/%/stage1: docker-build-base cp target/$*-unknown-linux-musl/release/stage1 $@ +##################################################################### +# stage0 (pure-UEFI network bootloader) + +STAGE0_DIR = crates/stage0 + +# Guard the arch-less forms: without these, `make boot-stage0` would match the +# generic `boot-%` pattern (stem "stage0") and try to build a UKI for a bogus +# architecture named "stage0". Require an explicit arch suffix instead. +.PHONY: stage0 boot-stage0 test-stage0 +stage0 boot-stage0 test-stage0: + @echo "'$@' needs an architecture suffix, e.g. 'make $@-x86_64' or 'make $@-aarch64'." >&2 + @exit 2 + +# Build the stage0 UEFI binary inside the build container. Same model as stage1: +# cargo runs in the container (never the host) and vaportpm is pulled from git, +# so only this repo is mounted. +tools/build-stage0/%/stage0.efi: docker-build-base + mkdir -p tools/build-stage0/$* + $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ + bash -c "rustup target add $*-unknown-uefi && cargo build --release --manifest-path $(STAGE0_DIR)/Cargo.toml --target $*-unknown-uefi" + cp $(STAGE0_DIR)/target/$*-unknown-uefi/release/stage0.efi $@ + +# Assemble + sign the stage0 boot disk (losetup/mount -> privileged container). +tools/build-stage0/%/boot.disk: tools/build-stage0/%/stage0.efi tools/build-uki/keys/db.crt + $(DOCKER_RUN) -e ARCH=$* $(BUILD_IMAGE) ./tools/build-stage0/build.sh + +stage0-amd64 stage0-x86_64: tools/build-stage0/x86_64/boot.disk +stage0-arm64 stage0-aarch64: tools/build-stage0/aarch64/boot.disk + +# Boot stage0 under QEMU. Pass PAYLOAD=path/to/payload.efi (repo-relative) to +# serve a local UEFI payload at http://10.0.2.1:8000/payload.efi; otherwise +# point user-data.stage0.json at any URL reachable from the guest. +# Set TRACE=1 to capture the guest TCP conversation to stage0-trace.txt (needs +# the dev image rebuilt for tcpdump: 'make docker-build-dev'). +boot-stage0-%: tools/qemu-test/ec2-metadata-mock-linux-amd64 tools/build-stage0/%/boot.disk user-data.stage0.json + $(DOCKER_RUN) $(DOCKER_OPT_KVM) \ + -e YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1 --cap-add=NET_ADMIN --device=/dev/net/tun \ + $(DEV_IMAGE) ./tools/qemu-test/boot.sh --kind stage0 --arch $* $(if $(PAYLOAD),--payload $(PAYLOAD)) $(if $(TRACE),--trace) + +# Long-term ed25519 release signing key for stage0 "signed mode". This is the +# vendor key that signs payloads; it never touches a deployed machine — stage0 +# only ever sees the *public* key, pinned in the metadata doc. Generated once in +# the build container (gitignored). release.pub.b64 is the raw 32-byte public +# key, base64-encoded, ready to drop straight into the _stage0 `ed25519` field. +tools/build-stage0/keys/release.pem: docker-build-base + mkdir -p tools/build-stage0/keys + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ + openssl genpkey -algorithm ed25519 -out tools/build-stage0/keys/release.pem && \ + openssl pkey -in tools/build-stage0/keys/release.pem -pubout -outform DER \ + | tail -c 32 | base64 -w0 > tools/build-stage0/keys/release.pub.b64" + +# Build the end-to-end test payload (a chain-loaded UEFI app that reads PCRs) and +# attach a detached ed25519 signature (payload.efi.sig) made with the release +# key. The payload is NOT Secure Boot db-signed: stage0 verifies the signature +# against the pinned pubkey and loads it via a FileAuthentication override. +# Hostname (not an IP literal) so the end-to-end test also exercises EFI_DNS4; +# boot.sh maps payload.lockboot.test -> 10.0.2.1 in the QEMU DNS. Override with +# PAYLOAD_URL=http://10.0.2.1:8000/payload.efi to skip DNS. +PAYLOAD_URL ?= http://payload.lockboot.test:8000/payload.efi +tools/build-stage0/%/payload.efi: docker-build-base tools/build-stage0/keys/release.pem + mkdir -p tools/build-stage0/$* + $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ + bash -c "rustup target add $*-unknown-uefi && \ + cargo build --release --manifest-path crates/stage0-test-payload/Cargo.toml --target $*-unknown-uefi && \ + cp crates/stage0-test-payload/target/$*-unknown-uefi/release/stage0-test-payload.efi $@ && \ + openssl pkeyutl -sign -inkey tools/build-stage0/keys/release.pem -rawin -in $@ -out $@.sig" + +# One-shot end-to-end test: build + sign the payload, pin the release pubkey into +# a _stage0 user-data doc (signed mode), then boot stage0 serving the payload +# and its detached .sig locally over HTTP. +test-stage0-%: tools/build-stage0/%/payload.efi tools/build-stage0/%/boot.disk tools/qemu-test/ec2-metadata-mock-linux-amd64 + @PUB=$$(cat tools/build-stage0/keys/release.pub.b64); \ + printf '{\n "_stage0": {\n "%s": { "url": "%s", "ed25519": "%s" }\n }\n}\n' \ + "$*" "$(PAYLOAD_URL)" "$$PUB" > user-data.stage0.json; \ + echo "Wrote user-data.stage0.json (signed mode, release pubkey $$PUB)" + $(MAKE) boot-stage0-$* PAYLOAD=tools/build-stage0/$*/payload.efi TRACE=$(TRACE) + + ##################################################################### # Git tagging helpers diff --git a/README.md b/README.md index d1fe260..afda676 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ You can run any statically linked Linux ELF, but the minimal filesystem only has ## Components +- **[stage0](crates/stage0/README.md)**: Kernel-less UEFI netboot loader (downloads + measures + chain-loads a UEFI payload) - **[stage1](crates/stage1/README.md)**: Secure bootloader (fetches config, verifies binaries, extends PCRs) - **[example-stage2](crates/example-stage2/README.md)**: Example user application - **[vaportpm](https://github.com/lockboot/vaportpm)**: TPM 2.0 attestation library (external dependency) diff --git a/crates/stage0-test-payload/Cargo.lock b/crates/stage0-test-payload/Cargo.lock new file mode 100644 index 0000000..2343d7f --- /dev/null +++ b/crates/stage0-test-payload/Cargo.lock @@ -0,0 +1,272 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "stage0-test-payload" +version = "0.1.0" +dependencies = [ + "anyhow", + "hex", + "uefi", + "vaportpm-attest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucs2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" +dependencies = [ + "bit_field", +] + +[[package]] +name = "uefi" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7569ceafb898907ff764629bac90ac24ba4203c38c33ef79ee88c74aa35b11" +dependencies = [ + "bitflags", + "cfg-if", + "log", + "ptr_meta", + "ucs2", + "uefi-macros", + "uefi-raw", + "uguid", +] + +[[package]] +name = "uefi-macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3dad47b3af8f99116c0f6d4d669c439487d9aaf1c8d9480d686cda6f3a8aa23" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uefi-raw" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cad96b8baaf1615d3fdd0f03d04a0b487d857c1b51b19dcbfe05e2e3c447b78" +dependencies = [ + "bitflags", + "uguid", +] + +[[package]] +name = "uguid" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab14ea9660d240e7865ce9d54ecdbd1cd9fa5802ae6f4512f093c7907e921533" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vaportpm-attest" +version = "0.1.0" +source = "git+https://github.com/lockboot/vaportpm#15770b11a477de3cb92207a76ae27a43ece695de" +dependencies = [ + "anyhow", + "hex", + "hmac", + "sha1", + "sha2", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/crates/stage0-test-payload/Cargo.toml b/crates/stage0-test-payload/Cargo.toml new file mode 100644 index 0000000..764aa7f --- /dev/null +++ b/crates/stage0-test-payload/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "stage0-test-payload" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +# Standalone workspace (UEFI-only), like stage0. +[workspace] + +[[bin]] +name = "stage0-test-payload" +path = "src/main.rs" + +[dependencies] +uefi = { version = "0.35", features = ["alloc", "global_allocator", "panic_handler"] } +vaportpm-attest = { git = "https://github.com/lockboot/vaportpm", default-features = false } +anyhow = { version = "1.0", default-features = false } +hex = { version = "0.4", default-features = false, features = ["alloc"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/crates/stage0-test-payload/src/main.rs b/crates/stage0-test-payload/src/main.rs new file mode 100644 index 0000000..a7e274e --- /dev/null +++ b/crates/stage0-test-payload/src/main.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! A trivial UEFI payload for exercising the stage0 netboot path end to end. +//! +//! When chain-loaded by stage0 it prints a banner and reads back PCR 14 (the +//! payload measurement) over `EFI_TCG2_PROTOCOL`, proving the +//! measure-then-execute flow worked. PCR 15 is read too and should be all-zero, +//! confirming stage0 measures only the binary. A real payload would instead set +//! up and boot its own OS. + +#![no_std] +#![no_main] + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::vec; +use alloc::vec::Vec; +use anyhow::{anyhow, bail, Result}; +use uefi::boot::{self, ScopedProtocol}; +use uefi::prelude::*; +use uefi::println; +use uefi::proto::tcg::v2::Tcg; +use vaportpm_attest::{PcrOps, TpmAlg, TpmTransport}; + +struct Tcg2Transport { + tcg: ScopedProtocol, + max_response_size: usize, +} + +impl TpmTransport for Tcg2Transport { + fn transmit_raw(&mut self, command: &[u8]) -> Result> { + let mut out = vec![0u8; self.max_response_size.max(64)]; + self.tcg + .submit_command(command, &mut out) + .map_err(|e| anyhow!("SubmitCommand failed: {:?}", e.status()))?; + if out.len() < 10 { + bail!("short TPM response"); + } + let size = u32::from_be_bytes([out[2], out[3], out[4], out[5]]) as usize; + if size < 10 || size > out.len() { + bail!("bad TPM response size {}", size); + } + out.truncate(size); + Ok(out) + } +} + +#[entry] +fn main() -> Status { + uefi::helpers::init().unwrap(); + println!("payload: hello from the chain-loaded UEFI payload"); + + match print_pcrs() { + Ok(()) => {} + Err(e) => println!("payload: could not read PCRs: {e}"), + } + + // A real payload would ExitBootServices and boot an OS here. + println!("payload: done"); + boot::stall(5_000_000); + Status::SUCCESS +} + +fn print_pcrs() -> Result<()> { + let handle = boot::get_handle_for_protocol::() + .map_err(|e| anyhow!("no EFI_TCG2_PROTOCOL: {:?}", e.status()))?; + let mut tcg = boot::open_protocol_exclusive::(handle) + .map_err(|e| anyhow!("open EFI_TCG2_PROTOCOL: {:?}", e.status()))?; + let max_response_size = tcg + .get_capability() + .map_err(|e| anyhow!("get_capability: {:?}", e.status()))? + .max_response_size as usize; + + let mut tpm = vaportpm_attest::Tpm::with_transport(Box::new(Tcg2Transport { + tcg, + max_response_size, + })); + + for (idx, value) in tpm.pcr_read_bank(&[14, 15], TpmAlg::Sha256)? { + println!("payload: PCR{idx} (sha256) = {}", hex::encode(value)); + } + Ok(()) +} diff --git a/crates/stage0/Cargo.lock b/crates/stage0/Cargo.lock new file mode 100644 index 0000000..80bddfe --- /dev/null +++ b/crates/stage0/Cargo.lock @@ -0,0 +1,351 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519-compact" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5c0284a5d4b1a2fae017a9fe55fd7d01699711f1b572493f16593e173ea2801" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "stage0" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "ed25519-compact", + "hex", + "serde", + "serde_json", + "sha2", + "uefi", + "uefi-raw", + "vaportpm-attest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucs2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" +dependencies = [ + "bit_field", +] + +[[package]] +name = "uefi" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7569ceafb898907ff764629bac90ac24ba4203c38c33ef79ee88c74aa35b11" +dependencies = [ + "bitflags", + "cfg-if", + "log", + "ptr_meta", + "ucs2", + "uefi-macros", + "uefi-raw", + "uguid", +] + +[[package]] +name = "uefi-macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3dad47b3af8f99116c0f6d4d669c439487d9aaf1c8d9480d686cda6f3a8aa23" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uefi-raw" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cad96b8baaf1615d3fdd0f03d04a0b487d857c1b51b19dcbfe05e2e3c447b78" +dependencies = [ + "bitflags", + "uguid", +] + +[[package]] +name = "uguid" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab14ea9660d240e7865ce9d54ecdbd1cd9fa5802ae6f4512f093c7907e921533" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vaportpm-attest" +version = "0.1.0" +source = "git+https://github.com/lockboot/vaportpm#15770b11a477de3cb92207a76ae27a43ece695de" +dependencies = [ + "anyhow", + "hex", + "hmac", + "sha1", + "sha2", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/stage0/Cargo.toml b/crates/stage0/Cargo.toml new file mode 100644 index 0000000..7f4da44 --- /dev/null +++ b/crates/stage0/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "stage0" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +# Standalone workspace: stage0 only builds for *-unknown-uefi, so it is kept out +# of the parent lockboot workspace (which defaults to a musl target). +[workspace] + +[dependencies] +# UEFI runtime: entry point, boot services, protocols (HTTP, TCG2, LoadImage). +uefi = { version = "0.35", features = ["alloc", "global_allocator", "panic_handler"] } +uefi-raw = "0.11" + +# no_std TPM core — provides TpmTransport + PCR ops over our EFI_TCG2 transport. +# Consumed from git like stage1 (the build never sees a local vaportpm checkout). +vaportpm-attest = { git = "https://github.com/lockboot/vaportpm", default-features = false } + +# no_std JSON + hashing for the metadata doc and payload integrity check. +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +sha2 = { version = "0.10", default-features = false, features = ["force-soft"] } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } + +# ed25519 signature verification for "signed mode" payloads. no_std, verify-only +# (no `random`/`std`): the release public key is pinned in the metadata doc and +# the detached .sig is fetched alongside the payload. +ed25519-compact = { version = "2", default-features = false } + +# Error type at the TpmTransport boundary (vaportpm-attest uses anyhow::Result). +anyhow = { version = "1.0", default-features = false } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/crates/stage0/README.md b/crates/stage0/README.md new file mode 100644 index 0000000..bc210b4 --- /dev/null +++ b/crates/stage0/README.md @@ -0,0 +1,112 @@ +# stage0 — measured UEFI network bootloader + +`stage0` is a pure-UEFI application (no Linux kernel) that the firmware boots +directly. It downloads and chain-loads another **UEFI** binary over the network, +measuring it into the TPM first. It is the kernel-less sibling of `stage1`: +same metadata-driven, measure-then-execute model, but living entirely in UEFI +boot services. + +## Flow + +1. Bring up the NIC via `EFI_IP4_CONFIG2` (DHCP). +2. Fetch a `_stage0` user-data document from the cloud metadata service over + `EFI_HTTP_PROTOCOL` (EC2 IMDSv2 → GCP → Azure → Aliyun, mirroring `stage1`). +3. Download the per-arch UEFI payload from the pinned URL over raw `EFI_TCP4` + (`src/tcp4.rs`); a hostname URL is resolved via `EFI_DNS4` (`src/dns4.rs`). + Metadata uses `EFI_HTTP` at fixed link-local IPs; the payload uses TCP4. +4. **Admit** the payload by one of two policies (see "Admission & trust"): + - **sha256 mode** — the payload's SHA-256 must equal the value pinned in the + metadata (immutable payload). + - **signed mode** — a detached ed25519 signature fetched from `.sig` must + verify against a long-term release **public key** pinned in the metadata + (the payload can roll forward without editing metadata). +5. Measure into the TPM via `EFI_TCG2_PROTOCOL`: **PCR 14** ← SHA-256(payload). + Nothing else is measured — see "Admission & trust". +6. `LoadImage` (from the memory buffer, via a temporary `FileAuthentication` + override) + `StartImage` to chain-load. + +Integrity/authenticity comes from the pinned hash or signature, so plain HTTP is +used (no reliance on the inconsistently-available `EFI_TLS_PROTOCOL`). + +## Admission & trust + +The attestation surface is deliberately minimal: **the only thing measured is +PCR 14** — "stage0 ran, and it loaded a binary with this hash." The config, the +pinned hash, the release key and the signature are *not* measured. A verifier +just checks PCR 14 against the set of approved release hashes; it does not have +to model the metadata document or key material. (This is why PCR 15 — the config +measurement `stage1` does — is intentionally dropped here.) + +The signature/hash is **admission control only**: it decides whether stage0 is +*willing* to load a payload, not what gets attested. Signed mode exists so a +deployment can pin a long-term release key once and let new builds roll forward +under that key without touching VM metadata; the private key stays offline with +the publisher and never reaches a deployed machine. + +Because the payload is admitted by stage0's own policy rather than the firmware +`db`, stage0 chain-loads it through a temporary **security-arch override** +(`secauth.rs`): it swaps `EFI_SECURITY2_ARCH_PROTOCOL.FileAuthentication` for an +allow-all across a single `LoadImage`, then restores it — exactly shim's +`security_policy_install()`. The firmware still does all real PE loading and +relocation; only the *verdict* is replaced. This is what lets the deployment +keep its lockdown model (a per-release, ephemeral `db` key that signs `stage0` +itself and is then destroyed, with the variable store locked) **and** still +chain-load late-bound payloads — the two are otherwise mutually exclusive, since +an ephemeral, destroyed key cannot sign a payload fetched at boot. + +Note this makes `stage0` a trust anchor *with policy*, not merely a measurer: +it is itself `db`-signed and measured, and everything it loads is measured into +PCR 14, so the chain stays attestable end to end. + +## `_stage0` metadata schema + +Each arch entry carries a `url` plus **exactly one** of `sha256` (pin an exact +hash) or `ed25519` (pin a base64 release public key; the detached signature is +fetched from `.sig`): + +```json +{ + "_stage0": { + "args": ["optional", "load-options"], + "x86_64": { "url": "http://…/payload.efi", "sha256": "<64 hex>" }, + "aarch64": { "url": "http://…/payload.efi", "ed25519": "" } + } +} +``` + +## TPM access + +`stage0` reuses `vaportpm-attest` unchanged — that crate funnels all TPM I/O +through its `TpmTransport` trait, so `stage0` supplies a `Tcg2Transport` backed +by `EFI_TCG2_PROTOCOL.SubmitCommand` (`src/tcg2.rs`) and calls the same +`pcr_extend` used on Linux. `stage0` only *measures*; the chained payload (or a +later Linux stage) produces the actual TPM2_Quote. Build the crate with +`--no-default-features` (no_std) for UEFI targets. + +## Build & test + +```sh +# Build the stage0 .efi (in the build container; vaportpm pulled from git) +make tools/build-stage0/x86_64/stage0.efi # or aarch64 + +# Assemble + sign the bootable ESP disk (privileged: losetup/mount) +make tools/build-stage0/x86_64/boot.disk + +# End-to-end under QEMU: builds + ed25519-signs the test payload, pins the +# release pubkey into user-data.stage0.json (signed mode), serves the payload +# and its .sig locally (via a DNS name, exercising EFI_DNS4), and boots stage0. +make test-stage0-x86_64 + +# Or boot an already-built disk, choosing what to boot: +./tools/qemu-test/boot.sh --kind stage0 --arch x86_64 \ + --payload tools/build-stage0/x86_64/payload.efi +./tools/qemu-test/boot.sh --help +``` + +Always include the arch suffix: `make boot-stage0` (no arch) is **not** a target +— it would be misread as a UKI build for an architecture literally named +"stage0". + +The test payload (`crates/stage0-test-payload`) is a trivial chain-loaded UEFI +app that prints a banner and reads back PCR 14/15, confirming the +measure-then-execute path end to end. diff --git a/crates/stage0/src/config.rs b/crates/stage0/src/config.rs new file mode 100644 index 0000000..c4e5411 --- /dev/null +++ b/crates/stage0/src/config.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The `_stage0` metadata schema. +//! +//! Mirrors `stage1`'s per-arch `{url, sha256}` structure (plus optional `args`) +//! but under a distinct `_stage0` key, so a UEFI payload is never confused with +//! a Linux `_stage2` binary in the same document. + +use alloc::string::String; +use alloc::vec::Vec; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct UserData { + #[serde(rename = "_stage0")] + pub stage0: Stage0Config, +} + +#[derive(Debug, Deserialize)] +pub struct Stage0Config { + #[serde(default)] + pub args: Option>, + // Exactly one of these is read per build (see `for_this_arch`); the other + // is still deserialized so a single multi-arch document works everywhere. + #[cfg_attr(not(target_arch = "aarch64"), allow(dead_code))] + #[serde(default)] + pub aarch64: Option, + #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))] + #[serde(default)] + pub x86_64: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ArchConfig { + pub url: String, + // Exactly one of these selects the verification mode (see `verify`): + // sha256 → pin an exact hash (immutable payload). + // ed25519 → pin a long-term release pubkey (base64); the payload may roll + // forward without editing metadata, gated by a detached `.sig`. + #[serde(default)] + pub sha256: Option, + #[serde(default)] + pub ed25519: Option, +} + +/// How stage0 admits the downloaded payload before measuring + loading it. +pub enum Verify { + /// Payload's SHA-256 must equal this 64-hex string. + Sha256(String), + /// Detached ed25519 signature (`.sig`) must verify against this + /// base64-encoded 32-byte release public key. + Ed25519(String), +} + +impl Stage0Config { + /// The config entry for the architecture stage0 was built for. + #[must_use] + pub fn for_this_arch(&self) -> Option<&ArchConfig> { + #[cfg(target_arch = "x86_64")] + { + self.x86_64.as_ref() + } + #[cfg(target_arch = "aarch64")] + { + self.aarch64.as_ref() + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + None + } + } +} + +impl ArchConfig { + /// Validate the URL and the (single) verification field, returning the + /// selected [`Verify`] mode. + pub fn validate(&self) -> Result { + if !(self.url.starts_with("http://") || self.url.starts_with("https://")) { + return Err("url must start with http:// or https://"); + } + if !self.url.chars().all(|c| c.is_ascii_graphic()) { + return Err("url must contain only printable ASCII"); + } + match (&self.sha256, &self.ed25519) { + (Some(_), Some(_)) => Err("specify only one of sha256 / ed25519"), + (None, None) => Err("must specify one of sha256 / ed25519"), + (Some(hex), None) => { + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("sha256 must be exactly 64 hex characters"); + } + Ok(Verify::Sha256(hex.clone())) + } + (None, Some(pubkey)) => { + // A raw ed25519 public key is 32 bytes. + match STANDARD.decode(pubkey.trim()) { + Ok(bytes) if bytes.len() == 32 => Ok(Verify::Ed25519(pubkey.clone())), + Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), + Err(_) => Err("ed25519 pubkey must be base64"), + } + } + } + } +} + +/// Parse the user-data JSON into a [`UserData`]. +pub fn parse(json: &[u8]) -> Result { + serde_json::from_slice(json).map_err(|_| "invalid JSON or missing _stage0 key") +} diff --git a/crates/stage0/src/dns4.rs b/crates/stage0/src/dns4.rs new file mode 100644 index 0000000..8dbccbf --- /dev/null +++ b/crates/stage0/src/dns4.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Hostname resolution over `EFI_DNS4_PROTOCOL`. +//! +//! Metadata is reached at fixed link-local IPs, but a payload URL may name a +//! host (e.g. an S3/GCS bucket). `tcp4::download` calls [`resolve`] for any +//! non-literal host, turning it into an IPv4 address before the TCP connect. +//! +//! The DNS server list is taken from DHCP (`UseDefaultSetting = TRUE`), the same +//! lease `http.rs` established to fetch metadata. `uefi-raw` 0.11 does not expose +//! DNS4, so the FFI bindings (UEFI spec, EFI_DNS4_PROTOCOL) are defined here. + +use core::ffi::c_void; +use core::ptr; + +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; +use uefi::proto::unsafe_protocol; +use uefi::{println, CString16, Status}; +use uefi_raw::protocol::driver::ServiceBindingProtocol; +use uefi_raw::{Boolean, Event, Ipv4Address}; + +// ---- EFI_DNS4_PROTOCOL FFI (UEFI spec) ---- + +#[repr(C)] +struct Dns4ConfigData { + dns_server_list_count: usize, + dns_server_list: *mut Ipv4Address, + use_default_setting: Boolean, + enable_dns_cache: Boolean, + protocol: u8, + station_ip: Ipv4Address, + subnet_mask: Ipv4Address, + local_port: u16, + retry_count: u32, + retry_interval: u32, +} + +#[repr(C)] +struct Dns4CompletionToken { + event: Event, + status: Status, + retry_count: u32, + retry_interval: u32, + // Union of response pointers; for HostNameToIp it is the H2AData pointer. + rsp_data: *mut Dns4HostToAddrData, +} + +#[repr(C)] +struct Dns4HostToAddrData { + ip_count: u32, + ip_list: *mut Ipv4Address, +} + +/// `EFI_DNS4_PROTOCOL` method table. Unused slots keep the spec order/size but +/// a placeholder signature (never invoked). +#[repr(C)] +struct Dns4Protocol { + get_mode_data: unsafe extern "efiapi" fn() -> Status, + configure: unsafe extern "efiapi" fn(*mut Dns4Protocol, *const Dns4ConfigData) -> Status, + host_name_to_ip: unsafe extern "efiapi" fn( + *mut Dns4Protocol, + *const u16, + *mut Dns4CompletionToken, + ) -> Status, + ip_to_host_name: unsafe extern "efiapi" fn() -> Status, + general_lookup: unsafe extern "efiapi" fn() -> Status, + update_dns_cache: unsafe extern "efiapi" fn() -> Status, + poll: unsafe extern "efiapi" fn(*mut Dns4Protocol) -> Status, + cancel: unsafe extern "efiapi" fn() -> Status, +} + +#[unsafe_protocol("b625b186-e063-44f7-8905-6a74dc6f52b4")] +struct Dns4Sb(ServiceBindingProtocol); + +#[unsafe_protocol("ae3d28cc-e05b-4fa1-a011-7eb55a3f1401")] +struct Dns4(Dns4Protocol); + +/// EFI_IP_PROTO_UDP — DNS queries ride UDP. +const IP_PROTO_UDP: u8 = 17; + +unsafe fn pump(dns: *mut Dns4Protocol, status: *const Status, budget_ms: u32) -> Status { + let mut waited = 0; + loop { + let s = ptr::read_volatile(status); + if s != Status::NOT_READY { + return s; + } + let _ = ((*dns).poll)(dns); + boot::stall(1000); + waited += 1; + if waited >= budget_ms { + return Status::TIMEOUT; + } + } +} + +fn new_event() -> Result { + unsafe { + boot::create_event( + uefi::boot::EventType::empty(), + uefi::boot::Tpl::CALLBACK, + None, + None, + ) + } + .map(|e| e.as_ptr()) + .map_err(|e| e.status()) +} + +/// Resolve `host` to an IPv4 address using the DHCP-provided DNS servers. +pub fn resolve(host: &str) -> Result<[u8; 4], Status> { + let sb_handle = boot::get_handle_for_protocol::().map_err(|e| { + println!("stage0: no EFI_DNS4 service binding: {:?}", e.status()); + e.status() + })?; + let mut sb = unsafe { + boot::open_protocol::( + OpenProtocolParams { + handle: sb_handle, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + .map_err(|e| e.status())? + }; + + let mut child: uefi_raw::Handle = ptr::null_mut(); + let st = unsafe { (sb.0.create_child)(&mut sb.0, &mut child) }; + if st != Status::SUCCESS { + println!("stage0: EFI_DNS4 create_child failed: {st:?}"); + return Err(st); + } + let child_handle = unsafe { uefi::Handle::from_ptr(child).ok_or(Status::DEVICE_ERROR)? }; + + let result = resolve_on_child(child_handle, host); + + let _ = unsafe { (sb.0.destroy_child)(&mut sb.0, child) }; + result +} + +fn resolve_on_child(child: uefi::Handle, host: &str) -> Result<[u8; 4], Status> { + let mut dns = unsafe { + boot::open_protocol::( + OpenProtocolParams { + handle: child, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + .map_err(|e| e.status())? + }; + let dns_ptr: *mut Dns4Protocol = &mut dns.0; + + // Configure with the DHCP-obtained DNS server list (same lease as metadata). + let cfg = Dns4ConfigData { + dns_server_list_count: 0, + dns_server_list: ptr::null_mut(), + use_default_setting: Boolean::from(true), + enable_dns_cache: Boolean::from(false), + protocol: IP_PROTO_UDP, + station_ip: Ipv4Address([0, 0, 0, 0]), + subnet_mask: Ipv4Address([0, 0, 0, 0]), + local_port: 0, + retry_count: 2, + retry_interval: 0, + }; + let st = unsafe { ((*dns_ptr).configure)(dns_ptr, &cfg) }; + if st != Status::SUCCESS { + println!("stage0: EFI_DNS4 configure failed: {st:?} (no DHCP-provided DNS server?)"); + return Err(st); + } + + let name = CString16::try_from(host).map_err(|_| Status::INVALID_PARAMETER)?; + let event = new_event()?; + let mut token = Dns4CompletionToken { + event, + status: Status::NOT_READY, + retry_count: 0, + retry_interval: 0, + rsp_data: ptr::null_mut(), + }; + let call = unsafe { ((*dns_ptr).host_name_to_ip)(dns_ptr, name.as_ptr().cast(), &mut token) }; + let st = if call == Status::SUCCESS { + unsafe { pump(dns_ptr, &token.status, 10_000) } + } else { + call + }; + let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; + + // Reset the instance regardless of outcome. + let _ = unsafe { ((*dns_ptr).configure)(dns_ptr, ptr::null()) }; + + if st != Status::SUCCESS { + println!("stage0: EFI_DNS4 HostNameToIp({host}) failed: {st:?}"); + return Err(st); + } + + let h2a = token.rsp_data; + if h2a.is_null() { + println!("stage0: EFI_DNS4 returned no response data for {host}"); + return Err(Status::DEVICE_ERROR); + } + let ip = unsafe { + let data = &*h2a; + if data.ip_count == 0 || data.ip_list.is_null() { + free_h2a(h2a); + println!("stage0: EFI_DNS4 found no addresses for {host}"); + return Err(Status::NOT_FOUND); + } + (*data.ip_list).0 + }; + unsafe { free_h2a(h2a) }; + println!( + "stage0: resolved {host} -> {}.{}.{}.{}", + ip[0], ip[1], ip[2], ip[3] + ); + Ok(ip) +} + +/// Free the driver-allocated response data (the IP list and the struct itself). +unsafe fn free_h2a(h2a: *mut Dns4HostToAddrData) { + let data = &*h2a; + if let Some(p) = ptr::NonNull::new(data.ip_list.cast::()) { + let _ = boot::free_pool(p.cast()); + } + if let Some(p) = ptr::NonNull::new(h2a.cast::()) { + let _ = boot::free_pool(p.cast()); + } +} diff --git a/crates/stage0/src/http.rs b/crates/stage0/src/http.rs new file mode 100644 index 0000000..c11e85a --- /dev/null +++ b/crates/stage0/src/http.rs @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Minimal HTTP/1.0 client over `EFI_HTTP_PROTOCOL`. +//! +//! uefi-rs ships an `HttpHelper`, but its request path only emits a `Host` +//! header. The cloud metadata services need custom headers (IMDSv2 token, +//! `Metadata-Flavor`, `Metadata: true`) and the EC2 token handshake needs +//! `PUT`, so we drive the raw `Http` protocol directly. +//! +//! Integrity of downloaded payloads comes from the SHA-256 pinned in the +//! (trusted) metadata document, so plain HTTP is sufficient and we avoid the +//! inconsistently-available `EFI_TLS_PROTOCOL`. + +use alloc::ffi::CString; +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; +use core::ffi::{c_char, c_void, CStr}; + +use uefi::boot::{ + self, EventType, OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, TimerTrigger, Tpl, +}; +use uefi::proto::network::http::{Http, HttpBinding}; +use uefi::proto::network::ip4config2::Ip4Config2; +use uefi::{println, CString16, Event, Handle, Status}; +use uefi_raw::protocol::network::http::{ + HttpAccessPoint, HttpConfigData, HttpHeader, HttpMessage, HttpRequestData, HttpResponseData, + HttpStatusCode, HttpToken, HttpV4AccessPoint, HttpVersion, +}; + +pub use uefi_raw::protocol::network::http::HttpMethod; + +/// Body is read one TCP segment at a time. Packet captures show OVMF's HttpDxe +/// pulls exactly one segment into a `Response()` body buffer and then stalls +/// unless the buffer is full (or Content-Length is reached) — it will not drain +/// further buffered segments within one call. Sizing the buffer to one MSS +/// (1460 = 1500 MTU − 20 IP − 20 TCP) makes each `Response()` fill exactly and +/// complete, so a loop drains the whole body one segment per call. +const CHUNK: usize = 1460; + +/// Per-request timeout reported to the HTTP driver (milliseconds). +const HTTP_TIMEOUT_MS: u32 = 8_000; + +/// Hard wall-clock cap for a single request/response token, in 100ns units. +/// A bit longer than HTTP_TIMEOUT_MS so the driver's own timeout fires first; +/// this only guards against a driver that never completes the token at all. +const POLL_DEADLINE_100NS: u64 = 12 * 10_000_000; // 12 seconds + +/// An HTTP connection bound to one NIC, configured for IPv4 + DHCP. +pub struct HttpClient { + child: Handle, + binding: ScopedProtocol, + // `Option` so the protocol is dropped before we destroy the child handle. + http: Option>, +} + +impl HttpClient { + /// Find a NIC with the HTTP service binding, bring it up via DHCP, and + /// create a configured HTTP protocol instance on it. + pub fn new() -> Result { + // On a fresh boot the firmware often hasn't connected the network stack + // yet, so the HTTP service binding isn't present. Connect all drivers + // first, then locate the binding. + connect_all_controllers(); + + let nic = match boot::get_handle_for_protocol::() { + Ok(h) => h, + Err(e) => { + println!( + "stage0: no EFI_HTTP service binding found ({:?}) -- firmware lacks the HTTP/network stack?", + e.status() + ); + return Err(e.status()); + } + }; + println!("stage0: found HTTP service binding on NIC handle"); + + // Bring the interface up (DHCP). No-op if already up. + { + let mut ip4 = Ip4Config2::new(nic).map_err(|e| e.status())?; + ip4.ifup(true).map_err(|e| { + println!("stage0: DHCP failed: {:?}", e.status()); + e.status() + })?; + } + + let mut binding = unsafe { + boot::open_protocol::( + OpenProtocolParams { + handle: nic, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + .map_err(|e| e.status())? + }; + + let child = binding.create_child().map_err(|e| e.status())?; + + let mut http = unsafe { + boot::open_protocol::( + OpenProtocolParams { + handle: child, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + .map_err(|e| { + let _ = binding.destroy_child(child); + e.status() + })? + }; + + let ip4 = HttpV4AccessPoint { + use_default_addr: true.into(), + ..Default::default() + }; + let config = HttpConfigData { + http_version: HttpVersion::HTTP_VERSION_10, + time_out_millisec: HTTP_TIMEOUT_MS, + local_addr_is_ipv6: false.into(), + access_point: HttpAccessPoint { ipv4_node: &ip4 }, + }; + http.configure(&config).map_err(|e| { + println!("stage0: HTTP configure failed: {:?}", e.status()); + e.status() + })?; + println!("stage0: HTTP protocol configured"); + + Ok(Self { + child, + binding, + http: Some(http), + }) + } + + fn http(&mut self) -> &mut Http { + self.http.as_mut().unwrap() + } + + /// Send one HTTP request (does not read the response). + fn send_request( + &mut self, + method: HttpMethod, + url: &str, + headers: &[(&str, &str)], + ) -> Result<(), Status> { + println!("stage0: HTTP {:?} {}", method, url); + let url16 = CString16::try_from(url).map_err(|_| Status::INVALID_PARAMETER)?; + + // Backing storage for the header C strings; must outlive the request. + // Always send a Host header (servers reject requests without one) unless + // the caller already supplied one. + let mut cstrings: Vec<(CString, CString)> = Vec::with_capacity(headers.len() + 1); + let has_host = headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("host")); + if !has_host { + if let Some(host) = host_from_url(url) { + cstrings.push(( + CString::new("Host").map_err(|_| Status::INVALID_PARAMETER)?, + CString::new(host).map_err(|_| Status::INVALID_PARAMETER)?, + )); + } + } + for (name, value) in headers { + cstrings.push(( + CString::new(*name).map_err(|_| Status::INVALID_PARAMETER)?, + CString::new(*value).map_err(|_| Status::INVALID_PARAMETER)?, + )); + } + let mut hdrs: Vec = cstrings + .iter() + .map(|(name, value)| HttpHeader { + field_name: name.as_ptr().cast::(), + field_value: value.as_ptr().cast::(), + }) + .collect(); + + let mut req = HttpRequestData { + method, + url: url16.as_ptr().cast::(), + }; + let mut tx_msg = HttpMessage::default(); + tx_msg.data.request = &mut req; + tx_msg.header_count = hdrs.len(); + tx_msg.header = hdrs.as_mut_ptr(); + + let event = make_wait_event()?; + let mut tx_token = HttpToken { + event: event.as_ptr(), + status: Status::NOT_READY, + message: &mut tx_msg, + }; + let res = self + .http() + .request(&mut tx_token) + .map_err(|e| { + println!("stage0: request() rejected: {:?}", e.status()); + e.status() + }) + .and_then(|()| self.await_completion(&tx_token, &event, "request")); + let _ = boot::close_event(unsafe { event.unsafe_clone() }); + res?; + if tx_token.status != Status::SUCCESS { + println!("stage0: request failed: {:?}", tx_token.status); + return Err(tx_token.status); + } + Ok(()) + } + + /// Read the first part of the response: status, headers (Content-Length), + /// and up to `cap` body bytes. Mirrors uefi-rs `HttpHelper::response_first`. + fn read_first( + &mut self, + cap: usize, + ) -> Result<(HttpStatusCode, Option, Vec), Status> { + let mut rsp = HttpResponseData { + status_code: HttpStatusCode::STATUS_UNSUPPORTED, + }; + let mut buf = vec![0u8; cap]; + let mut rx_msg = HttpMessage::default(); + rx_msg.data.response = &mut rsp; + rx_msg.body_length = buf.len(); + rx_msg.body = buf.as_mut_ptr().cast::(); + let event = make_wait_event()?; + let mut rx_token = HttpToken { + event: event.as_ptr(), + status: Status::NOT_READY, + message: &mut rx_msg, + }; + let res = self + .http() + .response(&mut rx_token) + .map_err(|e| e.status()) + .and_then(|()| self.await_completion(&rx_token, &event, "response")); + let _ = boot::close_event(unsafe { event.unsafe_clone() }); + res?; + // HTTP_ERROR means a response with a non-2xx status; still inspectable. + if rx_token.status != Status::SUCCESS && rx_token.status != Status::HTTP_ERROR { + println!("stage0: response failed: {:?}", rx_token.status); + return Err(rx_token.status); + } + let status_code = rsp.status_code; + let content_length = parse_content_length(&rx_msg); + let got = rx_msg.body_length; + println!( + "stage0: response {:?}, content-length={:?}, first {} B", + status_code, content_length, got + ); + Ok((status_code, content_length, buf[..got].to_vec())) + } + + /// Read up to `cap` more body bytes. Mirrors `HttpHelper::response_more`. + fn read_more(&mut self, cap: usize) -> Result, Status> { + let mut buf = vec![0u8; cap]; + let mut rx_msg = HttpMessage { + body_length: buf.len(), + body: buf.as_mut_ptr().cast::(), + ..Default::default() + }; + let event = make_wait_event()?; + let mut rx_token = HttpToken { + event: event.as_ptr(), + status: Status::NOT_READY, + message: &mut rx_msg, + }; + let res = self + .http() + .response(&mut rx_token) + .map_err(|e| e.status()) + .and_then(|()| self.await_completion(&rx_token, &event, "response-more")); + let _ = boot::close_event(unsafe { event.unsafe_clone() }); + res?; + if rx_token.status != Status::SUCCESS { + return Ok(Vec::new()); + } + Ok(buf[..rx_msg.body_length].to_vec()) + } + + /// Send a request and read the whole response body in 16 KiB chunks — the + /// multi-segment-safe pattern uefi-rs's HttpHelper uses. Returns (status, body). + pub fn fetch( + &mut self, + method: HttpMethod, + url: &str, + headers: &[(&str, &str)], + ) -> Result<(HttpStatusCode, Vec), Status> { + self.send_request(method, url, headers)?; + let (status, content_length, mut body) = self.read_first(CHUNK)?; + + // Pull the rest in chunks until we've read Content-Length bytes. We stop + // as soon as we have enough, so we never issue a Response() with nothing + // left to deliver (that would block until the timeout). + if let Some(total) = content_length { + while body.len() < total { + let chunk = self.read_more(CHUNK)?; + if chunk.is_empty() { + break; + } + body.extend_from_slice(&chunk); + println!("stage0: body {}/{} B", body.len(), total); + } + } + Ok((status, body)) + } + + /// Block until the async HTTP token completes, driven by the firmware's + /// event loop rather than a tight `poll()` spin. The token carries a wait + /// event that HttpDxe signals on completion; `WaitForEvent` lets the + /// network stack's timer/MNP events fire (which a busy poll loop can starve). + /// Bounded by a one-shot timer so a wedged driver can't hang the loader. + fn await_completion(&self, token: &HttpToken, event: &Event, what: &str) -> Result<(), Status> { + let timer = unsafe { boot::create_event(EventType::TIMER, Tpl::CALLBACK, None, None) } + .map_err(|e| e.status())?; + if let Err(e) = boot::set_timer(&timer, TimerTrigger::Relative(POLL_DEADLINE_100NS)) { + let _ = boot::close_event(timer); + return Err(e.status()); + } + + let result = loop { + if token.status != Status::NOT_READY { + break Ok(()); + } + let mut events = [unsafe { event.unsafe_clone() }, unsafe { + timer.unsafe_clone() + }]; + match boot::wait_for_event(&mut events) { + Ok(0) => {} // completion event signaled; re-check token.status + Ok(_) => { + println!("stage0: [{what}] TIMEOUT (event wait)"); + break Err(Status::TIMEOUT); + } + Err(e) => break Err(e.status()), + } + }; + + let _ = boot::set_timer(&timer, TimerTrigger::Cancel); + let _ = boot::close_event(timer); + result + } +} + +/// Create a plain, waitable event for an async HTTP token (no notify function, +/// so it can be passed to `WaitForEvent`; HttpDxe signals it on completion). +fn make_wait_event() -> Result { + unsafe { boot::create_event(EventType::empty(), Tpl::CALLBACK, None, None) } + .map_err(|e| e.status()) +} + +/// Connect all drivers to all handles (best-effort), forcing the firmware to +/// bind its network stack so the HTTP service binding becomes available even on +/// the first boot before BDS has connected everything. +fn connect_all_controllers() { + let handles = match boot::locate_handle_buffer(boot::SearchType::AllHandles) { + Ok(h) => h, + Err(e) => { + println!("stage0: locate_handle_buffer failed: {:?}", e.status()); + return; + } + }; + let mut connected = 0usize; + for handle in handles.iter() { + if boot::connect_controller(*handle, None, None, true).is_ok() { + connected += 1; + } + } + println!( + "stage0: connected drivers on {}/{} handles", + connected, + handles.len() + ); +} + +impl Drop for HttpClient { + fn drop(&mut self) { + // Protocol must be closed before the child handle is destroyed. + self.http = None; + let _ = self.binding.destroy_child(self.child); + } +} + +/// `true` if the status code is 200 OK. +#[must_use] +pub fn is_ok(status: HttpStatusCode) -> bool { + status == HttpStatusCode::STATUS_200_OK +} + +/// Extract the authority (`host[:port]`) from an `http://host/...` URL. +fn host_from_url(url: &str) -> Option<&str> { + // "http://HOST/path" -> split on '/' -> ["http:", "", "HOST", "path", ...] + url.split('/').nth(2).filter(|h| !h.is_empty()) +} + +/// Parse the `Content-Length` response header, if present. +fn parse_content_length(msg: &HttpMessage) -> Option { + for i in 0..msg.header_count { + unsafe { + let h = &*msg.header.add(i); + let name = CStr::from_ptr(h.field_name.cast::()) + .to_str() + .ok()?; + if name.eq_ignore_ascii_case("content-length") { + let value = CStr::from_ptr(h.field_value.cast::()) + .to_str() + .ok()?; + return value.trim().parse::().ok(); + } + } + } + None +} + +/// Collect the response headers as lowercased name/value pairs (unused by the +/// happy path but handy for diagnostics). +#[allow(dead_code)] +fn collect_headers(msg: &HttpMessage) -> Vec<(String, String)> { + let mut headers = Vec::new(); + for i in 0..msg.header_count { + unsafe { + let h = &*msg.header.add(i); + let name = CStr::from_ptr(h.field_name.cast::()); + let value = CStr::from_ptr(h.field_value.cast::()); + if let (Ok(n), Ok(v)) = (name.to_str(), value.to_str()) { + headers.push((n.to_lowercase(), String::from(v))); + } + } + } + headers +} diff --git a/crates/stage0/src/main.rs b/crates/stage0/src/main.rs new file mode 100644 index 0000000..389a1de --- /dev/null +++ b/crates/stage0/src/main.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! stage0 — a measured UEFI network bootloader for the lockboot stack. +//! +//! Boots as a pure UEFI application (no Linux kernel), pulls a `_stage0` +//! user-data document from the cloud metadata service, downloads a UEFI payload +//! over raw `EFI_TCP4` (see `tcp4.rs`), admits it via one of two policies — +//! a pinned SHA-256, or an ed25519 signature against a pinned release key +//! (`sig.rs`) — measures it into the TPM via `EFI_TCG2_PROTOCOL` (PCR 14 = +//! SHA-256 of the loaded binary), then chain-loads it. +//! +//! The payload is loaded through a temporary security-arch override (`secauth.rs`) +//! rather than relying on the firmware `db`, so the deployment is not forced to +//! Secure-Boot-sign every late-bound payload. The attestation surface is kept +//! deliberately small: the only thing measured is PCR 14 — "stage0 ran, and it +//! loaded a binary with this hash." The admission signature/key are not measured. + +#![no_std] +#![no_main] + +extern crate alloc; + +mod config; +mod dns4; +mod http; +mod metadata; +mod secauth; +mod sig; +mod tcg2; +mod tcp4; + +use alloc::string::String; +use config::Verify; +use sha2::{Digest, Sha256}; +use uefi::boot; +use uefi::prelude::*; +use uefi::proto::loaded_image::LoadedImage; +use uefi::{println, CString16}; + +/// PCR extended with SHA-256 of the loaded payload (matches stage1's binary PCR). +const PCR_BINARY: u8 = 14; + +#[entry] +fn main() -> Status { + uefi::helpers::init().unwrap(); + match run() { + Ok(()) => { + println!("stage0: payload returned control to stage0 (unexpected)"); + Status::LOAD_ERROR + } + Err(status) => { + println!("stage0: ERROR {:?}", status); + // Pause so the failure is visible on the serial console. + boot::stall(5_000_000); + status + } + } +} + +fn run() -> Result<(), Status> { + println!("stage0: measured UEFI netboot starting"); + + // Fetch metadata on its own HTTP instance, then drop it. Small metadata + // bodies download fine over EFI_HTTP; the payload uses raw TCP4 below. + let (url, verify, args) = { + let mut client = http::HttpClient::new()?; + println!("stage0: network configured"); + + let json = metadata::fetch(&mut client)?; + println!("stage0: fetched {} bytes of user-data", json.len()); + + let user_data = config::parse(&json).map_err(|m| { + println!("stage0: config error: {m}"); + Status::INVALID_PARAMETER + })?; + let arch = user_data.stage0.for_this_arch().ok_or_else(|| { + println!("stage0: no _stage0 config for this architecture"); + Status::UNSUPPORTED + })?; + let verify = arch.validate().map_err(|m| { + println!("stage0: invalid arch config: {m}"); + Status::INVALID_PARAMETER + })?; + (arch.url.clone(), verify, user_data.stage0.args.clone()) + }; + + // The payload is downloaded over raw TCP4 (EFI_HTTP/HttpDxe won't drain a + // multi-segment body here; see tcp4.rs). Metadata stays on EFI_HTTP above. + println!("stage0: downloading payload from {url}"); + let binary = tcp4::download(&url)?; + println!("stage0: downloaded {} bytes", binary.len()); + + // Admission control. PCR 14 always records the SHA-256 of what we load; the + // policy below only decides whether we are *allowed* to load it. + let digest = sha256(&binary); + match &verify { + Verify::Sha256(expected) => { + let actual = hex::encode(digest); + if !actual.eq_ignore_ascii_case(expected) { + println!("stage0: SHA256 mismatch! expected {expected}, got {actual}"); + return Err(Status::SECURITY_VIOLATION); + } + println!("stage0: SHA256 verified"); + } + Verify::Ed25519(pubkey) => { + // Detached signature lives alongside the payload at .sig. + let sig_url = alloc::format!("{url}.sig"); + println!("stage0: fetching signature from {sig_url}"); + let signature = tcp4::download(&sig_url)?; + sig::verify(pubkey, &binary, &signature).map_err(|m| { + println!("stage0: ed25519 verification failed: {m}"); + Status::SECURITY_VIOLATION + })?; + println!("stage0: ed25519 signature verified"); + } + } + + // Measure before executing. Only PCR 14 (the binary): the config/key are not + // measured, so attestation is simply "stage0 ran and loaded this hash". + // Scoped so the TCG2 protocol is released before chain-loading: stage0 opens + // it exclusively, and the payload needs to open it too (else ACCESS_DENIED). + { + let mut tpm = tcg2::open_tpm().map_err(|e| { + println!("stage0: TPM unavailable: {e}"); + Status::DEVICE_ERROR + })?; + measure(&mut tpm, PCR_BINARY, &digest)?; + } + println!("stage0: extended PCR{PCR_BINARY} with the payload measurement"); + + // Chain-load the measured payload from memory. The payload is admitted by + // stage0's own policy above, not the firmware db, so load it through a + // temporary security-arch override (see secauth.rs). + let image = secauth::load_image_verified(&binary).inspect_err(|&status| { + println!("stage0: load_image failed: {status:?}"); + })?; + + // Optionally pass args as UEFI load options; the backing buffer must stay + // alive until after start_image. + let _options = set_load_options(image, args.as_deref()); + + println!("stage0: starting payload"); + boot::start_image(image).map_err(|e| e.status())?; + + Ok(()) +} + +/// Extend a PCR with `data` via the TCG2-backed TPM transport. +fn measure(tpm: &mut vaportpm_attest::Tpm, pcr: u8, data: &[u8]) -> Result<(), Status> { + use vaportpm_attest::PcrOps; + tpm.pcr_extend(pcr, data).map_err(|e| { + println!("stage0: pcr_extend(PCR{pcr}) failed: {e}"); + Status::DEVICE_ERROR + }) +} + +/// Set the loaded image's load options from `args` (UCS-2). Returns the backing +/// [`CString16`], which the caller must keep alive until `start_image`. +fn set_load_options(image: Handle, args: Option<&[String]>) -> Option { + let args = args?; + if args.is_empty() { + return None; + } + let options = CString16::try_from(args.join(" ").as_str()).ok()?; + let mut loaded = boot::open_protocol_exclusive::(image).ok()?; + unsafe { + loaded.set_load_options(options.as_ptr().cast::(), options.num_bytes() as u32); + } + Some(options) +} + +fn sha256(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} diff --git a/crates/stage0/src/metadata.rs b/crates/stage0/src/metadata.rs new file mode 100644 index 0000000..508b194 --- /dev/null +++ b/crates/stage0/src/metadata.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Cloud metadata-service user-data fetching. +//! +//! Mirrors `stage1`'s provider order and endpoints (EC2 IMDSv2 → GCP → Azure), +//! plus a best-effort Aliyun path, using fixed link-local IPs so no DNS is +//! needed to reach the metadata service itself. + +use alloc::string::String; +use alloc::vec::Vec; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; + +use crate::http::{is_ok, HttpClient, HttpMethod}; +use uefi::{println, Status}; + +const EC2_TOKEN_URL: &str = "http://169.254.169.254/latest/api/token"; +const EC2_USERDATA_URL: &str = "http://169.254.169.254/latest/user-data"; +const GCP_USERDATA_URL: &str = + "http://169.254.169.254/computeMetadata/v1/instance/attributes/user-data"; +const AZURE_USERDATA_URL: &str = + "http://169.254.169.254/metadata/instance/compute/userData?api-version=2021-02-01&format=text"; +const ALIYUN_USERDATA_URL: &str = "http://100.100.100.200/latest/user-data"; + +/// A metadata provider: name + a fetch function returning the raw user-data. +type Provider = fn(&mut HttpClient) -> Result, Status>; + +/// Try each cloud provider in turn; return the first user-data document found. +pub fn fetch(client: &mut HttpClient) -> Result, Status> { + let providers: [(&str, Provider); 4] = [ + ("EC2 (IMDSv2)", try_ec2), + ("GCP", try_gcp), + ("Azure", try_azure), + ("Aliyun", try_aliyun), + ]; + for (name, try_fn) in providers { + println!("stage0: trying metadata provider: {name}"); + match try_fn(client) { + Ok(data) => { + println!("stage0: {name} returned {} bytes", data.len()); + return Ok(data); + } + Err(e) => println!("stage0: {name} failed: {:?}", e), + } + } + println!("stage0: no metadata provider responded"); + Err(Status::NOT_FOUND) +} + +/// AWS EC2 IMDSv2: obtain a session token (PUT), then GET user-data. +fn try_ec2(client: &mut HttpClient) -> Result, Status> { + let (status, token) = client.fetch( + HttpMethod::PUT, + EC2_TOKEN_URL, + &[("X-aws-ec2-metadata-token-ttl-seconds", "21600")], + )?; + if !is_ok(status) { + return Err(Status::ABORTED); + } + let token = String::from_utf8(token).map_err(|_| Status::ABORTED)?; + let token = token.trim(); + + let (status, body) = client.fetch( + HttpMethod::GET, + EC2_USERDATA_URL, + &[("X-aws-ec2-metadata-token", token)], + )?; + if !is_ok(status) { + return Err(Status::ABORTED); + } + Ok(body) +} + +/// GCP compute metadata (reachable at the link-local IP; requires the flavor header). +fn try_gcp(client: &mut HttpClient) -> Result, Status> { + let (status, body) = client.fetch( + HttpMethod::GET, + GCP_USERDATA_URL, + &[ + ("Metadata-Flavor", "Google"), + ("Host", "metadata.google.internal"), + ], + )?; + if !is_ok(status) { + return Err(Status::ABORTED); + } + Ok(body) +} + +/// Azure IMDS: user-data is returned base64-encoded. +fn try_azure(client: &mut HttpClient) -> Result, Status> { + let (status, body) = + client.fetch(HttpMethod::GET, AZURE_USERDATA_URL, &[("Metadata", "true")])?; + if !is_ok(status) { + return Err(Status::ABORTED); + } + let text = String::from_utf8(body).map_err(|_| Status::ABORTED)?; + STANDARD.decode(text.trim()).map_err(|_| Status::ABORTED) +} + +/// Aliyun ECS metadata (best-effort; v1 plain GET of user-data). +fn try_aliyun(client: &mut HttpClient) -> Result, Status> { + let (status, body) = client.fetch(HttpMethod::GET, ALIYUN_USERDATA_URL, &[])?; + if !is_ok(status) { + return Err(Status::ABORTED); + } + Ok(body) +} diff --git a/crates/stage0/src/secauth.rs b/crates/stage0/src/secauth.rs new file mode 100644 index 0000000..087ac2e --- /dev/null +++ b/crates/stage0/src/secauth.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Loading a payload that the UEFI `db` did not sign. +//! +//! Under Secure Boot, DXE core's `LoadImage` does not decide accept/reject +//! itself — it delegates to the architectural security protocols. For a +//! memory-buffer load the authoritative gate is +//! `EFI_SECURITY2_ARCH_PROTOCOL.FileAuthentication`, whose default +//! implementation runs the `db`/`dbx` check and returns `ACCESS_DENIED` for an +//! unsigned image. Older firmware without Security2 falls back to +//! `EFI_SECURITY_ARCH_PROTOCOL.FileAuthenticationState`. +//! +//! These are plain function pointers in boot-services memory. stage0 — already +//! a `db`-signed, measured image — temporarily swaps in an allow-all decision +//! around a single `LoadImage`, then restores it. This is exactly shim's +//! `security_policy_install()`/`uninstall()`: the firmware still does all the +//! real PE loading, relocation and handle setup; only the *verdict* is replaced. +//! +//! stage0 has already verified the buffer (ed25519 signature against the pinned +//! release key, or pinned SHA-256) before we get here, so the trust does not +//! weaken — it moves from the firmware `db` (which is not remotely attestable +//! and, under our ephemeral-key lockdown, cannot sign late-bound payloads) into +//! stage0's own policy. The payload is still measured into PCR 14, so the +//! attestation chain is unbroken: stage0 ran, and it loaded *this* hash. + +use uefi::boot::{ + self, LoadImageSource, OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, +}; +use uefi::proto::unsafe_protocol; +use uefi::{Handle, Status}; +use uefi_raw::Boolean; + +use core::ffi::c_void; + +// ---- EFI_SECURITY2_ARCH_PROTOCOL (94ab2f58-...) ---- + +type Security2FileAuth = unsafe extern "efiapi" fn( + this: *const c_void, // EFI_SECURITY2_ARCH_PROTOCOL* + device_path: *const c_void, // EFI_DEVICE_PATH_PROTOCOL* + file_buffer: *mut c_void, + file_size: usize, + boot_policy: Boolean, +) -> Status; + +#[repr(C)] +struct Security2Interface { + file_authentication: Security2FileAuth, +} + +#[unsafe_protocol("94ab2f58-1438-4ef1-9152-18941a3a0e68")] +struct Security2(Security2Interface); + +// ---- EFI_SECURITY_ARCH_PROTOCOL, v1 fallback (a46423e3-...) ---- + +type SecurityFileAuthState = unsafe extern "efiapi" fn( + this: *const c_void, // EFI_SECURITY_ARCH_PROTOCOL* + authentication_status: u32, + file: *const c_void, // EFI_DEVICE_PATH_PROTOCOL* +) -> Status; + +#[repr(C)] +struct SecurityInterface { + file_authentication_state: SecurityFileAuthState, +} + +#[unsafe_protocol("a46423e3-4617-49f1-b9ff-d1bfa9115839")] +struct Security(SecurityInterface); + +/// allow-all replacement for `Security2.FileAuthentication`. +unsafe extern "efiapi" fn allow_security2( + _this: *const c_void, + _device_path: *const c_void, + _file_buffer: *mut c_void, + _file_size: usize, + _boot_policy: Boolean, +) -> Status { + Status::SUCCESS +} + +/// allow-all replacement for `Security.FileAuthenticationState`. +unsafe extern "efiapi" fn allow_security( + _this: *const c_void, + _authentication_status: u32, + _file: *const c_void, +) -> Status { + Status::SUCCESS +} + +/// RAII guard: on construction, replaces the security-arch authentication hooks +/// with allow-all; on drop, restores the originals. The window is kept to a +/// single `LoadImage` call so no other image load is affected. +struct AuthOverride { + security2: Option<(ScopedProtocol, Security2FileAuth)>, + security: Option<(ScopedProtocol, SecurityFileAuthState)>, +} + +impl AuthOverride { + fn install() -> Self { + // EFI_SECURITY2_ARCH_PROTOCOL — authoritative for buffer loads on all + // modern edk2/OVMF firmware (our targets). + let security2 = open::().map(|mut sp| { + let iface: *mut Security2Interface = &mut sp.0; + let saved = unsafe { (*iface).file_authentication }; + unsafe { (*iface).file_authentication = allow_security2 }; + (sp, saved) + }); + + // EFI_SECURITY_ARCH_PROTOCOL — only consulted when Security2 is absent, + // but override it too so we behave on older firmware. + let security = open::().map(|mut sp| { + let iface: *mut SecurityInterface = &mut sp.0; + let saved = unsafe { (*iface).file_authentication_state }; + unsafe { (*iface).file_authentication_state = allow_security }; + (sp, saved) + }); + + Self { + security2, + security, + } + } +} + +impl Drop for AuthOverride { + fn drop(&mut self) { + if let Some((sp, saved)) = self.security2.as_mut() { + let iface: *mut Security2Interface = &mut sp.0; + unsafe { (*iface).file_authentication = *saved }; + } + if let Some((sp, saved)) = self.security.as_mut() { + let iface: *mut SecurityInterface = &mut sp.0; + unsafe { (*iface).file_authentication_state = *saved }; + } + } +} + +/// Open an architectural protocol non-exclusively (it lives for the life of +/// boot services; we only mutate one function pointer in it). +fn open() -> Option> { + let handle = boot::get_handle_for_protocol::

().ok()?; + unsafe { + boot::open_protocol::

( + OpenProtocolParams { + handle, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + } + .ok() +} + +/// `LoadImage` a payload from memory, bypassing the Secure Boot `db` check via a +/// temporary security-arch override. The caller MUST have already verified the +/// buffer (signature or pinned hash) — this only relaxes the firmware gate. +pub fn load_image_verified(buffer: &[u8]) -> Result { + let _guard = AuthOverride::install(); + boot::load_image( + boot::image_handle(), + LoadImageSource::FromBuffer { + buffer, + file_path: None, + }, + ) + .map_err(|e| e.status()) + // `_guard` drops here, restoring the original authentication hooks. +} diff --git a/crates/stage0/src/sig.rs b/crates/stage0/src/sig.rs new file mode 100644 index 0000000..39a80ec --- /dev/null +++ b/crates/stage0/src/sig.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! ed25519 admission control for "signed mode" payloads. +//! +//! In signed mode the metadata pins a long-term **release public key** (32-byte +//! ed25519, base64) instead of an exact SHA-256. The payload at the URL is +//! whatever the latest signed build is; stage0 fetches a detached signature +//! (`.sig`, 64 raw bytes) and verifies it against the pinned key before +//! loading. This lets a release roll forward without editing VM metadata. +//! +//! The signature is *admission control only* — it is not measured, and the key +//! is not measured. The attestation surface stays minimal: PCR 14 records the +//! SHA-256 of whatever binary actually ran, full stop. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use ed25519_compact::{PublicKey, Signature}; + +/// Verify a detached ed25519 `signature` over `message` against the base64 +/// `pubkey_b64` pinned in the metadata. Verification is constant-work and needs +/// no allocator or RNG. +pub fn verify(pubkey_b64: &str, message: &[u8], signature: &[u8]) -> Result<(), &'static str> { + let key_bytes = STANDARD + .decode(pubkey_b64.trim()) + .map_err(|_| "ed25519 pubkey is not valid base64")?; + let public_key = + PublicKey::from_slice(&key_bytes).map_err(|_| "ed25519 pubkey wrong length")?; + let signature = + Signature::from_slice(signature).map_err(|_| "ed25519 signature wrong length")?; + public_key + .verify(message, &signature) + .map_err(|_| "ed25519 signature verification failed") +} diff --git a/crates/stage0/src/tcg2.rs b/crates/stage0/src/tcg2.rs new file mode 100644 index 0000000..a633c2c --- /dev/null +++ b/crates/stage0/src/tcg2.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! TPM access from UEFI via `EFI_TCG2_PROTOCOL`. +//! +//! `vaportpm-attest` speaks raw TPM 2.0 command/response blocks through its +//! [`TpmTransport`] trait. Here we provide a transport that ships those blocks +//! over the firmware's `EFI_TCG2_PROTOCOL.SubmitCommand`, so the exact same +//! `pcr_extend` logic that `stage1` runs against `/dev/tpmrm0` on Linux runs +//! unchanged here — keeping a single measurement (and verification) model. + +use alloc::boxed::Box; +use alloc::vec; +use alloc::vec::Vec; +use anyhow::{anyhow, bail, Result}; +use uefi::boot; +use uefi::boot::ScopedProtocol; +use uefi::proto::tcg::v2::Tcg; +use vaportpm_attest::{Tpm, TpmTransport}; + +/// A [`TpmTransport`] backed by `EFI_TCG2_PROTOCOL`. +struct Tcg2Transport { + tcg: ScopedProtocol, + max_response_size: usize, +} + +impl TpmTransport for Tcg2Transport { + fn transmit_raw(&mut self, command: &[u8]) -> Result> { + // Size the response buffer to the firmware-reported maximum. + let mut out = vec![0u8; self.max_response_size.max(64)]; + self.tcg + .submit_command(command, &mut out) + .map_err(|e| anyhow!("EFI_TCG2 SubmitCommand failed: {:?}", e.status()))?; + + // The actual response length lives in the TPM response header + // (bytes 2..6, big-endian). Truncate to it. + if out.len() < 10 { + bail!("TPM response buffer too small ({} bytes)", out.len()); + } + let size = u32::from_be_bytes([out[2], out[3], out[4], out[5]]) as usize; + if size < 10 || size > out.len() { + bail!("invalid TPM response size {}", size); + } + out.truncate(size); + Ok(out) + } +} + +/// Locate the TPM and return a [`Tpm`] context bound to the TCG2 transport. +/// +/// Fails closed: if no TCG2 protocol is present or the TPM is reported absent, +/// returns an error rather than booting an unmeasured payload. +pub fn open_tpm() -> Result { + let handle = boot::get_handle_for_protocol::() + .map_err(|e| anyhow!("no EFI_TCG2_PROTOCOL: {:?}", e.status()))?; + let mut tcg = boot::open_protocol_exclusive::(handle) + .map_err(|e| anyhow!("open EFI_TCG2_PROTOCOL: {:?}", e.status()))?; + + let cap = tcg + .get_capability() + .map_err(|e| anyhow!("TCG2 get_capability: {:?}", e.status()))?; + if !cap.tpm_present() { + bail!("TCG2 reports no TPM present"); + } + let max_response_size = cap.max_response_size as usize; + + Ok(Tpm::with_transport(Box::new(Tcg2Transport { + tcg, + max_response_size, + }))) +} diff --git a/crates/stage0/src/tcp4.rs b/crates/stage0/src/tcp4.rs new file mode 100644 index 0000000..ed4a34c --- /dev/null +++ b/crates/stage0/src/tcp4.rs @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Minimal HTTP/1.0 client over `EFI_TCP4_PROTOCOL`. +//! +//! OVMF/EDK2's `EFI_HTTP_PROTOCOL` (HttpDxe) will not deliver a multi-segment +//! response body in our usage: packet captures show the whole body arrives and +//! is ACKed by the firmware's TCP stack, but HttpDxe pulls only the first +//! segment and never drains the rest of its receive buffer. (Hardened firmware +//! such as AWS Nitro may additionally refuse plain `http://` via EFI_HTTP when +//! `PcdAllowHttpConnections=FALSE`.) So the payload download drops one layer to +//! raw TCP4 and runs its own `Receive()` loop — the exact step HttpDxe skips. +//! +//! `uefi-raw` 0.11 does not expose TCP4, so the FFI bindings (straight from the +//! UEFI spec, EFI_TCP4_PROTOCOL) are defined here. + +use alloc::vec; +use alloc::vec::Vec; +use core::ffi::c_void; +use core::ptr; + +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; +use uefi::proto::unsafe_protocol; +use uefi::{println, Status}; +use uefi_raw::protocol::driver::ServiceBindingProtocol; +use uefi_raw::{Boolean, Event, Ipv4Address}; + +// ---- EFI_TCP4_PROTOCOL FFI (UEFI spec) ---- + +#[repr(C)] +struct AccessPoint { + use_default_address: Boolean, + station_address: Ipv4Address, + subnet_mask: Ipv4Address, + station_port: u16, + remote_address: Ipv4Address, + remote_port: u16, + active_flag: Boolean, +} + +#[repr(C)] +struct ConfigData { + type_of_service: u8, + time_to_live: u8, + access_point: AccessPoint, + control_option: *const c_void, +} + +#[repr(C)] +struct CompletionToken { + event: Event, + status: Status, +} + +#[repr(C)] +struct ConnectionToken { + completion_token: CompletionToken, +} + +#[repr(C)] +struct FragmentData { + fragment_length: u32, + fragment_buffer: *mut c_void, +} + +#[repr(C)] +struct TxData { + push: Boolean, + urgent: Boolean, + data_length: u32, + fragment_count: u32, + fragment_table: [FragmentData; 1], +} + +#[repr(C)] +struct RxData { + urgent_flag: Boolean, + data_length: u32, + fragment_count: u32, + fragment_table: [FragmentData; 1], +} + +#[repr(C)] +union Packet { + rx_data: *mut RxData, + tx_data: *mut TxData, +} + +#[repr(C)] +struct IoToken { + completion_token: CompletionToken, + packet: Packet, +} + +/// `EFI_TCP4_PROTOCOL` method table. Slots we don't call keep the correct +/// order/size but use a placeholder signature (never invoked). +#[repr(C)] +struct Tcp4Protocol { + get_mode_data: unsafe extern "efiapi" fn() -> Status, + configure: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *const ConfigData) -> Status, + routes: unsafe extern "efiapi" fn() -> Status, + connect: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *mut ConnectionToken) -> Status, + accept: unsafe extern "efiapi" fn() -> Status, + transmit: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *mut IoToken) -> Status, + receive: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *mut IoToken) -> Status, + close: unsafe extern "efiapi" fn() -> Status, + cancel: unsafe extern "efiapi" fn() -> Status, + poll: unsafe extern "efiapi" fn(*mut Tcp4Protocol) -> Status, +} + +#[unsafe_protocol("00720665-67eb-4a99-baf7-d3c33a1c7cc9")] +struct Tcp4Sb(ServiceBindingProtocol); + +#[unsafe_protocol("65530bc7-a359-410f-b010-5aadc7ec2b62")] +struct Tcp4(Tcp4Protocol); + +/// Drive an async token to completion by polling its volatile `status`, pumping +/// the driver via `Poll()` and stalling 1ms between checks. Bounded by `budget_ms`. +unsafe fn pump(tcp: *mut Tcp4Protocol, status: *const Status, budget_ms: u32) -> Status { + let mut waited = 0; + loop { + let s = ptr::read_volatile(status); + if s != Status::NOT_READY { + return s; + } + let _ = ((*tcp).poll)(tcp); + boot::stall(1000); + waited += 1; + if waited >= budget_ms { + return Status::TIMEOUT; + } + } +} + +fn new_event() -> Result { + unsafe { + boot::create_event( + uefi::boot::EventType::empty(), + uefi::boot::Tpl::CALLBACK, + None, + None, + ) + } + .map(|e| e.as_ptr()) + .map_err(|e| e.status()) +} + +/// Connect to `ip:port`, send `request`, and read the full response until the +/// peer closes the connection (so requests must ask for `Connection: close`). +fn exchange(ip: [u8; 4], port: u16, request: &[u8]) -> Result, Status> { + let nic = boot::get_handle_for_protocol::().map_err(|e| { + println!("stage0: no EFI_TCP4 service binding: {:?}", e.status()); + e.status() + })?; + let mut sb = unsafe { + boot::open_protocol::( + OpenProtocolParams { + handle: nic, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + .map_err(|e| e.status())? + }; + + let mut child: uefi_raw::Handle = ptr::null_mut(); + let st = unsafe { (sb.0.create_child)(&mut sb.0, &mut child) }; + if st != Status::SUCCESS { + return Err(st); + } + let child_handle = unsafe { uefi::Handle::from_ptr(child).ok_or(Status::DEVICE_ERROR)? }; + + let result = exchange_on_child(child_handle, ip, port, request); + + // Tear the TCP4 child down regardless of outcome. + let _ = unsafe { (sb.0.destroy_child)(&mut sb.0, child) }; + result +} + +fn exchange_on_child( + child: uefi::Handle, + ip: [u8; 4], + port: u16, + request: &[u8], +) -> Result, Status> { + let mut tcp = unsafe { + boot::open_protocol::( + OpenProtocolParams { + handle: child, + agent: boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + .map_err(|e| e.status())? + }; + let tcp_ptr: *mut Tcp4Protocol = &mut tcp.0; + + // Configure: default (DHCP) station address, active open to ip:port. + let cfg = ConfigData { + type_of_service: 0, + time_to_live: 64, + access_point: AccessPoint { + use_default_address: Boolean::from(true), + station_address: Ipv4Address([0, 0, 0, 0]), + subnet_mask: Ipv4Address([0, 0, 0, 0]), + station_port: 0, + remote_address: Ipv4Address(ip), + remote_port: port, + active_flag: Boolean::from(true), + }, + control_option: ptr::null(), + }; + let st = unsafe { ((*tcp_ptr).configure)(tcp_ptr, &cfg) }; + if st != Status::SUCCESS { + println!("stage0: TCP4 configure failed: {st:?}"); + return Err(st); + } + + // Connect. + let event = new_event()?; + let mut ct = ConnectionToken { + completion_token: CompletionToken { + event, + status: Status::NOT_READY, + }, + }; + let st = unsafe { ((*tcp_ptr).connect)(tcp_ptr, &mut ct) }; + let st = if st == Status::SUCCESS { + unsafe { pump(tcp_ptr, &ct.completion_token.status, 10_000) } + } else { + st + }; + let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; + if st != Status::SUCCESS { + println!("stage0: TCP4 connect failed: {st:?}"); + let _ = unsafe { ((*tcp_ptr).configure)(tcp_ptr, ptr::null()) }; + return Err(st); + } + println!( + "stage0: TCP4 connected to {}.{}.{}.{}:{}", + ip[0], ip[1], ip[2], ip[3], port + ); + + let send_res = tcp_send(tcp_ptr, request); + let recv_res = send_res.and_then(|()| tcp_recv_all(tcp_ptr)); + + // Reset the instance (also closes the connection). + let _ = unsafe { ((*tcp_ptr).configure)(tcp_ptr, ptr::null()) }; + recv_res +} + +fn tcp_send(tcp_ptr: *mut Tcp4Protocol, data: &[u8]) -> Result<(), Status> { + let mut tx = TxData { + push: Boolean::from(true), + urgent: Boolean::from(false), + data_length: data.len() as u32, + fragment_count: 1, + fragment_table: [FragmentData { + fragment_length: data.len() as u32, + fragment_buffer: data.as_ptr() as *mut c_void, + }], + }; + let event = new_event()?; + let mut tok = IoToken { + completion_token: CompletionToken { + event, + status: Status::NOT_READY, + }, + packet: Packet { tx_data: &mut tx }, + }; + let st = unsafe { ((*tcp_ptr).transmit)(tcp_ptr, &mut tok) }; + let st = if st == Status::SUCCESS { + unsafe { pump(tcp_ptr, &tok.completion_token.status, 10_000) } + } else { + st + }; + let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; + if st != Status::SUCCESS { + println!("stage0: TCP4 transmit failed: {st:?}"); + return Err(st); + } + Ok(()) +} + +fn tcp_recv_all(tcp_ptr: *mut Tcp4Protocol) -> Result, Status> { + let mut out: Vec = Vec::new(); + loop { + let mut buf = vec![0u8; 32 * 1024]; + let mut rx = RxData { + urgent_flag: Boolean::from(false), + data_length: buf.len() as u32, + fragment_count: 1, + fragment_table: [FragmentData { + fragment_length: buf.len() as u32, + fragment_buffer: buf.as_mut_ptr().cast(), + }], + }; + let event = new_event()?; + let mut tok = IoToken { + completion_token: CompletionToken { + event, + status: Status::NOT_READY, + }, + packet: Packet { rx_data: &mut rx }, + }; + let call = unsafe { ((*tcp_ptr).receive)(tcp_ptr, &mut tok) }; + let st = if call == Status::SUCCESS { + unsafe { pump(tcp_ptr, &tok.completion_token.status, 10_000) } + } else { + call + }; + let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; + + if st == Status::SUCCESS { + let got = (rx.data_length as usize).min(buf.len()); + if got == 0 { + break; + } + out.extend_from_slice(&buf[..got]); + } else { + // EFI_CONNECTION_FIN / reset / timeout — peer closed or done. + break; + } + } + Ok(out) +} + +/// Download `url` over raw TCP4 and return the response body. The host may be an +/// IPv4 literal or a name resolved over EFI_DNS4. Uses `Connection: close` so the +/// body is delimited by the peer closing. +pub fn download(url: &str) -> Result, Status> { + let (host, port, path) = parse_http_url(url).ok_or_else(|| { + println!("stage0: TCP4 download: unsupported URL (need http://host[:port]/path): {url}"); + Status::INVALID_PARAMETER + })?; + + // IPv4 literal connects directly; a hostname is resolved over EFI_DNS4. + let ip = match parse_ipv4(host) { + Some(ip) => ip, + None => crate::dns4::resolve(host)?, + }; + + let mut req = alloc::string::String::new(); + req.push_str("GET "); + req.push_str(path); + req.push_str(" HTTP/1.1\r\nHost: "); + req.push_str(host); + req.push_str("\r\nConnection: close\r\nUser-Agent: stage0\r\n\r\n"); + println!("stage0: TCP4 GET {url}"); + + let raw = exchange(ip, port, req.as_bytes())?; + println!("stage0: TCP4 received {} bytes total", raw.len()); + + // Split headers/body on the blank line. + let sep = find_subslice(&raw, b"\r\n\r\n").ok_or_else(|| { + println!("stage0: TCP4 response had no header terminator"); + Status::PROTOCOL_ERROR + })?; + let head = &raw[..sep]; + let body = raw[sep + 4..].to_vec(); + + // Status line: "HTTP/1.x NNN ..." + let status_ok = head + .split(|&b| b == b'\n') + .next() + .map(|line| find_subslice(line, b" 200 ").is_some() || line.ends_with(b" 200")) + .unwrap_or(false); + if !status_ok { + let line = core::str::from_utf8(head.split(|&b| b == b'\n').next().unwrap_or(b"")) + .unwrap_or(""); + println!("stage0: TCP4 non-200 status: {}", line.trim_end()); + return Err(Status::ABORTED); + } + Ok(body) +} + +/// Parse `http://[:port]/` → (host, port, path). The host may be an +/// IPv4 literal or a name (resolved by the caller via EFI_DNS4). +fn parse_http_url(url: &str) -> Option<(&str, u16, &str)> { + let rest = url.strip_prefix("http://")?; + let slash = rest.find('/').unwrap_or(rest.len()); + let authority = &rest[..slash]; + let path = if slash < rest.len() { + &rest[slash..] + } else { + "/" + }; + let (host, port) = match authority.split_once(':') { + Some((h, p)) => (h, p.parse::().ok()?), + None => (authority, 80), + }; + Some((host, port, path)) +} + +fn parse_ipv4(s: &str) -> Option<[u8; 4]> { + let mut octets = [0u8; 4]; + let mut parts = s.split('.'); + for o in octets.iter_mut() { + *o = parts.next()?.parse::().ok()?; + } + if parts.next().is_some() { + return None; + } + Some(octets) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} diff --git a/tools/build-stage0/build.sh b/tools/build-stage0/build.sh new file mode 100755 index 0000000..fcc62f2 --- /dev/null +++ b/tools/build-stage0/build.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Build a bootable disk image that boots stage0.efi directly (no kernel/UKI). +# +# Mirrors tools/build-uki/build.sh's ESP/disk logic, but the payload on the ESP +# is the signed stage0 UEFI application instead of a Unified Kernel Image. +# +# Requires privilege for losetup/mount (run inside the privileged build docker, +# or with sudo on the host). Reuses the Secure Boot keys from tools/build-uki/keys. +set -euox pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +ARCH="${ARCH:-x86_64}" +KEYDIR="${REPO_ROOT}/tools/build-uki/keys" +OUTPUT_DIR="${OUTPUT_DIR:-${SCRIPT_DIR}/${ARCH}}" +STAGE0_EFI="${STAGE0_EFI:-${OUTPUT_DIR}/stage0.efi}" + +case "${ARCH}" in + x86_64) BOOT_EFI="BOOTX64.EFI" ;; + aarch64) BOOT_EFI="BOOTAA64.EFI" ;; + *) echo "Unsupported ARCH: ${ARCH}"; exit 1 ;; +esac + +if [ ! -f "${STAGE0_EFI}" ]; then + echo "Error: stage0 EFI binary not found at ${STAGE0_EFI}" + echo "Build it first: make tools/build-stage0/${ARCH}/stage0.efi" + exit 1 +fi + +mkdir -p "${OUTPUT_DIR}" + +# --- Sign stage0.efi for Secure Boot (same db key as the UKI) --- +SIGNED_EFI="${OUTPUT_DIR}/${BOOT_EFI}" +echo "=== Signing stage0.efi ===" +sbsign --key "${KEYDIR}/db.crt.key" --cert "${KEYDIR}/db.crt" --output "${SIGNED_EFI}" "${STAGE0_EFI}" + +# --- Create the bootable GPT + FAT32 disk --- +echo "=== Creating bootable disk image ===" +EFI_SIZE_BYTES=$(stat -c%s "${SIGNED_EFI}") +EFI_SIZE_MB=$((EFI_SIZE_BYTES / 1024 / 1024 + 1)) +PARTITION_SIZE_MB=$((EFI_SIZE_MB * 3 / 2)) +if [ ${PARTITION_SIZE_MB} -lt 64 ]; then PARTITION_SIZE_MB=64; fi +DISK_SIZE_MB=$((PARTITION_SIZE_MB + 2)) + +DISK_IMAGE="${OUTPUT_DIR}/boot.disk" +dd if=/dev/zero of="${DISK_IMAGE}" bs=1M count=${DISK_SIZE_MB} status=none + +# Deterministic GUIDs/volume-id derived from the signed binary's hash. +EFI_HASH=$(sha256sum "${SIGNED_EFI}" | cut -d' ' -f1) +DISK_GUID="${EFI_HASH:0:8}-${EFI_HASH:8:4}-${EFI_HASH:12:4}-${EFI_HASH:16:4}-${EFI_HASH:20:12}" +PART_GUID="${EFI_HASH:32:8}-${EFI_HASH:36:4}-${EFI_HASH:40:4}-${EFI_HASH:44:4}-${EFI_HASH:48:12}" +VOLUME_ID="${EFI_HASH:0:8}" + +sfdisk "${DISK_IMAGE}" < ../..) REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -if [ "$YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES" != 1 ]; then +usage() { + cat <<'EOF' +Usage: boot.sh [OPTIONS] + +Boot a lockboot disk image under QEMU with Secure Boot, an emulated TPM 2.0, +and a mocked EC2 metadata service. + +Options: + --kind What to boot (default: uki). + uki = Linux Unified Kernel Image (stage1 path) + stage0 = pure-UEFI network bootloader + --arch Target architecture (default: $ARCH or x86_64). + --boot-disk Override the disk image to boot. + --user-data Override the metadata user-data JSON. + --ovmf-vars Override the OVMF/EFI variables file. + --payload (stage0) Serve this UEFI payload over HTTP at + http://10.0.2.1:8000/payload.efi for stage0 to fetch. + --trace Capture the guest's TCP traffic on tap0 to a pcap at + stage0-trace.pcap in the repo root (bind-mounted, so it + persists on the host). Open in Wireshark / tshark to + reassemble the HTTP streams. + -h, --help Show this help and exit. + +Defaults by --kind: + uki : disk tools/build-uki//boot.disk, user-data.json + stage0 : disk tools/build-stage0//boot.disk, user-data.stage0.json + +The tap/iptables setup needs NET_ADMIN; run via 'make boot-...' (privileged +dev container) or with sudo and YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1. +EOF +} + +# Defaults (ARCH may still come from the environment for Makefile compatibility) +ARCH="${ARCH:-x86_64}" +BOOT_KIND="uki" +BOOT_DISK="" +USER_DATA="" +OVMF_VARS_OVERRIDE="" +PAYLOAD="" +TRACE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --kind) BOOT_KIND="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --boot-disk) BOOT_DISK="$2"; shift 2 ;; + --user-data) USER_DATA="$2"; shift 2 ;; + --ovmf-vars) OVMF_VARS_OVERRIDE="$2"; shift 2 ;; + --payload) PAYLOAD="$2"; shift 2 ;; + --trace) TRACE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [ "${YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES:-}" != 1 ]; then echo "Error: not inside docker, refusing to do dangerous stuff!!" + echo "Run via 'make boot-...' or sudo with YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1." exit 1 fi -# Get architecture from environment (default to x86_64) -ARCH=${ARCH:-x86_64} +case "${ARCH}" in x86_64|aarch64) ;; *) echo "Unsupported ARCH: ${ARCH}"; exit 1 ;; esac +case "${BOOT_KIND}" in uki|stage0) ;; *) echo "Unknown --kind: ${BOOT_KIND}"; exit 1 ;; esac -# Default user-data file KEYDIR="${REPO_ROOT}/tools/build-uki/keys" -USER_DATA="${REPO_ROOT}/user-data.json" TMP=/tmp -echo "=== Booting UKI with Secure Boot + TPM 2.0 (${ARCH}) ===" +# Resolve disk / user-data defaults from the selected kind (flags win). +if [ "${BOOT_KIND}" = "stage0" ]; then + DISK_DIR="${REPO_ROOT}/tools/build-stage0/${ARCH}" + : "${USER_DATA:=${REPO_ROOT}/user-data.stage0.json}" +else + DISK_DIR="${REPO_ROOT}/tools/build-uki/${ARCH}" + : "${USER_DATA:=${REPO_ROOT}/user-data.json}" +fi +: "${BOOT_DISK:=${DISK_DIR}/boot.disk}" + +echo "=== Booting ${BOOT_KIND} with Secure Boot + TPM 2.0 (${ARCH}) ===" echo "User-data file: ${USER_DATA}" +echo "Boot disk: ${BOOT_DISK}" AMMM=${SCRIPT_DIR}/ec2-metadata-mock-linux-amd64 @@ -36,10 +101,9 @@ if [ ! -f "${USER_DATA}" ]; then exit 1 fi -# Boot disk location (in tools/build-uki) -BOOT_DISK="${REPO_ROOT}/tools/build-uki/${ARCH}/boot.disk" +# Boot disk resolved above from --kind/--boot-disk. if [ ! -f "${BOOT_DISK}" ]; then - echo "Error: ${BOOT_DISK} not found. Run 'make ${ARCH}' first." + echo "Error: ${BOOT_DISK} not found. Build it first (see --help)." exit 1 fi @@ -77,7 +141,7 @@ else exit 1 fi -OVMF_VARS_ORIG="${REPO_ROOT}/tools/build-uki/${ARCH}/efi-vars.ovmf" +OVMF_VARS_ORIG="${OVMF_VARS_OVERRIDE:-${DISK_DIR}/efi-vars.ovmf}" OVMF_VARS="/tmp/efi-vars.ovmf" if [ ! -f "${OVMF_CODE}" ]; then @@ -106,6 +170,12 @@ sleep 1 cleanup() { kill $(cat $TMP/swtpm.pid 2>/dev/null) 2>/dev/null || true kill $(cat $TMP/ec2-mock.pid 2>/dev/null) 2>/dev/null || true + kill $(cat $TMP/payload-http.pid 2>/dev/null) 2>/dev/null || true + kill $(cat $TMP/tcpdump.pid 2>/dev/null) 2>/dev/null || true + # The boot runs as root; hand the trace back to the host user. + if [ "${TRACE}" = 1 ] && [ -n "${OWNER_UID:-}" ] && [ -f "${TRACE_FILE:-}" ]; then + chown "${OWNER_UID}:${OWNER_GID:-${OWNER_UID}}" "${TRACE_FILE}" 2>/dev/null || true + fi } # Set trap to cleanup on exit @@ -121,6 +191,17 @@ ip link set tap0 up ip addr add 10.0.2.1/24 dev tap0 ip addr add 169.254.169.254/24 dev tap0 +# Optional: capture the guest's full TCP traffic to a pcap (full packets, so the +# HTTP streams can be reassembled/extracted in Wireshark/tshark). Written into +# the bind-mounted repo so it survives the container. +TRACE_FILE="${REPO_ROOT}/stage0-trace.pcap" +if [ "${TRACE}" = 1 ]; then + echo "Capturing tap0 TCP -> ${TRACE_FILE} (repo root; open in Wireshark)" + tcpdump -i tap0 -s 0 -U -w "${TRACE_FILE}" tcp 2>/dev/null & + echo $! > $TMP/tcpdump.pid + sleep 0.5 +fi + # Create AEMM config with user-data echo "Starting EC2 metadata mock..." echo '{"userdata":{"values":{"userdata":"'$(base64 -w0 "${USER_DATA}")'"}}}' > $TMP/aemm-config.json @@ -136,6 +217,26 @@ echo $! > $TMP/ec2-mock.pid # Give services time to start sleep 1 +# Optionally serve the stage0 payload over HTTP on the tap gateway, so a +# `_stage0` user-data can point at http://10.0.2.1:8000/payload.efi. +if [ -n "${PAYLOAD}" ]; then + if [ ! -f "${PAYLOAD}" ]; then + echo "Error: payload ${PAYLOAD} not found"; exit 1 + fi + PAYLOAD_DIR=$(mktemp -d) + cp "${PAYLOAD}" "${PAYLOAD_DIR}/payload.efi" + # In signed mode stage0 also fetches a detached signature at .sig; + # serve it alongside the payload if the build produced one. + [ -f "${PAYLOAD}.sig" ] && cp "${PAYLOAD}.sig" "${PAYLOAD_DIR}/payload.efi.sig" + # Serve over HTTP/1.1 (with Content-Length + keep-alive). The default + # `python -m http.server` speaks HTTP/1.0 with `Connection: close`, which + # OVMF's HttpDxe does not complete the response token for. Real cloud object + # stores (S3/GCS) serve HTTP/1.1, so this matches production. + ( cd "${PAYLOAD_DIR}" && exec python3 -c 'import http.server; http.server.SimpleHTTPRequestHandler.protocol_version="HTTP/1.1"; http.server.ThreadingHTTPServer(("10.0.2.1",8000), http.server.SimpleHTTPRequestHandler).serve_forever()' ) & + echo $! > $TMP/payload-http.pid + echo "Serving payload (HTTP/1.1) at http://10.0.2.1:8000/payload.efi (sha256 $(sha256sum "${PAYLOAD}" | cut -d' ' -f1))" +fi + echo 1 > /proc/sys/net/ipv4/ip_forward iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE @@ -153,6 +254,7 @@ cat > /tmp/dnsmasq-hosts <