Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 92 additions & 24 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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=<file> serve a custom payload (sha256, or ed25519 if <file>.sig exists)
# USER_DATA=<doc> 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=<file> / USER_DATA=<doc>) 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
Expand Down Expand Up @@ -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/<file>) 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=<f> serve a custom payload instead of the built test payload.
# USER_DATA=<doc> 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)
Expand Down
71 changes: 50 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<base64 pubkey>",
"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 `<url>.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 (`<url>.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": "<base64 pubkey>" } } } }
```

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 `<url>.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 `<args_url>.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 `<url>.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 `<args_url>.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.
Expand Down
40 changes: 31 additions & 9 deletions crates/stage0-test-payload/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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::<LoadedImage>(boot::image_handle()) else {
return false;
};
let Some(bytes) = li.load_options_as_bytes() else {
return false;
};
let needle: Vec<u8> = flag.bytes().flat_map(|b| [b, 0]).collect();
bytes.windows(needle.len()).any(|w| w == needle.as_slice())
}

fn print_pcrs() -> Result<()> {
Expand Down
Loading