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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ sha2 = "0.10"
anyhow = "1.0"
hex = "0.4"
libc = "0.2"
# ed25519 verification for signed-mode payloads (verify-only). Same crate stage0/mkuki
# use — the detached .sig is a cross-repo wire contract.
ed25519-compact = { version = "2", default-features = false }

vaportpm-attest = { git = "https://github.com/lockboot/vaportpm" }

Expand Down
72 changes: 53 additions & 19 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -203,51 +203,85 @@ build/keys/release.pem: docker-build-base
openssl pkey -in build/keys/release.pem -pubout -outform DER \
| tail -c 32 | base64 -w0 > build/keys/release.pub.b64"

# Detached ed25519 signature over the whole UKI (SIGN=1). Deterministic per RFC 8032,
# so `openssl pkeyutl -rawin` yields the exact bytes stage0 verifies with the pinned
# pubkey (same approach stage0 uses to sign its own test payload). Served as
# linux.efi.sig; stage0 fetches <url>.sig when the manifest carries `ed25519`.
# Detached ed25519 sigs over the UKI and stage2 (SIGN=1). ed25519 is deterministic, so
# `openssl pkeyutl -rawin` yields the exact bytes stage0/stage1 verify against the pinned
# pubkey. Served as <name>.sig; each verifier fetches <url>.sig in ed25519 mode.
build/%/linux.efi.sig: tools/build-uki/%/linux.efi build/keys/release.pem
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\
mkdir -p build/$* && \
openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \
-in tools/build-uki/$*/linux.efi -out build/$*/linux.efi.sig"

build/%/stage2.sig: build/%/stage2 build/keys/release.pem
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\
mkdir -p build/$* && \
openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \
-in build/$*/stage2 -out build/$*/stage2.sig"

# Signed remote args for SIGN_ARGS=1: a JSON array of strings, ed25519-signed like the
# payloads. stage1 fetches args.json + args.json.sig, verifies against the pinned key,
# and uses them as argv (overriding inline _stage2.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\"]' > build/$*/args.json && \
openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \
-in build/$*/args.json -out build/$*/args.json.sig"

# Guard the arch-less form with a helpful message instead of "no rule to make target".
.PHONY: test-chain
test-chain:
@echo "'$@' needs an arch suffix, e.g. 'make $@-x86_64' or 'make $@-aarch64'." >&2
@exit 2

# Full-chain end-to-end test: stage0 -> UKI -> stage1 -> example-stage2, all served
# from one local dir (no S3). A single served user-data carries `_stage1` (stage0
# admits the UKI) and `_stage2` (stage1 admits the leaf by sha256); the two parsers
# coexist on distinct keys. Hashes are computed from the local files so the doc can
# never go stale. SIGN=1 additionally serves linux.efi.sig and pins the ed25519
# pubkey for `_stage1` instead of a sha256.
test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 $(if $(SIGN),build/%/linux.efi.sig)
# Full-chain end-to-end test: stage0 -> UKI -> stage1 -> example-stage2, served from one
# local dir. One served user-data carries `_stage1` (stage0 admits the UKI) and `_stage2`
# (stage1 admits the leaf); the two parsers coexist on distinct keys. Hashes are computed
# from the local files so the doc can't go stale. Modes:
# (default) sha256 pins for both hops.
# SIGN=1 ed25519 for BOTH hops: serve linux.efi.sig + stage2.sig, pin the release
# pubkey in _stage1 and _stage2 (payloads roll forward under a stable key).
# SIGN_ARGS=1 (implies SIGN) also serve signed args.json (+ .sig) and set _stage2.args_url,
# exercising stage1's signed-remote-args path.
# FALLBACK=1 make the _stage2 url a list [dead 127.0.0.1:9, real] so stage1's mirror
# fallback is exercised (the first url refuses, the second serves).
test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \
$(if $(SIGN),build/%/linux.efi.sig build/%/stage2.sig) \
$(if $(SIGN_ARGS),build/%/args.json.sig)
@if [ ! -f "$(STAGE0_BOOT_DISK)" ]; then \
echo "Missing external stage0 boot disk: $(STAGE0_BOOT_DISK)" >&2; \
echo "Build it first: (cd $(STAGE0_DIR) && make build-$*)" >&2; \
echo "or set STAGE0_BOOT_DISK=<path> to one unpacked from a stage0 release." >&2; \
exit 1; \
fi
@D="build/$*/chain"; rm -rf "$$D"; mkdir -p "$$D"; \
@D="build/$*/chain"; rm -rf "$$D"; mkdir -p "$$D"; H="http://$(SERVE_HOST)"; \
cp tools/build-uki/$*/linux.efi "$$D/linux.efi"; \
cp build/$*/stage2 "$$D/stage2"; \
S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \
S2URL="\"$$H/stage2\""; \
if [ -n "$(FALLBACK)" ]; then S2URL="[ \"http://127.0.0.1:9/stage2\", \"$$H/stage2\" ]"; echo "fallback: stage2 url = [dead 127.0.0.1:9, $$H/stage2]"; fi; \
if [ -n "$(SIGN)" ]; then \
cp build/$*/linux.efi.sig "$$D/linux.efi.sig"; \
cp build/$*/stage2.sig "$$D/stage2.sig"; \
PUB=$$(cat build/keys/release.pub.b64); \
printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "ed25519": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \
"$*" "$(SERVE_HOST)" "$$PUB" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \
echo "Wrote user-data.stage0.json (signed UKI, pubkey $$PUB; stage2 sha256 $$S2_SHA)"; \
S1="\"$*\": { \"url\": \"$$H/linux.efi\", \"ed25519\": \"$$PUB\" }"; \
S2="\"$*\": { \"url\": $$S2URL, \"ed25519\": \"$$PUB\""; \
if [ -n "$(SIGN_ARGS)" ]; then \
cp build/$*/args.json "$$D/args.json"; \
cp build/$*/args.json.sig "$$D/args.json.sig"; \
S2="$$S2, \"args_url\": \"$$H/args.json\""; \
echo "user-data: signed mode + signed args (pubkey $$PUB)"; \
else \
echo "user-data: signed mode (pubkey $$PUB)"; \
fi; \
S2="$$S2 }"; \
else \
UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \
printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "sha256": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \
"$*" "$(SERVE_HOST)" "$$UKI_SHA" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \
echo "Wrote user-data.stage0.json (chain: UKI sha256 $$UKI_SHA, stage2 sha256 $$S2_SHA)"; \
S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \
S1="\"$*\": { \"url\": \"$$H/linux.efi\", \"sha256\": \"$$UKI_SHA\" }"; \
S2="\"$*\": { \"url\": $$S2URL, \"sha256\": \"$$S2_SHA\" }"; \
echo "user-data: sha256 mode (UKI $$UKI_SHA, stage2 $$S2_SHA)"; \
fi; \
printf '{\n "_stage1": { %s },\n "_stage2": { %s }\n}\n' "$$S1" "$$S2" > user-data.stage0.json; \
$(DOCKER_RUN) $(DOCKER_OPT_KVM) \
-e YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1 --cap-add=NET_ADMIN --device=/dev/net/tun \
$(HARNESS_IMAGE) --kind stage0 --arch $* \
Expand Down
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Part of [Lock.Boot](https://github.com/lockboot) — see the org page for the whole boot chain. **stage1** is the netboot **UKI**: a Unified Kernel Image (Linux kernel + minimal initramfs + the `stage1` bootloader as PID 1) that [stage0](https://github.com/lockboot/stage0) fetches over the network, verifies, measures into PCR 14, and chain-loads.

Once running, `stage1` reads a `_stage2` manifest from cloud metadata (IMDSv2), downloads the stage2 payload, verifies it by `sha256`, extends a TPM PCR, generates an attestation document, and `exec`s it as PID 1.
Once running, `stage1` reads a `_stage2` manifest from cloud metadata (IMDSv2), downloads the stage2 payload, **admits it by a pinned `sha256` or an `ed25519` signature**, extends **PCR 14 with the payload hash** (loaded code only — never config), generates an attestation, and `exec`s it as PID 1.

## Build

Expand All @@ -26,18 +26,52 @@ make test-chain-x86_64 SIGN=1 # ed25519 signed-manifest admission

## stage2 manifest (`_stage2`)

stage1 admits its payload from a `_stage2` block in the instance's user-data:
stage1 admits its stage2 payload from a `_stage2` block in the instance's user-data, per architecture. Choose **one** admission mode per entry.

**sha256** — pin an exact payload:

```json
{
"_stage2": {
"x86_64": { "url": "https://example.com/stage2-amd64", "sha256": "abc123..." },
"aarch64": { "url": "https://example.com/stage2-arm64", "sha256": "def456..." },
"x86_64": { "url": "https://host/stage2-amd64", "sha256": "abc123..." },
"aarch64": { "url": "https://host/stage2-arm64", "sha256": "def456..." },
"args": ["--flag", "value"]
}
}
```

**ed25519** — pin a long-term release **public key** (base64 of 32 bytes). The payload can then roll forward with **no reconfiguration**: re-sign it, push it, reboot. stage1 fetches a detached signature at `<url>.sig` (override with `sig_url`; `{sha256}` is substituted) and verifies it against the pinned key:

```json
{
"_stage2": {
"x86_64": {
"url": "https://host/stage2-amd64",
"ed25519": "BASE64_32BYTE_PUBKEY",
"args_url": "https://host/args.json"
}
}
}
```

`args_url` (ed25519 mode only) fetches a **signed** JSON array of strings — verified against the same key via `<args_url>.sig` (or an explicit `args_sig_url`) — that **overrides** inline `args`. Generate configs with `stage1 --make-config <ARCH> <URL>` (sha256) or `stage1 --make-config-ed25519 <ARCH> <URL> <PUBKEY_B64>`; sign payloads with `openssl pkeyutl -sign -rawin` (the same key format `mkuki` uses, wire-compatible with stage0).

**Fallback URLs.** Every URL field (`url`, `sig_url`, `args_url`, `args_sig_url`) accepts either a single string **or a list of strings** tried in order — for mirror resiliency. Because the payload is cryptographically pinned, any mirror that yields verifying bytes is accepted; a dead or wrong mirror is simply skipped. URLs may be `http://` or `https://`, and `sig_url`/`args_url`/`args_sig_url` may contain a `{sha256}` placeholder (replaced with the payload's hex digest, for content-addressed signatures):

```json
{
"_stage2": {
"x86_64": {
"url": ["https://cdn1/stage2", "https://cdn2/stage2"],
"ed25519": "BASE64_32BYTE_PUBKEY",
"sig_url": ["https://cdn1/sigs/{sha256}.sig", "https://cdn2/sigs/{sha256}.sig"]
}
}
}
```

**Measurement is code-only.** stage1 extends **PCR 14** with the SHA-256 of the stage2 binary and nothing else — the admission pin / key / signature and the config JSON are *not* measured. This keeps the platform quote reproducible from the boot artifacts alone (stage0 → UKI → app), and leaves a stage2 app free to measure whatever config *it* deems trust-relevant (PCR 15 is left untouched for it).

Any statically-linked Linux ELF works; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`.

## Publish the UKI
Expand Down
1 change: 1 addition & 0 deletions crates/stage1/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ sha2 = { workspace = true }
hex = { workspace = true }
anyhow = { workspace = true }
base64 = { workspace = true }
ed25519-compact = { workspace = true }
vaportpm-attest = { workspace = true }
libc = { workspace = true }
Loading