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
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.

105 changes: 38 additions & 67 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -203,30 +203,10 @@ 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 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"
# In ed25519 mode each hop is admitted via a signed **manifest** ({ url, sha256, args,
# version }); the manifests are built + signed inside the test-chain recipe (below) because
# their `args` and mirror lists vary per run. ed25519 is deterministic, so `openssl pkeyutl
# -rawin` yields the exact bytes stage0/stage1 verify against the pinned pubkey.

# Guard the arch-less form with a helpful message instead of "no rule to make target".
.PHONY: test-chain
Expand All @@ -238,18 +218,14 @@ test-chain:
# 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.
# ARGS='[..]' set inline _stage2.args to this JSON array (ignored when SIGN_ARGS is set,
# which supplies its own signed args). Used by the smoke-args-% target.
# 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)
# (default) sha256 pins for both hops (inline in the trusted user-data).
# SIGN=1 ed25519 for BOTH hops: build + sign a manifest per hop ({ url, sha256, args,
# version }), pin the release pubkey + manifest_url in _stage1 / _stage2.
# ARGS='[..]' stage2 args (JSON array). In SIGN mode they ride inside the signed manifest;
# in sha256 mode they are inline _stage2.args. Used by the smoke-args-% target.
# FALLBACK=1 make the _stage2 fetch URL a list [dead 127.0.0.1:9, real] so stage1's mirror
# fallback is exercised (SIGN: the manifest_url list; sha256: the payload url).
test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 $(if $(SIGN),build/keys/release.pem)
@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; \
Expand All @@ -259,54 +235,49 @@ test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \
@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"; \
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; \
UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \
S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \
ARGSJSON="[]"; if [ -n '$(ARGS)' ]; then ARGSJSON=$$(printf '%s' '$(ARGS)'); 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); \
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 }"; \
printf '{ "url": "%s/linux.efi", "sha256": "%s", "args": [], "version": 1 }\n' "$$H" "$$UKI_SHA" > "$$D/linux.efi.manifest.json"; \
printf '{ "url": "%s/stage2", "sha256": "%s", "args": %s, "version": 1 }\n' "$$H" "$$S2_SHA" "$$ARGSJSON" > "$$D/stage2.manifest.json"; \
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\
openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $$D/linux.efi.manifest.json -out $$D/linux.efi.manifest.json.sig && \
openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $$D/stage2.manifest.json -out $$D/stage2.manifest.json.sig"; \
S2MURL="\"$$H/stage2.manifest.json\""; \
if [ -n "$(FALLBACK)" ]; then S2MURL="[ \"http://127.0.0.1:9/stage2.manifest.json\", \"$$H/stage2.manifest.json\" ]"; echo "fallback: stage2 manifest_url = [dead 127.0.0.1:9, real]"; fi; \
S1="\"$*\": { \"ed25519\": \"$$PUB\", \"manifest_url\": \"$$H/linux.efi.manifest.json\" }"; \
S2="\"$*\": { \"ed25519\": \"$$PUB\", \"manifest_url\": $$S2MURL }"; \
echo "user-data: signed manifest mode (pubkey $$PUB, stage2 args $$ARGSJSON)"; \
printf '{\n "_stage1": { %s },\n "_stage2": { %s }\n}\n' "$$S1" "$$S2" > user-data.stage0.json; \
else \
UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \
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, real]"; fi; \
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; \
S2ARGS=""; \
if [ -n '$(ARGS)' ] && [ -z "$(SIGN_ARGS)" ]; then \
AJSON=$$(printf '%s' '$(ARGS)'); \
S2ARGS="\"args\": $$AJSON, "; \
echo "user-data: inline _stage2.args = $$AJSON"; \
S2ARGS=""; if [ -n '$(ARGS)' ]; then S2ARGS="\"args\": $$ARGSJSON, "; fi; \
echo "user-data: sha256 mode (UKI $$UKI_SHA, stage2 $$S2_SHA, args $$ARGSJSON)"; \
printf '{\n "_stage1": { %s },\n "_stage2": { %s%s }\n}\n' "$$S1" "$$S2ARGS" "$$S2" > user-data.stage0.json; \
fi; \
printf '{\n "_stage1": { %s },\n "_stage2": { %s%s }\n}\n' "$$S1" "$$S2ARGS" "$$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 $* \
--boot-disk "$(STAGE0_BOOT_DISK)" \
--serve-dir "$$D" --user-data user-data.stage0.json $(if $(TRACE),--trace)

# ---- Smoke test: _stage2 args actually reach the payload's argv ----
# Boot the full chain with a known inline args array (one arg contains a space, to prove
# it is a real argv vector and not shell word-splitting) and assert the payload echoed it.
# The signed-remote-args path is covered separately by `test-chain-% SIGN=1 SIGN_ARGS=1`.
# ---- Smoke test: _stage2 args reach the payload's argv (via the signed manifest) ----
# Boot the full chain in ed25519 signed mode with a known args array (one arg contains a
# space, to prove a real argv vector and not shell word-splitting) carried inside the signed
# manifest, and assert the payload echoed it. Also exercises the manifest->stdin merge. The
# sha256 inline-args path is the simpler subset (`test-chain-% ARGS=...` with no SIGN).
.PHONY: smoke-args-%
smoke-args-%:
@log="build/$*/smoke-args.log"; mkdir -p "build/$*"; \
$(MAKE) test-chain-$* ARGS='["--smoke","hello world"]' 2>&1 | tee "$$log"; \
$(MAKE) test-chain-$* SIGN=1 ARGS='["--smoke","hello world"]' 2>&1 | tee "$$log"; \
echo "=== smoke-args assertion ==="; \
if grep -q 'arg\[1\]: --smoke' "$$log" && grep -q 'arg\[2\]: hello world' "$$log"; then \
echo "PASS: inline _stage2.args reached the payload argv (spaces preserved)"; \
echo "PASS: signed-manifest _stage2.args reached the payload argv (spaces preserved)"; \
else \
echo "FAIL: expected 'arg[1]: --smoke' and 'arg[2]: hello world' in the console output"; \
exit 1; \
Expand Down
31 changes: 12 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,39 +40,32 @@ stage1 admits its stage2 payload from a `_stage2` block in the instance's user-d
}
```

**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:
**ed25519** — pin a long-term release **public key** (base64 of 32 bytes) and a `manifest_url`. stage1 fetches a **signed manifest** from that URL, verifies its detached signature against the pinned key, and admits the payload by the exact `sha256` the manifest names. Binding the payload **and** its args under one signature means a hostile mirror can neither mix-and-match independently-signed pieces nor roll the payload back to an old signed build — yet the release still rolls forward with **no reconfiguration**: re-sign a new manifest and reboot.

```json
{
"_stage2": {
"x86_64": {
"url": "https://host/stage2-amd64",
"ed25519": "BASE64_32BYTE_PUBKEY",
"args_url": "https://host/args.json"
"manifest_url": "https://host/stage2.manifest.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`. You don't hand-write these docs: the [`deploy` tool](#deploy) below signs the payloads and generates the `user-data.json`.

**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):
The manifest — mirror-served, updatable, signed — carries the payload URL(s), hash, args, and version:

```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"]
}
}
}
{ "url": "https://host/stage2-amd64", "sha256": "abc123...", "args": ["--flag", "value"], "version": 7 }
```

Its detached signature is fetched from `manifest_sig_url` (a `{sha256}` there is replaced with the **manifest's** hash, for content-addressing), defaulting to `<manifest_url>.sig`. You don't hand-write these docs: the [`deploy` tool](#deploy) below builds + signs the manifest and generates the `user-data.json`.

**Fallback URLs.** Every URL field — `url` and `sha256` (sha256 mode), `manifest_url` / `manifest_sig_url`, and the manifest's own `url` — accepts a single string **or a list** 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://`.

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

**Execution is pathless.** stage1 loads the payload into a sealed `memfd` (`F_SEAL_WRITE`) and `execveat`s it directly, so the bytes measured into PCR 14 are immutable and are exactly what runs — nothing is written to a named path where it could be swapped between measurement and exec. The payload receives the raw user-data JSON on **stdin** (a second in-memory file, so any runtime that reads stdin works — no extra-fd convention that would trip up Bun/Node single-file executables), and the pre-exec attestation at `/tmp/stage1.attest`.
**Execution is pathless.** stage1 loads the payload into a sealed `memfd` (`F_SEAL_WRITE`) and `execveat`s it directly, so the bytes measured into PCR 14 are immutable and are exactly what runs — nothing is written to a named path where it could be swapped between measurement and exec. The payload receives the user-data JSON on **stdin** (a second in-memory file, so any runtime that reads stdin works — no extra-fd convention that would trip up Bun/Node single-file executables). In ed25519 mode the verified manifest is merged into `_stage2.<arch>` first, so the payload sees the resolved `url`/`sha256`/`args`/`version` alongside your top-level operator keys; sha256 mode passes the doc through unchanged. The pre-exec attestation is written to `/tmp/stage1.attest`.

Any statically-linked Linux ELF works, as long as it reads its config from stdin; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`.

Expand All @@ -81,13 +74,13 @@ Any statically-linked Linux ELF works, as long as it reads its config from stdin
Two distinct hops, don't conflate them:

- **stage1's own config** comes from the cloud **metadata** service (the PID-1 boot path) or, when stage1 is run as a normal process, from a user-data doc **piped on stdin** (`stage1 < user-data.json`). There are no `--url`/`--file` flags — pipe it in. `--attest` remains for diagnostics.
- **The stage2 app's argv** comes from **`_stage2.args`** (inline) or the signed `args_url` (which overrides inline); these are handed to the payload as `argv[1..]` (with `argv[0] = "stage2"`).
- **The stage2 app's argv** comes from **`_stage2.args`** (inline, sha256 mode) or the signed **manifest's `args`** (ed25519 mode); these are handed to the payload as `argv[1..]` (with `argv[0] = "stage2"`).

Note on `_stage1.args`: that field belongs to **stage0**, which sets the booted EFI program's UEFI *LoadOptions* from it — the generic contract for any EFI stage1. For **this Linux UKI**, the kernel command line is baked into the signed, measured `.cmdline` and is authoritative: under Secure Boot the stub **ignores** LoadOptions, so `_stage1.args` cannot (and must not) alter the UKI cmdline. Configure a UKI-based stage1 through **`_stage2`**, not the kernel cmdline. See the [stage0 repo](https://github.com/lockboot/stage0) for the LoadOptions contract.

## Deploy

The **`deploy`** tool (binary `lockboot-deploy`) turns local build artifacts into an upload-ready deployment: it signs (or hashes) the UKI + stage2, composes **mirror URL lists** from repeated `--base-url`, and emits a directory plus a merged `user-data.json` carrying both `_stage1` (the UKI hop) and `_stage2` (the payload hop).
The **`deploy`** tool (binary `lockboot-deploy`) turns local build artifacts into an upload-ready deployment: it pins each payload by sha256 (sha256 mode) or emits a **signed manifest** per payload (ed25519 mode, `--key`), composes **mirror URL lists** from repeated `--base-url`, and emits a directory plus a merged `user-data.json` carrying both `_stage1` (the UKI hop) and `_stage2` (the payload hop). Args (`--args`) ride inside the signed manifest in ed25519 mode, or inline in sha256 mode; `--version` stamps the manifest.

```bash
lockboot-deploy create --arch x86_64 \
Expand All @@ -99,7 +92,7 @@ lockboot-deploy validate ./deploy # check against the admission rul
lockboot-deploy modify ./deploy --add-base-url http://cdn3 # add / --remove-base-url a mirror
```

`create` writes `deploy/<arch>/{linux.efi,stage2}` (+ `.sig` in signed mode) and merges `deploy/user-data.json`; sync the directory to each mirror and pass `user-data.json` as the instance's user-data. It shares the `metadata` types with the stage1 verifier, so what it emits is exactly what stage0/stage1 accept. (`tools/publish.sh` remains as a simpler UKI-only uploader; the bootable cloud image — the stage0 Secure Boot root — is published from the [stage0 repo](https://github.com/lockboot/stage0).)
`create` writes `deploy/<arch>/{linux.efi,stage2}` (plus `<payload>.manifest.json` + `.sig` per payload in signed mode) and merges `deploy/user-data.json`; sync the directory to each mirror and pass `user-data.json` as the instance's user-data. It shares the `metadata` types with the stage1 verifier, so what it emits is exactly what stage0/stage1 accept. (`tools/publish.sh` remains as a simpler UKI-only uploader; the bootable cloud image — the stage0 Secure Boot root — is published from the [stage0 repo](https://github.com/lockboot/stage0).)

## Crates

Expand Down
3 changes: 3 additions & 0 deletions crates/deploy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ ed25519-sign = { path = "../ed25519-sign" }
clap = { version = "4", features = ["derive"] }
serde_json = { workspace = true }
anyhow = { workspace = true }

[dev-dependencies]
base64 = { workspace = true }
Loading