Skip to content
Closed
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
50 changes: 31 additions & 19 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -121,47 +121,59 @@ 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 <manifest>.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. ----
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)
Expand Down
70 changes: 40 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,54 +25,64 @@ it at your payload with a `_stage1` user-data document:
"sha256": "<64-hex sha256>"
},
"aarch64": {
"url": "http://cdn.example.com/app.efi",
"ed25519": "<base64 pubkey>",
"args_url": "http://cdn.example.com/app.args", // optional
"manifest_url": "http://cdn.example.com/app.manifest.json"
}
}
}
```

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 `<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`).
- **`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 `<manifest_url>.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 `<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` |

`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 `<manifest_url>.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`)

Expand Down
Loading