diff --git a/Makefile b/Makefile index ce15438..a4d8481 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ # stage0 - measured UEFI network bootloader. Standalone build + test. # # make / make build build the db-signed boot.disk (host arch) -# make boot build + boot it under QEMU (signed test payload) +# make boot build + boot it under QEMU (sha256 mode by default) # make test alias for boot -# Append an arch suffix to target a specific one: build-x86_64, boot-aarch64, ... -# (default arch is `uname -m`). Knobs for boot: -# PAYLOAD= serve a custom payload (sha256, or ed25519 if .sig exists) -# USER_DATA= serve your own _stage1 doc verbatim -# TRACE=1 capture the guest TCP stream to ./stage0-trace.pcap +# make smoke-boot asserting boot-test matrix: every admission mode (sha256, ed25519, +# signed-args, signed manifest, mirror fallback), each verified to +# chain-load the payload. Local-only (nested KVM), several minutes. +# Append an arch suffix to target a specific one: build-x86_64, boot-aarch64, smoke-boot-aarch64... +# (default arch is `uname -m`). Boot modes (SIGN=1 / SIGN_ARGS=1 / MANIFEST=1 / FALLBACK=1 / +# ARGS='[..]' / PAYLOAD= / USER_DATA=) are documented at the boot-% rule below. +# TRACE=1 capture the guest TCP stream to ./stage0-trace.pcap .PRECIOUS: build/keys/% \ build/%/stage0.efi build/%/payload.efi build/%/boot.disk @@ -132,40 +134,106 @@ build/%/payload.efi: docker-build-base build/keys/release.pem cp crates/stage0-test-payload/target/$*-unknown-uefi/release/stage0-test-payload.efi $@ && \ openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $@ -out $@.sig" +# ---- Signed remote LoadOptions for SIGN_ARGS=1: a JSON array of strings, ed25519-signed like +# the payload. stage0 fetches args.json + args.json.sig, verifies against the pinned key, and +# uses them as the child's UEFI LoadOptions (overriding inline args). ---- +build/%/args.json.sig: build/keys/release.pem + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ + mkdir -p build/$* && \ + printf '%s' '[\"--from\",\"signed-args\",\"--nosleep\"]' > build/$*/args.json && \ + openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \ + -in build/$*/args.json -out build/$*/args.json.sig" + # ---- QEMU harness: the lean harness image bakes qemu-test.sh as its entrypoint # (and the EC2_MOCK_CACHE + iptables-ack env), so we just append CLI args. ---- STAGE0_QEMU = $(DOCKER_RUN) $(DOCKER_OPT_KVM) \ --cap-add=NET_ADMIN --device=/dev/net/tun \ $(HARNESS_IMAGE) -# Boot stage0 under QEMU. Defaults to the signed test payload and regenerates the -# user-data each run so it can never go stale. FALLBACK=1 makes the _stage1 url a list -# [dead 10.0.2.1:9, real] so the mirror-fallback loop is exercised (first url refused). -boot-%: build/%/boot.disk build/%/payload.efi docker-build-harness - @P="$(PAYLOAD)"; [ -n "$$P" ] || P="build/$*/payload.efi"; \ - URLVAL="\"$(PAYLOAD_URL)\""; \ - if [ -n "$(FALLBACK)" ]; then URLVAL="[ \"http://10.0.2.1:9/payload.efi\", \"$(PAYLOAD_URL)\" ]"; echo "fallback: _stage1 url = [dead 10.0.2.1:9, $(PAYLOAD_URL)]"; fi; \ +# Boot stage0 under QEMU. Serves a staging dir (so payload, signed args, and a signed +# manifest are all served uniformly at http://SERVE_HOST/) and regenerates the +# user-data each run so it can never go stale. Each arch entry is the `{ "payload" | "manifest" }` +# discriminated union. Modes: +# (default) sha256 pin of the payload. +# SIGN=1 ed25519 detached-sig admission (serves payload.efi.sig). +# SIGN_ARGS=1 (implies signed) signed LoadOptions via args_url (serves args.json + .sig). +# MANIFEST=1 (implies signed) resolve a signed `_stage1` manifest that pins the payload, +# exercising stage0's manifest-resolution loop + top-level merge. +# FALLBACK=1 payload/manifest url is a list [dead 10.0.2.1:9, real] (mirror fallback). +# ARGS='[..]' inline payload LoadOptions, verbatim (ignored under SIGN_ARGS). With no ARGS, +# make passes `--nosleep` so the payload skips its EC2-only ~60s serial-flush hold; +# the payload also powers off at the end rather than returning to stage0 (which +# would trigger stage0's own ~90s fail-closed drain) -- so QEMU exits promptly. +# PAYLOAD= serve a custom payload instead of the built test payload. +# USER_DATA= serve the payload dir but boot your own `_stage1` doc verbatim. +boot-%: build/%/boot.disk build/%/payload.efi docker-build-harness \ + $(if $(SIGN_ARGS),build/%/args.json.sig) + @D="build/$*/serve"; rm -rf "$$D"; mkdir -p "$$D"; H="http://$(SERVE_HOST)"; \ + P="$(PAYLOAD)"; [ -n "$$P" ] || P="build/$*/payload.efi"; \ + cp "$$P" "$$D/payload.efi"; \ + URLVAL="\"$$H/payload.efi\""; \ + if [ -n "$(FALLBACK)" ]; then URLVAL="[ \"http://10.0.2.1:9/payload.efi\", \"$$H/payload.efi\" ]"; echo "fallback: url = [dead 10.0.2.1:9, $$H/payload.efi]"; fi; \ + INLINE_ARGS=""; \ + if [ -z "$(SIGN_ARGS)" ]; then \ + if [ -n '$(ARGS)' ]; then INLINE_ARGS=", \"args\": $$(printf '%s' '$(ARGS)')"; echo "LoadOptions = $(ARGS)"; \ + else INLINE_ARGS=", \"args\": [\"--nosleep\"]"; fi; \ + fi; \ if [ -n "$(USER_DATA)" ]; then \ - cp "$(USER_DATA)" user-data.stage0.json; \ - echo "Using user-data from $(USER_DATA)"; \ - elif [ -f "$$P.sig" ] && [ -f build/keys/release.pub.b64 ]; then \ + cp "$(USER_DATA)" user-data.stage0.json; echo "using user-data from $(USER_DATA)"; \ + elif [ -n "$(SIGN)$(SIGN_ARGS)$(MANIFEST)" ]; then \ PUB=$$(cat build/keys/release.pub.b64); \ - printf '{\n "_stage1": {\n "%s": { "url": %s, "ed25519": "%s" }\n }\n}\n' \ - "$*" "$$URLVAL" "$$PUB" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (signed mode, release pubkey $$PUB)"; \ + if [ -n "$(MANIFEST)" ]; then \ + SHA=$$(sha256sum "$$D/payload.efi" | cut -d' ' -f1); \ + printf '{ "_stage1": { "%s": { "payload": { "url": %s, "sha256": "%s"%s } } } }\n' "$*" "$$URLVAL" "$$SHA" "$$INLINE_ARGS" > "$$D/stage1.manifest.json"; \ + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c \ + "openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $$D/stage1.manifest.json -out $$D/stage1.manifest.json.sig"; \ + printf '{\n "_stage1": { "%s": { "manifest": { "url": "%s/stage1.manifest.json", "ed25519": "%s" } } }\n}\n' "$*" "$$H" "$$PUB" > user-data.stage0.json; \ + echo "user-data: _stage1 via signed manifest (pubkey $$PUB)"; \ + else \ + cp "$$P.sig" "$$D/payload.efi.sig"; \ + PAY="\"url\": $$URLVAL, \"ed25519\": \"$$PUB\""; \ + if [ -n "$(SIGN_ARGS)" ]; then \ + cp build/$*/args.json "$$D/args.json"; cp build/$*/args.json.sig "$$D/args.json.sig"; \ + PAY="$$PAY, \"args_url\": \"$$H/args.json\""; \ + fi; \ + printf '{\n "_stage1": { "%s": { "payload": { %s%s } } }\n}\n' "$*" "$$PAY" "$$INLINE_ARGS" > user-data.stage0.json; \ + echo "user-data: signed mode (pubkey $$PUB)"; \ + fi; \ else \ - SHA=$$(sha256sum "$$P" | cut -d' ' -f1); \ - printf '{\n "_stage1": {\n "%s": { "url": %s, "sha256": "%s" }\n }\n}\n' \ - "$*" "$$URLVAL" "$$SHA" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (sha256 mode, $$SHA)"; \ + SHA=$$(sha256sum "$$D/payload.efi" | cut -d' ' -f1); \ + printf '{\n "_stage1": { "%s": { "payload": { "url": %s, "sha256": "%s"%s } } }\n}\n' "$*" "$$URLVAL" "$$SHA" "$$INLINE_ARGS" > user-data.stage0.json; \ + echo "user-data: sha256 mode ($$SHA)"; \ fi; \ $(STAGE0_QEMU) --kind stage0 --arch $* \ --boot-disk build/$*/boot.disk \ - --user-data user-data.stage0.json --payload "$$P" $(if $(TRACE),--trace) + --user-data user-data.stage0.json --serve-dir "$$D" $(if $(TRACE),--trace) test-%: $(MAKE) boot-$* TRACE=$(TRACE) +# ---- Reproducible boot-test matrix: boot stage0 -> test-payload in every admission mode and +# assert the chain-loaded payload actually ran (its `payload: done` proves stage0 fetched, +# admitted, PCR-measured, and chain-loaded it). Local-only (nested KVM); each boot self-powers-off +# after the payload's serial-drain hold, so the whole matrix takes several minutes. ---- +.PHONY: smoke-boot +smoke-boot-%: build/%/boot.disk build/%/payload.efi build/%/args.json.sig docker-build-harness + @fail=0; sum="build/$*/smoke-boot.summary"; : > "$$sum"; \ + for m in "sha256:" "sign:SIGN=1" "sign_args:SIGN=1 SIGN_ARGS=1" "manifest:SIGN=1 MANIFEST=1" "fallback:FALLBACK=1"; do \ + name="$${m%%:*}"; vars="$${m#*:}"; log="build/$*/boot-$$name.log"; \ + echo "==================== stage0 boot test [$$name] $$vars ===================="; \ + echo " booting (runs to self-poweroff so QEMU releases the boot.disk lock before the next mode) -> $$log"; \ + $(MAKE) --no-print-directory boot-$* $$vars > "$$log" 2>&1 || true; \ + if grep -q 'payload: done' "$$log"; then \ + echo "PASS [$$name]" | tee -a "$$sum"; \ + else \ + echo "FAIL [$$name] (see $$log)" | tee -a "$$sum"; fail=1; \ + fi; \ + done; \ + echo "==================== stage0 boot-test summary ===================="; cat "$$sum"; \ + if [ "$$fail" = 0 ]; then echo "ALL STAGE0 BOOT TESTS PASSED"; else echo "SOME STAGE0 BOOT TESTS FAILED"; exit 1; fi + +smoke-boot: smoke-boot-$(ARCH) + # Arch-less convenience forms target the host architecture ($(ARCH)). .PHONY: build boot test build: build-$(ARCH) diff --git a/README.md b/README.md index 9a89e6d..c500640 100644 --- a/README.md +++ b/README.md @@ -20,46 +20,75 @@ it at your payload with a `_stage1` user-data document: ```json { "_stage1": { - "x86_64": { - "url": "http://cdn.example.com/app.efi", - "sha256": "<64-hex sha256>" - }, - "aarch64": { + "x86_64": { "payload": { "url": "http://cdn.example.com/app.efi", "sha256": "<64-hex sha256>" } }, + "aarch64": { "payload": { "url": "http://cdn.example.com/app.efi", "ed25519": "", - "args_url": "http://cdn.example.com/app.args", // optional - } + "args_url": "http://cdn.example.com/app.args" + } } } } ``` -Per arch, pick the admission mode: +Each arch entry is a discriminated union — exactly one of a `payload` (admit a +binary now) or a `manifest` (resolve a signed manifest first): -- **`sha256`**: pin an exact hash. Immutable; re-pin for every build. -- **`ed25519`**: pin a long-term release public key. The payload rolls forward - without editing metadata: sign each build offline and serve the detached +- **`payload` / `sha256`**: pin an exact hash. Immutable; re-pin for every build. +- **`payload` / `ed25519`**: pin a long-term release public key. The payload rolls + forward without editing metadata: sign each build offline and serve the detached signature at `.sig`, or at a `sig_url` of your choice. A `{sha256}` in `sig_url` is replaced with the payload's hash, so signatures can be content-addressed (e.g. `http://cdn.example.com/sigs/{sha256}.sig`). +- **`manifest`**: pin a release key **and** a manifest URL. stage0 fetches the signed + manifest (a `_stage1` fragment), verifies its detached signature (`.sig`, or + `sig_url`) against the pinned `ed25519` key, deep-merges it, and re-evaluates — + looping through a chain of signed manifests (per-hop key delegation) until it + reaches a payload; a repeated `(url, sha256)` is a cycle and fails closed. Binding + the payload + args under one manifest signature stops mix-and-match of independently + signed pieces. Optional `manifest.sha256` also pins the manifest's own bytes. + + ```json + { "_stage1": { "x86_64": { "manifest": { "url": "http://cdn.example.com/app.manifest.json", "ed25519": "" } } } } + ``` The payload must be a UEFI PE. However the firmware `db` feels about it, stage0 admits it by your pin/signature and measures it into **PCR 14** (= its SHA-256). +## Testing + +`make boot` builds the `db`-signed boot disk and boots it under QEMU, serving a small test +payload that reads the PCRs and prints them. Pick a mode with `SIGN=1` (ed25519), `SIGN_ARGS=1` +(signed LoadOptions), `MANIFEST=1` (resolve a signed `_stage1` manifest), `FALLBACK=1` (mirror +fallback), or `ARGS='[…]'` (inline LoadOptions). + +`make smoke-boot` runs the whole matrix as an **asserting** suite — each admission mode boots +`stage0 → test-payload` and verifies the payload actually chain-loaded (proving fetch → admit → +PCR-measure → chain-load), printing a per-mode PASS/FAIL summary. It needs nested KVM (local only) +and takes ~2 minutes. Everything is regenerated and signed reproducibly from the Makefile — no +manual steps. + +Under the test harness the payload runs with `--nosleep` in its LoadOptions (skipping its +EC2-only ~60s serial-flush hold, which QEMU doesn't need) and powers the machine off at the end +instead of returning to stage0 — so each boot exits promptly. A real EC2 deploy passes no such +flag and keeps the full drain; nothing is gated behind a separate build. + ## `_stage1` metadata reference -A `_stage1` object with an optional `args` and one entry per architecture. Each -arch entry needs `url` **and exactly one** of `sha256` or `ed25519`. +A `_stage1` object with one entry per architecture; the running arch's must be present. +Each arch entry is `{ "payload": {…} }` (needs `url` **and exactly one** of `sha256` / +`ed25519`) or `{ "manifest": {…} }` (needs `url` + `ed25519`). | Field | In | Type | Rules | |---|---|---|---| -| `args` | `_stage1` | `string[]` | optional; passed to the payload as UEFI load options | -| `x86_64` / `aarch64` | `_stage1` | object | per-arch entry; the running arch's must be present | -| `url` | arch entry | `string` | `http://…`, printable ASCII (TLS is not used) | -| `sha256` | arch entry | `string` | exactly 64 hex characters | -| `ed25519` | arch entry | `string` | base64 of a 32-byte public key | -| `sig_url` | arch entry | `string` | optional (signed mode); payload signature location, `{sha256}` → payload hash. Defaults to `.sig` | -| `args_url` | arch entry | `string` | optional (signed mode only); fetch signed load options here, `{sha256}` → payload hash. Overrides inline `args` | -| `args_sig_url` | arch entry | `string` | optional; signature for `args_url`, `{sha256}` → payload hash. Defaults to `.sig`. Requires `args_url` | +| `x86_64` / `aarch64` | `_stage1` | object | per-arch union entry; the running arch's must be present | +| `url` | payload / manifest | `string`/list | `http://…`, printable ASCII (TLS is not used) | +| `sha256` | payload | `string` | exactly 64 hex characters | +| `ed25519` | payload / manifest | `string` | base64 of a 32-byte public key | +| `sig_url` | payload / manifest | `string`/list | optional (signed mode); signature location, `{sha256}` → payload/manifest hash. Defaults to `.sig` | +| `args` | payload | `string[]` | optional inline UEFI load options | +| `args_url` | payload | `string`/list | optional (signed mode only); fetch signed load options here, `{sha256}` → payload hash. Overrides inline `args` | +| `args_sig_url` | payload | `string`/list | optional; signature for `args_url`, `{sha256}` → payload hash. Defaults to `.sig`. Requires `args_url` | +| `sha256` | manifest | `string` | optional; pins the manifest's own bytes (64 hex) | `args_url` content is verified against `ed25519` (the same release key as the payload) and used verbatim, trimmed, as the load-options string. diff --git a/crates/stage0-test-payload/src/main.rs b/crates/stage0-test-payload/src/main.rs index 7257159..1e4ccf2 100644 --- a/crates/stage0-test-payload/src/main.rs +++ b/crates/stage0-test-payload/src/main.rs @@ -21,6 +21,8 @@ use anyhow::{anyhow, bail, Result}; use uefi::boot::{self, ScopedProtocol}; use uefi::prelude::*; use uefi::println; +use uefi::runtime::{self, ResetType}; +use uefi::proto::loaded_image::LoadedImage; use uefi::proto::tcg::v2::Tcg; use vaportpm_attest::{PcrOps, TpmTransport}; @@ -57,18 +59,38 @@ fn main() -> Status { Err(e) => println!("payload: could not read PCRs: {e}"), } - // EC2's serial console buffer is only flushed periodically; if the payload - // returns immediately, stage0 falls through, the VM resets/terminates, and the - // PCR dump above never makes it out. Hold ~60s before handing back, with a - // heartbeat so it is visibly alive and the console keeps draining. - println!("payload: holding ~60s so the serial console drains..."); - for i in 1..=6 { - boot::stall(10_000_000); // 10s - println!("payload: drain {i}/6 (~{}s)", i * 10); + // EC2's serial console buffer is only flushed periodically; if the payload terminates + // immediately the PCR dump above never makes it out. Hold ~60s with a heartbeat so it is + // visibly alive and the console keeps flushing -- skipped when LoadOptions carry `--nosleep` + // (the QEMU test harness passes it, since it captures serial live and has no such lag). + if !load_options_contains("--nosleep") { + println!("payload: holding ~60s so the serial console flushes..."); + for i in 1..=6 { + boot::stall(10_000_000); // 10s + println!("payload: flush {i}/6 (~{}s)", i * 10); + } } println!("payload: done"); - Status::SUCCESS + // A test payload never boots an OS, so it never needs to hand control back to stage0. Power + // off cleanly instead of returning: a return makes stage0 treat it as an unexpected + // fall-through and run its own (~90s) fail-closed drain, needlessly slowing the boot. + runtime::reset(ResetType::SHUTDOWN, Status::SUCCESS, None) +} + +/// True if this image's UEFI LoadOptions (set by stage0 from `_stage1.args` / a signed manifest) +/// contain the ASCII `flag`. Lets the QEMU test harness (`--nosleep`) skip the EC2-only +/// serial-flush hold. LoadOptions are UCS-2, so an ASCII flag encodes as each byte followed by +/// `0x00`; we scan the raw bytes for that needle (avoids any CStr16 decode/normalisation). +fn load_options_contains(flag: &str) -> bool { + let Ok(li) = boot::open_protocol_exclusive::(boot::image_handle()) else { + return false; + }; + let Some(bytes) = li.load_options_as_bytes() else { + return false; + }; + let needle: Vec = flag.bytes().flat_map(|b| [b, 0]).collect(); + bytes.windows(needle.len()).any(|w| w == needle.as_slice()) } fn print_pcrs() -> Result<()> { diff --git a/crates/stage0/src/config.rs b/crates/stage0/src/config.rs index dc2360a..d5268c1 100644 --- a/crates/stage0/src/config.rs +++ b/crates/stage0/src/config.rs @@ -2,18 +2,18 @@ //! The `_stage1` metadata schema. //! -//! Mirrors `stage1`'s per-arch `{url, sha256}` structure (plus optional `args`) -//! but under a distinct `_stage1` key, so a UEFI payload is never confused with -//! a Linux `_stage2` binary in the same document. +//! Mirrors `stage1`'s per-arch discriminated union but under a distinct `_stage1` key, so a UEFI +//! payload is never confused with a Linux `_stage2` binary in the same document. Each arch entry +//! is either a [`Payload`] (admit a UEFI binary now, by sha256 pin or ed25519-signed) or a +//! [`ManifestRef`] (fetch a signed manifest — itself a `_stage1` fragment — and resolve it). //! -//! `args` (and the signed `args_url`) set the booted EFI program's UEFI *LoadOptions* -- -//! the generic way stage0 parameterizes whatever EFI image it chain-loads. They are -//! sourced ONLY from this metadata (or the signed URL); stage0 never forwards its own -//! firmware/shell invocation arguments to stage1. For a Linux UKI stage1 specifically, -//! the kernel command line is baked into the signed, measured UKI and is authoritative: -//! under Secure Boot the stub ignores LoadOptions, so `_stage1.args` cannot alter the UKI -//! cmdline (operator config for a UKI flows through `_stage2`, not the kernel cmdline). -//! A non-UKI EFI stage1 is free to read these LoadOptions as its arguments. +//! `args` (and the signed `args_url`) set the booted EFI program's UEFI *LoadOptions* -- the +//! generic way stage0 parameterizes whatever EFI image it chain-loads. They are sourced ONLY from +//! this metadata (or the signed URL / manifest); stage0 never forwards its own firmware/shell +//! invocation arguments to stage1. For a Linux UKI stage1 specifically, the kernel command line is +//! baked into the signed, measured UKI and is authoritative: under Secure Boot the stub ignores +//! LoadOptions, so `args` cannot alter the UKI cmdline (operator config for a UKI flows through +//! `_stage2`). A non-UKI EFI stage1 is free to read these LoadOptions as its arguments. use alloc::string::String; use alloc::vec::Vec; @@ -50,11 +50,6 @@ pub struct UserData { #[derive(Debug, Deserialize)] pub struct Stage1Config { - /// Inline UEFI LoadOptions for the booted stage1 EFI image (a non-UKI stage1 reads - /// these as its args). Overridden by the signed `args_url`. See the module docs for - /// how a Linux UKI treats these (baked cmdline wins; ignored under Secure Boot). - #[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))] @@ -65,43 +60,75 @@ pub struct Stage1Config { pub x86_64: Option, } +/// One architecture's admission entry: the discriminated union ([`Entry`]) plus the resolution +/// history accumulated by the manifest loop (used only for cycle detection — stage0 forwards no +/// doc, so the history is not surfaced anywhere). #[derive(Debug, Deserialize)] pub struct ArchConfig { + #[serde(flatten)] + pub entry: Entry, + // Deserialized so a merged manifest fragment carrying this key round-trips, but stage0 never + // reads it: it forwards no document (the UKI re-fetches its own metadata) and detects cycles + // with a loop-local record. stage1 is where this history is surfaced (into the stdin doc). + #[serde(default)] + #[allow(dead_code)] + pub resolved_manifests: Vec, +} + +/// The per-arch discriminated union: a concrete payload to admit, or a signed manifest to resolve. +#[derive(Debug, Deserialize)] +pub enum Entry { + #[serde(rename = "payload")] + Payload(Payload), + #[serde(rename = "manifest")] + Manifest(ManifestRef), +} + +/// A concrete payload admission. Exactly one of `sha256` (pin an exact hash) or `ed25519` (pin a +/// long-term release pubkey; the payload rolls forward gated by a detached `.sig`) selects the mode. +#[derive(Debug, Deserialize)] +pub struct Payload { pub url: UrlList, - // 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, - /// Where the detached ed25519 signature lives (signed mode). Any `{sha256}` - /// is replaced with the payload's hex digest, so the signature can be - /// content-addressed. Defaults to `.sig` when omitted. String or list. + /// Where the detached ed25519 signature lives (signed mode). `{sha256}` → payload digest. + /// Defaults to `.sig`. String or list. #[serde(default)] pub sig_url: Option, - /// Optional signed load options (ed25519 mode only). The args are fetched from - /// `args_url` (with `{sha256}` substituted), and their detached signature from - /// `args_sig_url` (with `{sha256}` substituted), or `.sig` when that - /// is omitted. The signature is verified against the same release key as the - /// payload; the verified bytes are used verbatim as the payload's UEFI load - /// options, overriding inline `args`. String or list. + /// Inline UEFI LoadOptions (overridden by a verified `args_url`). + #[serde(default)] + pub args: Option>, + /// Optional signed load options (ed25519 mode only): fetched from `args_url` (`{sha256}` + /// substituted), verified against the same release key via `args_sig_url` (else `.sig`), + /// used verbatim as the payload's LoadOptions, overriding inline `args`. String or list. #[serde(default)] pub args_url: Option, #[serde(default)] pub args_sig_url: Option, } -/// How stage0 admits the downloaded payload before measuring + loading it. -pub enum Verify { +/// A pointer to a signed manifest (also a resolution-history record). The manifest is fetched from +/// `url`, its detached signature (`sig_url`, else `.sig`) verified against the pinned `ed25519` +/// key, then deep-merged and re-evaluated. `sha256` optionally pins the manifest's own bytes. +#[derive(Debug, Clone, Deserialize)] +pub struct ManifestRef { + pub url: UrlList, + pub ed25519: String, + #[serde(default)] + pub sig_url: Option, + #[serde(default)] + pub sha256: Option, +} + +/// How stage0 admits a downloaded payload before measuring + loading it. +pub enum Admit { /// Payload's SHA-256 must equal this 64-hex string. Sha256(String), - /// Detached ed25519 signature must verify against this base64-encoded 32-byte - /// release public key. `sig_url` is where the payload signature is fetched from - /// (or `None` to default to `.sig`). `args_url`/`args_sig_url` optionally - /// add signed load options verified against the same key. All `*_url` values - /// still carry an unsubstituted `{sha256}`; the caller substitutes it. + /// Detached ed25519 signature must verify against this base64 32-byte release key. `sig_url` + /// is where the payload signature is fetched (or `None` → `.sig`); `args_url`/`args_sig_url` + /// optionally add signed load options. All `*_url` values still carry an unsubstituted `{sha256}`. Ed25519 { pubkey: String, sig_url: Option, @@ -129,16 +156,29 @@ impl Stage1Config { } } -impl ArchConfig { - /// Validate the URL and the (single) verification field, returning the - /// selected [`Verify`] mode. - pub fn validate(&self) -> Result { - // http:// only: stage0's TCP4 client speaks plain HTTP, TLS is not used - // (integrity comes from the pin/signature, not the transport). Rejecting - // https:// here turns an unfetchable URL into a clear config-time error - // rather than a late download failure. Each field is a URL or a fallback list. - let ok_url = |s: &str| s.starts_with("http://") && s.chars().all(|c| c.is_ascii_graphic()); - let ok_list = |l: &UrlList| !l.0.is_empty() && l.0.iter().all(|s| ok_url(s)); +// http:// only: stage0's TCP4 client speaks plain HTTP, TLS is not used (integrity comes from the +// pin/signature, not the transport). Rejecting https:// turns an unfetchable URL into a clear +// config-time error rather than a late download failure. +fn ok_url(s: &str) -> bool { + s.starts_with("http://") && s.chars().all(|c| c.is_ascii_graphic()) +} +fn ok_list(l: &UrlList) -> bool { + !l.0.is_empty() && l.0.iter().all(|s| ok_url(s)) +} +fn ok_sha256(hex: &str) -> bool { + hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) +} +fn ok_pubkey(s: &str) -> Result<(), &'static str> { + match STANDARD.decode(s.trim()) { + Ok(bytes) if bytes.len() == 32 => Ok(()), + Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), + Err(_) => Err("ed25519 pubkey must be base64"), + } +} + +impl Payload { + /// Validate the URL(s) + the (single) verification field, returning the selected [`Admit`] mode. + pub fn admission(&self) -> Result { if !ok_list(&self.url) { return Err("url must be a non-empty http:// URL (or list of them), printable ASCII (TLS unsupported)"); } @@ -156,34 +196,45 @@ impl ArchConfig { } match (&self.sha256, &self.ed25519) { (Some(_), Some(_)) => Err("specify only one of sha256 / ed25519"), - (None, None) => Err("must specify one of sha256 / ed25519"), + (None, None) => Err("payload must specify one of sha256 / ed25519"), (Some(hex), None) => { - // Signed args need the release key, which only signed mode pins. if self.args_url.is_some() { return Err("args_url requires ed25519 signed mode"); } - if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + if !ok_sha256(hex) { return Err("sha256 must be exactly 64 hex characters"); } - Ok(Verify::Sha256(hex.clone())) + Ok(Admit::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: pubkey.clone(), - sig_url: self.sig_url.clone(), - args_url: self.args_url.clone(), - args_sig_url: self.args_sig_url.clone(), - }), - Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), - Err(_) => Err("ed25519 pubkey must be base64"), - } + ok_pubkey(pubkey)?; + Ok(Admit::Ed25519 { + pubkey: pubkey.clone(), + sig_url: self.sig_url.clone(), + args_url: self.args_url.clone(), + args_sig_url: self.args_sig_url.clone(), + }) } } } } +impl ManifestRef { + /// Validate the manifest pointer (http-only). + pub fn validate(&self) -> Result<(), &'static str> { + if !ok_list(&self.url) { + return Err("manifest url must be a non-empty http:// URL (or list), printable ASCII"); + } + if self.sig_url.as_ref().is_some_and(|l| !ok_list(l)) { + return Err("manifest sig_url must be http:// URL(s), printable ASCII"); + } + if self.sha256.as_ref().is_some_and(|h| !ok_sha256(h)) { + return Err("manifest sha256 must be exactly 64 hex characters"); + } + ok_pubkey(&self.ed25519) + } +} + /// 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 _stage1 key") diff --git a/crates/stage0/src/main.rs b/crates/stage0/src/main.rs index cc90b22..16aecb5 100644 --- a/crates/stage0/src/main.rs +++ b/crates/stage0/src/main.rs @@ -35,7 +35,8 @@ mod udp4; use alloc::string::String; use alloc::vec::Vec; -use config::{UrlList, Verify}; +use config::{Admit, ArchConfig, Entry, ManifestRef, UrlList}; +use serde_json::Value; use sha2::{Digest, Sha256}; use uefi::boot; use uefi::prelude::*; @@ -81,7 +82,7 @@ fn run() -> Result<(), Status> { // Bring the network up once (DHCP), then fetch metadata. Metadata and payload // both ride the raw-TCP4 HTTP client (http.rs). - let (urls, verify, args) = { + let (binary, digest, opts) = { net::bringup()?; // An embedded `_stage1` section is part of the signed, measured PE, so it @@ -94,26 +95,12 @@ fn run() -> Result<(), Status> { } None => metadata::fetch()?, }; - let user_data = config::parse(&json).map_err(|m| { - crate::slog!("stage0: config error: {m}"); - Status::INVALID_PARAMETER - })?; - let arch = user_data.stage1.for_this_arch().ok_or_else(|| { - crate::slog!("stage0: no _stage1 config for this architecture"); - Status::UNSUPPORTED - })?; - let verify = arch.validate().map_err(|m| { - crate::slog!("stage0: invalid arch config: {m}"); - Status::INVALID_PARAMETER - })?; - (arch.url.0.clone(), verify, user_data.stage1.args.clone()) + // Resolve `_stage1.`: admit a payload directly, or follow a chain of signed + // manifests (each deep-merged into the doc and re-evaluated) until a payload is reached. + // Returns the admitted binary, its digest, and the load options (signed args, else inline). + resolve_payload(&json)? }; - // Try each mirror URL in order until one downloads and admits. Content is pinned, - // so any mirror that yields verifying bytes is acceptable (fallback for resiliency). - // Signed remote load options (ed25519 mode), if any, override the inline `args`. - let (binary, digest, signed_args) = admit_payload(&urls, &verify)?; - // 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 @@ -134,11 +121,8 @@ fn run() -> Result<(), Status> { crate::slog!("stage0: load_image failed: {status:?}"); })?; - // Load options: signed remote args (if any) override the inline `args`. The - // backing buffer must stay alive until after start_image. - let opts = signed_args.or_else(|| { - args.as_deref().filter(|a| !a.is_empty()).map(|a| a.join(" ")) - }); + // Load options were resolved above (signed args override inline). The backing buffer + // must stay alive until after start_image. let _options = set_load_options(image, opts.as_deref()); crate::slog!("stage0: starting payload"); @@ -176,13 +160,150 @@ fn download_first(urls: &[String]) -> Result, Status> { Err(last) } +/// Resolve `_stage1.` to a concrete payload and admit it. The entry is a discriminated union: +/// a `payload` is admitted directly (sha256 pin or ed25519-signed); a `manifest` is fetched, verified +/// against its pinned key, deep-merged into the doc, and the merged entry re-evaluated — a loop that +/// follows a chain of signed manifests (per-hop key delegation) until it reaches a payload. A repeated +/// (url,hash) is a cycle and fails closed. stage0 forwards no document (the UKI re-fetches its own +/// metadata), so the merged doc drives only re-evaluation. Returns the admitted binary, its digest, +/// and the load options (signed args, else inline `args` joined by spaces). +fn resolve_payload(json: &[u8]) -> Result<(Vec, [u8; 32], Option), Status> { + // Validate the document up front (clear error) + confirm this arch is present, then drive + // resolution off a Value so a signed manifest fragment can be deep-merged and re-evaluated. + let ud = config::parse(json).map_err(|m| { + crate::slog!("stage0: config error: {m}"); + Status::INVALID_PARAMETER + })?; + ud.stage1.for_this_arch().ok_or_else(|| { + crate::slog!("stage0: no _stage1 config for this architecture"); + Status::UNSUPPORTED + })?; + let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }; + let mut doc: Value = serde_json::from_slice(json).map_err(|_| Status::INVALID_PARAMETER)?; + let mut history: Vec = Vec::new(); + + loop { + let entry_val = doc + .get("_stage1") + .and_then(|s| s.get(arch)) + .ok_or_else(|| { + crate::slog!("stage0: no _stage1 config for this architecture"); + Status::UNSUPPORTED + })?; + let ac: ArchConfig = serde_json::from_value(entry_val.clone()).map_err(|_| { + crate::slog!("stage0: invalid _stage1 entry"); + Status::INVALID_PARAMETER + })?; + + match ac.entry { + Entry::Payload(p) => { + let mode = p.admission().map_err(|m| { + crate::slog!("stage0: invalid _stage1 payload: {m}"); + Status::INVALID_PARAMETER + })?; + let (binary, digest, signed_args) = admit_payload(&p.url.0, &mode)?; + let opts = signed_args.or_else(|| { + p.args.as_deref().filter(|a| !a.is_empty()).map(|a| a.join(" ")) + }); + return Ok((binary, digest, opts)); + } + Entry::Manifest(m) => { + m.validate().map_err(|e| { + crate::slog!("stage0: invalid _stage1 manifest: {e}"); + Status::INVALID_PARAMETER + })?; + let (murl, bytes, hash) = fetch_manifest(&m)?; + if history + .iter() + .any(|r| r.sha256.as_deref() == Some(hash.as_str()) && r.url.0 == [murl.clone()]) + { + crate::slog!("stage0: manifest resolution cycle at {murl}"); + return Err(Status::SECURITY_VIOLATION); + } + history.push(ManifestRef { + url: UrlList(alloc::vec![murl]), + ed25519: m.ed25519.clone(), + sig_url: m.sig_url.clone(), + sha256: Some(hash), + }); + // Consume the pointer, then deep-merge the manifest fragment (manifest wins). The + // merged entry re-populates with a `payload` (stop) or a fresh `manifest` (delegate). + if let Some(e) = doc.get_mut("_stage1").and_then(|s| s.get_mut(arch)).and_then(Value::as_object_mut) { + e.remove("manifest"); + } + let manifest_doc: Value = serde_json::from_slice(&bytes).map_err(|_| { + crate::slog!("stage0: manifest is not valid JSON"); + Status::INVALID_PARAMETER + })?; + deep_merge(&mut doc, &manifest_doc); + } + } + } +} + +/// Fetch a signed manifest (mirror fallback) and verify its detached signature against the pinned +/// key. Returns the serving URL, the verified bytes, and their hex sha256. +fn fetch_manifest(m: &ManifestRef) -> Result<(String, Vec, String), Status> { + let mut last = Status::NOT_FOUND; + for url in &m.url.0 { + match try_fetch_manifest(m, url) { + Ok((bytes, hash)) => return Ok((url.clone(), bytes, hash)), + Err(s) => { + crate::slog!("stage0: manifest rejected: {url} ({s:?})"); + last = s; + } + } + } + Err(last) +} + +fn try_fetch_manifest(m: &ManifestRef, url: &str) -> Result<(Vec, String), Status> { + let bytes = http::download(url)?; + let hash = hex::encode(sha256(&bytes)); + if let Some(pin) = &m.sha256 { + if !pin.eq_ignore_ascii_case(&hash) { + crate::slog!("stage0: manifest sha256 mismatch: expected {pin}, got {hash}"); + return Err(Status::SECURITY_VIOLATION); + } + } + let sig_urls = match &m.sig_url { + Some(u) => substitute(&u.0, &hash), + None => alloc::vec![alloc::format!("{url}.sig")], + }; + let signature = download_first(&sig_urls)?; + sig::verify(&m.ed25519, &bytes, &signature).map_err(|e| { + crate::slog!("stage0: manifest verification failed: {e}"); + Status::SECURITY_VIOLATION + })?; + crate::slog!("stage0: manifest verified: sha256:{hash} (ed25519 key:{})", m.ed25519); + Ok((bytes, hash)) +} + +/// Recursively merge `overlay` into `base`: two objects merge key-by-key (recursing on shared keys); +/// anything else takes `overlay`. `overlay` (the signed manifest) wins on every conflict. +fn deep_merge(base: &mut Value, overlay: &Value) { + match (base, overlay) { + (Value::Object(b), Value::Object(o)) => { + for (k, v) in o { + match b.get_mut(k) { + Some(existing) => deep_merge(existing, v), + None => { + b.insert(k.clone(), v.clone()); + } + } + } + } + (b, o) => *b = o.clone(), + } +} + /// Try each payload URL until one downloads and admits (content is pinned, so any mirror /// that yields verifying bytes is acceptable). Returns the bytes, their SHA-256 digest, /// and any verified signed load options. -fn admit_payload(urls: &[String], verify: &Verify) -> Result<(Vec, [u8; 32], Option), Status> { +fn admit_payload(urls: &[String], mode: &Admit) -> Result<(Vec, [u8; 32], Option), Status> { let mut last = Status::NOT_FOUND; for url in urls { - match admit_from(url, verify) { + match admit_from(url, mode) { Ok(result) => return Ok(result), Err(s) => { crate::slog!("stage0: payload url rejected: {url} ({s:?})"); @@ -194,22 +315,22 @@ fn admit_payload(urls: &[String], verify: &Verify) -> Result<(Vec, [u8; 32], } /// Download one payload candidate and run admission control (a gate — never measured). -fn admit_from(url: &str, verify: &Verify) -> Result<(Vec, [u8; 32], Option), Status> { +fn admit_from(url: &str, mode: &Admit) -> Result<(Vec, [u8; 32], Option), Status> { crate::sdbg!("stage0: downloading payload from {url}"); let binary = http::download(url)?; crate::slog!("stage0: payload: {} bytes from {url}", binary.len()); let digest = sha256(&binary); let hash = hex::encode(digest); let mut signed_args: Option = None; - match verify { - Verify::Sha256(expected) => { + match mode { + Admit::Sha256(expected) => { if !hash.eq_ignore_ascii_case(expected) { crate::slog!("stage0: SHA256 mismatch! expected {expected}, got {hash}"); return Err(Status::SECURITY_VIOLATION); } crate::slog!("stage0: verified: sha256:{hash} (sha256 pin)"); } - Verify::Ed25519 { pubkey, sig_url, args_url, args_sig_url } => { + Admit::Ed25519 { pubkey, sig_url, args_url, args_sig_url } => { // Detached signature: the `sig_url` templates with `{sha256}` replaced by the // payload digest (content-addressable), else `.sig` (co-located per mirror). let sig_urls = match sig_url {