From f3c77fd391f1c7d9714e3c734d5174868a5fa8c0 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:14:05 +0000 Subject: [PATCH] stage0: admit _stage1 via a signed manifest (fix mix-and-match) ed25519 signed mode now pins { ed25519 pubkey, manifest_url } in the (trusted) user-data instead of signing each artifact separately. stage0 fetches a signed manifest ({ url, sha256, args, version }), verifies its detached signature against the pinned key, then admits the payload by the manifest's exact sha256. Binding the payload + args under one signature closes the mix-and-match / downgrade hole (#2): a hostile mirror can neither recombine independently-signed pieces nor swap the payload, while the release still rolls forward by re-signing a new manifest. - config.rs: drop sig_url/args_url/args_sig_url; add manifest_url/manifest_sig_url plus a Manifest type; url/sha256 are now the sha256-mode fields. validate() enforces the per-mode shape. - main.rs: admit_payload dispatches by mode -- sha256 downloads from the inline url; ed25519 fetches+verifies the manifest (manifest_sig_url, {sha256} -> manifest hash, else .sig) then admits by manifest.sha256. Drop fetch_signed_args. - Makefile: the test payload is no longer self-signed; a signed manifest.json is built + served, and boot-% serves a directory. Verified: make boot-x86_64 admits via the manifest and runs the payload; a corrupted manifest signature is rejected (SECURITY_VIOLATION) and fails closed with the payload never running. Anti-rollback (version enforcement via TPM NV) and domain-separated signatures are deferred follow-ups on #2. Co-Authored-By: Claude Opus 4.8 --- Makefile | 50 +++++++---- README.md | 70 ++++++++------- crates/stage0/src/config.rs | 174 ++++++++++++++++++++++-------------- crates/stage0/src/main.rs | 166 ++++++++++++++++++++-------------- 4 files changed, 274 insertions(+), 186 deletions(-) diff --git a/Makefile b/Makefile index ce15438..6175503 100644 --- a/Makefile +++ b/Makefile @@ -121,16 +121,25 @@ build/%/boot.disk: build/%/stage0.efi build/keys/db.crt build-amd64 build-x86_64: build/x86_64/boot.disk build-arm64 build-aarch64: build/aarch64/boot.disk -# ---- Test payload: a chain-loaded UEFI app that reads PCRs, ed25519-signed ---- +# ---- Test payload: a chain-loaded UEFI app that reads PCRs ---- # Served at a hostname (not an IP) so the test also exercises EFI_DNS4; qemu-test.sh # maps payload.lockboot.test -> 10.0.2.1. Override SERVE_HOST=10.0.2.1:8000 to skip DNS. SERVE_HOST ?= payload.lockboot.test:8000 PAYLOAD_URL ?= http://$(SERVE_HOST)/payload.efi -build/%/payload.efi: docker-build-base build/keys/release.pem +build/%/payload.efi: docker-build-base $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ bash -c "mkdir -p build/$* && 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 build/keys/release.pem -rawin -in $@ -out $@.sig" + cp crates/stage0-test-payload/target/$*-unknown-uefi/release/stage0-test-payload.efi $@" + +# ---- Signed release manifest for the test payload (ed25519 mode) ---- +# Pins the payload url + sha256, ed25519-signed with the release key. stage0 fetches + verifies +# THIS (not a per-payload sig), then admits the payload by the pinned hash. The detached sig is +# co-located at .sig. +build/%/manifest.json: build/%/payload.efi build/keys/release.pem + $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ + SHA=\$$(sha256sum build/$*/payload.efi | cut -d' ' -f1); \ + printf '{ \"url\": \"$(PAYLOAD_URL)\", \"sha256\": \"%s\", \"args\": [], \"version\": 1 }\n' \"\$$SHA\" > $@ && \ + openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $@ -out $@.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. ---- @@ -138,30 +147,33 @@ 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 +# Boot stage0 under QEMU. Defaults to signed-manifest (ed25519) admission and regenerates the +# user-data each run so it can never go stale. The serve dir holds the payload + the signed +# manifest (+ its .sig). Knobs: SHA256=1 uses inline sha256 admission instead; FALLBACK=1 makes +# the _stage1 manifest_url a list [dead 10.0.2.1:9, real] so the mirror-fallback loop is exercised. +boot-%: build/%/boot.disk build/%/payload.efi build/%/manifest.json 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; \ + D="build/$*/serve"; rm -rf "$$D"; mkdir -p "$$D"; cp "$$P" "$$D/payload.efi"; \ + MURL="\"http://$(SERVE_HOST)/manifest.json\""; \ + if [ -n "$(FALLBACK)" ]; then MURL="[ \"http://10.0.2.1:9/manifest.json\", \"http://$(SERVE_HOST)/manifest.json\" ]"; echo "fallback: manifest_url = [dead 10.0.2.1:9, real]"; 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 \ - 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)"; \ - else \ + elif [ -n "$(SHA256)" ]; then \ SHA=$$(sha256sum "$$P" | cut -d' ' -f1); \ - printf '{\n "_stage1": {\n "%s": { "url": %s, "sha256": "%s" }\n }\n}\n' \ - "$*" "$$URLVAL" "$$SHA" > user-data.stage0.json; \ + printf '{\n "_stage1": { "%s": { "url": "http://$(SERVE_HOST)/payload.efi", "sha256": "%s" } }\n}\n' \ + "$*" "$$SHA" > user-data.stage0.json; \ echo "Wrote user-data.stage0.json (sha256 mode, $$SHA)"; \ + else \ + cp build/$*/manifest.json "$$D/manifest.json"; cp build/$*/manifest.json.sig "$$D/manifest.json.sig"; \ + PUB=$$(cat build/keys/release.pub.b64); \ + printf '{\n "_stage1": { "%s": { "ed25519": "%s", "manifest_url": %s } }\n}\n' \ + "$*" "$$PUB" "$$MURL" > user-data.stage0.json; \ + echo "Wrote user-data.stage0.json (signed manifest mode, release pubkey $$PUB)"; \ 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) diff --git a/README.md b/README.md index 9a89e6d..32469ec 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,8 @@ it at your payload with a `_stage1` user-data document: "sha256": "<64-hex sha256>" }, "aarch64": { - "url": "http://cdn.example.com/app.efi", "ed25519": "", - "args_url": "http://cdn.example.com/app.args", // optional + "manifest_url": "http://cdn.example.com/app.manifest.json" } } } @@ -35,44 +34,55 @@ it at your payload with a `_stage1` user-data document: Per arch, pick the admission mode: -- **`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 - 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`). +- **`sha256`**: pin an exact hash inline (`url` + `sha256`). Immutable; re-pin for every build. +- **`ed25519`**: pin a long-term release public key **and** a `manifest_url`. stage0 fetches a + **signed manifest** from that URL (`{ "url", "sha256", "args", "version" }`), verifies its + detached signature against the pinned key, then admits the payload by the manifest's exact + `sha256`. Because the payload and its args are bound under **one** signature, a hostile mirror + can neither mix-and-match independently-signed pieces nor roll the payload back to an old signed + build -- yet you still roll forward by re-signing a new manifest (the user-data is unchanged). + The signature is served at `.sig`, or a `manifest_sig_url` of your choice (a + `{sha256}` there is replaced with the *manifest's* hash, for content-addressing). 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). ## `_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 an optional inline `args` and one entry per architecture. Each arch +entry uses **exactly one** of `sha256` (static) or `ed25519` (signed manifest). | Field | In | Type | Rules | |---|---|---|---| -| `args` | `_stage1` | `string[]` | optional; passed to the payload as UEFI load options | +| `args` | `_stage1` | `string[]` | optional inline LoadOptions (sha256 mode; ed25519 mode uses the manifest's `args`) | | `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` | - -`args_url` content is verified against `ed25519` (the same release key as the -payload) and used verbatim, trimmed, as the load-options string. - -**Args model.** `args` / `args_url` set the booted EFI program's UEFI **LoadOptions** — -the generic way stage0 parameterizes whatever EFI image it chain-loads. They come **only** -from this metadata (or the signed URL); stage0 never forwards its own firmware/shell -invocation arguments to stage1. For a **Linux UKI** stage1, the kernel command line is -baked into the signed, measured UKI and is authoritative: under Secure Boot the systemd -stub **ignores** LoadOptions, so `args` cannot alter the UKI cmdline (and a replace would -also escape PCR 14). Production runs Secure Boot on; configure a UKI-based stage1 through -its `_stage2` document, not the kernel cmdline. A non-UKI EFI stage1 may read these -LoadOptions as its arguments. +| `url` | arch entry | `string`/list | **sha256 mode**: payload location(s), `http://…` printable ASCII (TLS not used) | +| `sha256` | arch entry | `string` | **sha256 mode**: exactly 64 hex characters | +| `ed25519` | arch entry | `string` | **ed25519 mode**: base64 of a 32-byte release public key | +| `manifest_url` | arch entry | `string`/list | **ed25519 mode**: where the signed manifest is fetched | +| `manifest_sig_url` | arch entry | `string`/list | optional; manifest signature location, `{sha256}` → *manifest* hash. Defaults to `.sig` | + +The **manifest** (ed25519 mode) is fetched from `manifest_url` and verified against `ed25519`: + +| Field | Type | Rules | +|---|---|---| +| `url` | `string`/list | payload location(s); a `{sha256}` is replaced with the `sha256` below | +| `sha256` | `string` | the payload's exact 64 hex digest | +| `args` | `string[]` | optional; passed to the payload as LoadOptions | +| `version` | number | optional monotonic release version (anti-rollback hint; not yet enforced) | + +Every URL field takes a single string or a fallback list (mirror resiliency); the content is +cryptographically pinned, so any mirror that verifies is accepted. + +**Args model.** `args` (inline, sha256 mode) or the signed manifest's `args` (ed25519 mode) set +the booted EFI program's UEFI **LoadOptions** — the generic way stage0 parameterizes whatever EFI +image it chain-loads. They come **only** from this metadata (or the signed manifest); stage0 never +forwards its own firmware/shell invocation arguments to stage1. For a **Linux UKI** stage1, the +kernel command line is baked into the signed, measured UKI and is authoritative: under Secure Boot +the systemd stub **ignores** LoadOptions, so these cannot alter the UKI cmdline (and a replace +would also escape PCR 14). Production runs Secure Boot on; configure a UKI-based stage1 through its +`_stage2` document, not the kernel cmdline. A non-UKI EFI stage1 may read these LoadOptions as its +arguments. ### Embedded metadata (self-contained `netboot.efi`) diff --git a/crates/stage0/src/config.rs b/crates/stage0/src/config.rs index dc2360a..6db5c4d 100644 --- a/crates/stage0/src/config.rs +++ b/crates/stage0/src/config.rs @@ -2,18 +2,25 @@ //! 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 admission structure under a distinct `_stage1` key, so a +//! UEFI payload is never confused with a Linux `_stage2` binary in the same document. //! -//! `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. +//! Two admission modes per arch entry: +//! - **sha256** -- pin an exact hash inline (`url` + `sha256` in the trusted user-data; +//! static payload). +//! - **ed25519** -- pin a release pubkey + a `manifest_url`. stage0 fetches a signed +//! **manifest** (`{ url, sha256, args, version }`), verifies it against the pinned key, +//! then admits the payload by the manifest's exact `sha256`. Binding the payload (and its +//! args) under ONE signature means a hostile mirror can neither mix-and-match +//! independently-signed pieces nor swap the payload, while the release still rolls forward +//! by re-signing a new manifest (the user-data is unchanged). +//! +//! `args` (inline, or from the signed manifest) set the booted EFI program's UEFI *LoadOptions* +//! -- the generic way stage0 parameterizes whatever EFI image it chain-loads. stage0 never +//! forwards its own firmware/shell invocation arguments to stage1. For a Linux UKI stage1 the +//! kernel command line is baked into the signed, measured UKI and is authoritative: under Secure +//! Boot the stub ignores LoadOptions, so these do not alter the UKI cmdline (operator config for +//! a UKI flows through `_stage2`). A non-UKI EFI stage1 may read these LoadOptions as its args. use alloc::string::String; use alloc::vec::Vec; @@ -50,9 +57,9 @@ 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). + /// Inline UEFI LoadOptions for the booted stage1 EFI image (sha256 mode; a non-UKI + /// stage1 reads these as its args). In ed25519 mode the signed manifest's `args` take + /// precedence. See the module docs for how a Linux UKI treats these. #[serde(default)] pub args: Option>, // Exactly one of these is read per build (see `for_this_arch`); the other @@ -67,46 +74,54 @@ pub struct Stage1Config { #[derive(Debug, Deserialize)] pub struct ArchConfig { - 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`. + /// Payload URL(s) for **sha256 mode** (the payload is pinned inline). In ed25519 mode the + /// payload URL comes from the signed manifest instead, so this is unused there. + #[serde(default)] + pub url: Option, + /// sha256 mode: the payload's exact 64-hex digest (immutable payload). #[serde(default)] pub sha256: Option, + /// ed25519 mode: a base64 32-byte release pubkey. The payload rolls forward via a signed + /// manifest at `manifest_url` (no metadata edits per update). #[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. + /// ed25519 mode: where the signed manifest is fetched from (string or fallback 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. + pub manifest_url: Option, + /// ed25519 mode: where the manifest's detached signature is fetched from. Any `{sha256}` + /// is replaced with the **retrieved manifest's** hex digest (content-addressed), so the + /// signature can be co-located per mirror. Defaults to `.sig`. String or list. #[serde(default)] - pub args_url: Option, + pub manifest_sig_url: Option, +} + +/// The signed release manifest (ed25519 mode). Fetched from `manifest_url` and verified against +/// the pinned release key; the payload is then admitted by its `sha256`. +#[derive(Debug, Deserialize)] +pub struct Manifest { + /// Payload URL(s) (mirror list). `{sha256}` is replaced with `sha256` below. + pub url: UrlList, + /// The payload's exact 64-hex digest. + pub sha256: String, + /// Args handed to the payload (LoadOptions for stage0's EFI child). + #[serde(default)] + pub args: Option>, + /// Monotonic release version (anti-rollback hint; enforcement is future work). #[serde(default)] - pub args_sig_url: Option, + pub version: u64, } -/// How stage0 admits the downloaded payload before measuring + loading it. +/// How stage0 admits the payload before measuring + loading it. pub enum Verify { - /// Payload's SHA-256 must equal this 64-hex string. + /// sha256 mode: the payload (from `ArchConfig::url`) must hash to 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. + /// ed25519 mode: fetch the manifest from `manifest_url`, verify its detached signature + /// (from `manifest_sig_url`, `{sha256}` -> manifest hash, else `.sig`) + /// against `pubkey`, then admit the payload by the manifest's `sha256`. Ed25519 { pubkey: String, - sig_url: Option, - args_url: Option, - args_sig_url: Option, + manifest_url: UrlList, + manifest_sig_url: Option, }, } @@ -129,52 +144,73 @@ 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)); - if !ok_list(&self.url) { - return Err("url must be a non-empty http:// URL (or list of them), printable ASCII (TLS unsupported)"); +// 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()) +} + +impl Manifest { + /// Parse + validate a fetched manifest (called after its signature is verified). + pub fn parse(json: &[u8]) -> Result { + let m: Manifest = serde_json::from_slice(json).map_err(|_| "manifest is not valid JSON")?; + if !ok_list(&m.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("sig_url must be http:// URL(s), printable ASCII"); + if !ok_sha256(&m.sha256) { + return Err("manifest sha256 must be exactly 64 hex characters"); } - if self.args_url.as_ref().is_some_and(|l| !ok_list(l)) { - return Err("args_url must be http:// URL(s), printable ASCII"); + Ok(m) + } +} + +impl ArchConfig { + /// Validate the arch entry and return the selected [`Verify`] mode. + pub fn validate(&self) -> Result { + if self.manifest_url.as_ref().is_some_and(|l| !ok_list(l)) { + return Err("manifest_url must be http:// URL(s), printable ASCII"); } - if self.args_sig_url.as_ref().is_some_and(|l| !ok_list(l)) { - return Err("args_sig_url must be http:// URL(s), printable ASCII"); + if self.manifest_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.args_sig_url.is_some() && self.args_url.is_none() { - return Err("args_sig_url requires args_url"); + if self.manifest_sig_url.is_some() && self.manifest_url.is_none() { + return Err("manifest_sig_url requires manifest_url"); } 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) => { - // 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"); + // sha256 (static) mode: the payload is pinned inline and fetched from `url`. + if self.manifest_url.is_some() { + return Err("manifest_url requires ed25519 signed mode"); + } + let url = self.url.as_ref().ok_or("sha256 mode requires url")?; + if !ok_list(url) { + return Err("url must be a non-empty http:// URL (or list), printable ASCII (TLS unsupported)"); } - 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())) } (None, Some(pubkey)) => { - // A raw ed25519 public key is 32 bytes. + // ed25519 (roll-forward) mode: a signed manifest pins the payload. + let manifest_url = self + .manifest_url + .clone() + .ok_or("ed25519 mode requires manifest_url")?; 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(), + manifest_url, + manifest_sig_url: self.manifest_sig_url.clone(), }), Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), Err(_) => Err("ed25519 pubkey must be base64"), diff --git a/crates/stage0/src/main.rs b/crates/stage0/src/main.rs index cc90b22..7b8c47f 100644 --- a/crates/stage0/src/main.rs +++ b/crates/stage0/src/main.rs @@ -35,7 +35,7 @@ mod udp4; use alloc::string::String; use alloc::vec::Vec; -use config::{UrlList, Verify}; +use config::{Manifest, UrlList, Verify}; use sha2::{Digest, Sha256}; use uefi::boot; use uefi::prelude::*; @@ -81,7 +81,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 (verify, sha256_urls, inline_args) = { net::bringup()?; // An embedded `_stage1` section is part of the signed, measured PE, so it @@ -106,13 +106,14 @@ fn run() -> Result<(), Status> { crate::slog!("stage0: invalid arch config: {m}"); Status::INVALID_PARAMETER })?; - (arch.url.0.clone(), verify, user_data.stage1.args.clone()) + // `url` is only used in sha256 mode; ed25519 mode carries its URLs in the manifest. + (verify, arch.url.clone(), user_data.stage1.args.clone()) }; - // 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)?; + // Admit the payload: sha256 mode downloads from the inline `url` and checks the pin; + // ed25519 mode fetches + verifies a signed manifest first, then admits by the hash it + // names. Any signed args (from the manifest) come back to override inline `args`. + let (binary, digest, signed_args) = admit_payload(&verify, sha256_urls.as_ref())?; // Measure before executing. Only PCR 14 (the binary): the config/key are not // measured, so attestation is simply "stage0 ran and loaded this hash". @@ -134,10 +135,10 @@ fn run() -> Result<(), Status> { crate::slog!("stage0: load_image failed: {status:?}"); })?; - // Load options: signed remote args (if any) override the inline `args`. The + // Load options: manifest args (ed25519 mode), 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(" ")) + inline_args.as_deref().filter(|a| !a.is_empty()).map(|a| a.join(" ")) }); let _options = set_load_options(image, opts.as_deref()); @@ -176,14 +177,45 @@ fn download_first(urls: &[String]) -> Result, Status> { Err(last) } -/// 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> { +/// Admit the payload. sha256 mode downloads from the inline `url` and checks the pin; ed25519 +/// mode fetches + verifies a signed manifest, then admits the payload by the manifest's hash. +/// Returns the bytes, their SHA-256 digest, and any signed load options (from the manifest). +fn admit_payload( + verify: &Verify, + sha256_urls: Option<&UrlList>, +) -> Result<(Vec, [u8; 32], Option), Status> { + match verify { + Verify::Sha256(expected) => { + // Static pin: the payload URL(s) come from the (trusted) user-data. + let urls = sha256_urls.ok_or(Status::INVALID_PARAMETER)?; + admit_by_hash(&urls.0, expected, None) + } + Verify::Ed25519 { pubkey, manifest_url, manifest_sig_url } => { + // Roll-forward: one signed manifest binds the payload hash + args under one key, + // so a hostile mirror can neither mix-and-match nor swap the payload. + let manifest = fetch_manifest(pubkey, manifest_url, manifest_sig_url.as_ref())?; + let urls = substitute(&manifest.url.0, &manifest.sha256); + let args = manifest + .args + .as_ref() + .filter(|a| !a.is_empty()) + .map(|a| a.join(" ")); + admit_by_hash(&urls, &manifest.sha256, args) + } + } +} + +/// Try each mirror until one downloads and matches `expected_hex` (content is pinned, so any +/// mirror yielding the right bytes is acceptable). Threads `args` (signed load options) through. +fn admit_by_hash( + urls: &[String], + expected_hex: &str, + args: Option, +) -> Result<(Vec, [u8; 32], Option), Status> { let mut last = Status::NOT_FOUND; for url in urls { - match admit_from(url, verify) { - Ok(result) => return Ok(result), + match download_verify(url, expected_hex) { + Ok((binary, digest)) => return Ok((binary, digest, args)), Err(s) => { crate::slog!("stage0: payload url rejected: {url} ({s:?})"); last = s; @@ -193,79 +225,77 @@ fn admit_payload(urls: &[String], verify: &Verify) -> Result<(Vec, [u8; 32], Err(last) } -/// 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> { +/// Download one payload candidate and check its SHA-256 against `expected_hex` (a gate — the +/// hash itself is never measured; PCR 14 gets the loaded binary). +fn download_verify(url: &str, expected_hex: &str) -> Result<(Vec, [u8; 32]), 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) => { - 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 } => { - // 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 { - Some(u) => substitute(&u.0, &hash), - None => alloc::vec![alloc::format!("{url}.sig")], - }; - let signature = download_first(&sig_urls)?; - sig::verify(pubkey, &binary, &signature).map_err(|m| { - crate::slog!("stage0: ed25519 verification failed: {m}"); - Status::SECURITY_VIOLATION - })?; - crate::slog!("stage0: verified: sha256:{hash} (ed25519 key:{pubkey})"); - if let Some(au) = args_url { - signed_args = Some(fetch_signed_args(&au.0, args_sig_url.as_ref(), pubkey, &hash)?); + if !hash.eq_ignore_ascii_case(expected_hex) { + crate::slog!("stage0: SHA256 mismatch! expected {expected_hex}, got {hash}"); + return Err(Status::SECURITY_VIOLATION); + } + crate::slog!("stage0: verified: sha256:{hash}"); + Ok((binary, digest)) +} + +/// Fetch + verify the signed release manifest (ed25519 mode). Tries each `manifest_url` mirror; +/// a candidate is accepted only if it downloads, its detached signature verifies against +/// `pubkey`, and it parses as a valid [`Manifest`]. The signature comes from `manifest_sig_url` +/// (with `{sha256}` replaced by the retrieved manifest's own digest), else `.sig`. +fn fetch_manifest( + pubkey: &str, + manifest_url: &UrlList, + manifest_sig_url: Option<&UrlList>, +) -> Result { + let mut last = Status::NOT_FOUND; + for murl in &manifest_url.0 { + match try_fetch_manifest(pubkey, murl, manifest_sig_url) { + Ok(m) => return Ok(m), + Err(s) => { + crate::slog!("stage0: manifest rejected: {murl} ({s:?})"); + last = s; } } } - Ok((binary, digest, signed_args)) + Err(last) } -/// Fetch and verify signed load options (ed25519 mode). `args_url`/`args_sig_url` may -/// contain `{sha256}` (replaced with the payload digest) and may each be a fallback list. -/// The detached signature (from `args_sig_url`, else `.sig`) must verify against -/// the release `pubkey`; the verified bytes are returned verbatim (trimmed) as load options. -fn fetch_signed_args( - args_urls: &[String], - args_sig_url: Option<&UrlList>, +fn try_fetch_manifest( pubkey: &str, - payload_hash: &str, -) -> Result { - let args_urls = substitute(args_urls, payload_hash); - let sig_urls = match args_sig_url { - Some(u) => substitute(&u.0, payload_hash), - None => args_urls.iter().map(|u| alloc::format!("{u}.sig")).collect(), + murl: &str, + manifest_sig_url: Option<&UrlList>, +) -> Result { + let bytes = http::download(murl)?; + let mhash = hex::encode(sha256(&bytes)); + let sig_urls = match manifest_sig_url { + Some(u) => substitute(&u.0, &mhash), + None => alloc::vec![alloc::format!("{murl}.sig")], }; - let args = download_first(&args_urls)?; - let sig = download_first(&sig_urls)?; - sig::verify(pubkey, &args, &sig).map_err(|m| { - crate::slog!("stage0: signed args verification failed: {m}"); + let signature = download_first(&sig_urls)?; + sig::verify(pubkey, &bytes, &signature).map_err(|m| { + crate::slog!("stage0: manifest signature invalid: {m}"); Status::SECURITY_VIOLATION })?; - let opts = core::str::from_utf8(&args) - .map_err(|_| { - crate::slog!("stage0: signed args are not valid UTF-8"); - Status::INVALID_PARAMETER - })? - .trim(); - crate::slog!("stage0: args: {} bytes signed (ed25519)", opts.len()); - Ok(opts.into()) + let manifest = Manifest::parse(&bytes).map_err(|m| { + crate::slog!("stage0: invalid manifest: {m}"); + Status::INVALID_PARAMETER + })?; + crate::slog!( + "stage0: manifest verified (sha256:{}, version {}, key:{pubkey})", + manifest.sha256, + manifest.version + ); + Ok(manifest) } /// Set the loaded image's load options from the final `opts` string (UCS-2). /// Returns the backing [`CString16`], which the caller must keep alive until /// `start_image`. /// -/// `opts` is sourced only from the metadata `_stage1.args` or the signed `args_url` +/// `opts` is sourced only from the metadata `_stage1.args` or the signed manifest's `args` /// (see `run`); stage0 never reads or forwards its own firmware/shell invocation /// arguments to the child. For a Linux UKI child these LoadOptions would be the kernel /// command line, but the UKI bakes + measures its own `.cmdline` and the stub ignores