-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMakefile
More file actions
234 lines (212 loc) · 14.2 KB
/
Copy pathMakefile
File metadata and controls
234 lines (212 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# 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 (sha256 mode by default)
# make test alias for boot
# 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
STAGE0_DIR = crates/stage0
# Default to the host architecture (uname -m gives x86_64 / aarch64 on Linux), so
# bare `make`, `make boot`, `make test` "just work". Override with ARCH=... or by
# using an explicit arch suffix, e.g. `make build-aarch64`.
ARCH ?= $(shell uname -m)
.DEFAULT_GOAL := build
# ---- Shared build harness (docker images + DOCKER_RUN plumbing) ----
# CANONICAL SOURCE lives here (stage0/build.mk); vendored into stage1/vaportpm via the
# workspace `make sync-harness` and guarded by `make check-harness`. Edit build.mk, not copies.
include build.mk
# stage0 owns the QEMU harness image (Dockerfile.harness); stage1 borrows the built lockboot:harness.
.PHONY: docker-build-harness
docker-build-harness:
docker build -f Dockerfile.harness -t $(HARNESS_IMAGE) .
# ---- Secure Boot keys: stage0's own snakeoil PK/KEK/db (regenerated per build) ----
# The pattern rule generates the whole set into build/keys via tools/gen-keys.sh;
# release.pem (below) is matched by its explicit rule instead.
build/keys/%: docker-build-base
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) ./tools/gen-keys.sh build/keys
# ---- stage0-sign: this repo's standalone host-side signer (ed25519 keygen + domain-separated
# signatures), so stage0 builds + tests without stage1's deploy tool. Built + run in the container.
# Built for the musl target (like stage1's deploy) so the signer is a fully static host binary.
# NB: named STAGE0_SIGN, not SIGN -- SIGN is the boot-test matrix's signed-mode flag (SIGN=1),
# and a command-line `SIGN=1` from the recursive $(MAKE) boot-% would otherwise clobber this path.
STAGE0_SIGN := crates/stage0-sign/target/x86_64-unknown-linux-musl/debug/stage0-sign
.PHONY: sign-bin sign-test
sign-bin: docker-build-base
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo build --manifest-path crates/stage0-sign/Cargo.toml --target x86_64-unknown-linux-musl
# Run stage0-sign's tests (incl. the golden known-answer vector shared with stage1's signer).
sign-test: docker-build-base
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo test --manifest-path crates/stage0-sign/Cargo.toml --target x86_64-unknown-linux-musl
# ---- CI gate: fmt + clippy + unit tests across all crates (what the branch ruleset requires) ----
# stage0 is not one cargo workspace: 4 independent crates in two target families. The EFI crates
# (ena, stage0, stage0-test-payload) are no_std UEFI and not host-testable -> fmt + clippy on the
# UEFI target; the host signer (stage0-sign) also runs its unit tests (incl. the golden vector).
UEFI_CRATES := ena stage0 stage0-test-payload
HOST_CRATES := stage0-sign
.PHONY: ci fmt-fix
fmt-fix: docker-build-base ## Apply rustfmt across all crates (no --check)
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c '\
for c in $(UEFI_CRATES) $(HOST_CRATES); do cargo fmt --manifest-path crates/$$c/Cargo.toml; done'
# ena.efi is include_bytes!'d by the stage0 crate, so it must exist before clippy/check can
# compile it (a clean CI checkout has no build/ artifacts; a warm local tree hid this).
ci: docker-build-base build/x86_64/ena.efi
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c '\
set -e; rustup target add x86_64-unknown-uefi x86_64-unknown-linux-musl; \
for c in $(UEFI_CRATES) $(HOST_CRATES); do \
echo ">> fmt-check $$c"; cargo fmt --manifest-path crates/$$c/Cargo.toml --check; \
done; \
for c in $(UEFI_CRATES); do \
echo ">> clippy $$c (uefi)"; cargo clippy --manifest-path crates/$$c/Cargo.toml --target x86_64-unknown-uefi -- -D warnings; \
done; \
echo ">> clippy stage0-sign (host)"; cargo clippy --manifest-path crates/stage0-sign/Cargo.toml --target x86_64-unknown-linux-musl --all-targets -- -D warnings; \
echo ">> test stage0-sign (host)"; cargo test --manifest-path crates/stage0-sign/Cargo.toml --target x86_64-unknown-linux-musl'
# ---- ed25519 release key for "signed mode" payload admission ----
# Vendor key; stage0 only ever sees the public half, pinned in the metadata doc. Generated by
# stage0-sign keygen (the domain-separated signer the verifier admits against).
build/keys/release.pem: docker-build-base | sign-bin
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\
mkdir -p build/keys && \
$(STAGE0_SIGN) keygen --out build/keys/release.pem --pub build/keys/release.pub.b64"
# ---- Build the ena.efi UEFI driver (embedded into stage0 for EC2/ENA netboot) ----
# A resident SimpleNetworkProtocol driver for the AWS ENA NIC, which Nitro's UEFI
# firmware does not provide. stage0 include_bytes! build/$*/ena.efi unconditionally.
# Mark precious: it is only a prerequisite of the stage0.efi pattern rule, so make
# would otherwise treat it as a chained intermediate and delete it after the build.
.PRECIOUS: build/%/ena.efi
build/%/ena.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/ena/Cargo.toml --target $*-unknown-uefi && cp -v crates/ena/target/$*-unknown-uefi/release/ena.efi $@"
# ---- Build the stage0 UEFI binary ----
# mkdir runs inside the container (as DOCKER_SAMEUSER) so the output dir is owned
# by the build user, not by the host caller (which is root under `gh act`).
# stage0 include_bytes! build/$*/ena.efi unconditionally (one measured unit), so the
# ena.efi prerequisite must be built first; nothing extra to pass on the command line.
build/%/stage0.efi: build/%/ena.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 $(STAGE0_DIR)/Cargo.toml --target $*-unknown-uefi && cp -v $(STAGE0_DIR)/target/$*-unknown-uefi/release/stage0.efi $@"
# ---- Assemble + db-sign the boot disk (privileged: losetup/mount) ----
build/%/boot.disk: build/%/stage0.efi build/keys/db.crt
$(DOCKER_RUN) -e ARCH=$* $(BUILD_IMAGE) ./tools/build.sh
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 ----
# 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 | sign-bin
$(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 $@ && \
$(STAGE0_SIGN) sign --domain stage1.uki --key build/keys/release.pem --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 | sign-bin
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\
mkdir -p build/$* && \
printf '%s' '[\"--from\",\"signed-args\",\"--nosleep\"]' > build/$*/args.json && \
$(STAGE0_SIGN) sign --domain stage1.args --key build/keys/release.pem \
--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. 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 [ -n "$(SIGN)$(SIGN_ARGS)$(MANIFEST)" ]; then \
PUB=$$(cat build/keys/release.pub.b64); \
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 \
"$(STAGE0_SIGN) sign --domain stage1.manifest --key build/keys/release.pem --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 "$$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 --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)
boot: boot-$(ARCH)
test: test-$(ARCH)
.PHONY: clean
# Remove per-arch build output and the cargo target/ trees in each crate workspace.
# Plain rm (not `cargo clean`) so it needs no docker image and works on a checkout
# that was never built. build/keys/ (snakeoil + release key) is left in place.
clean:
rm -rf build/x86_64 build/aarch64 crates/*/target