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.

55 changes: 31 additions & 24 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,19 @@ test-chain:
# 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.
# from the local files so the doc can't go stale. Each arch entry is the discriminated union
# `{ "payload": {…} }` (admit a binary) or `{ "manifest": {…} }` (resolve a signed manifest). Modes:
# (default) sha256 pins for both hops (payload entries).
# 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.
# pubkey in the _stage1 / _stage2 payloads (roll forward under a stable key).
# SIGN_ARGS=1 (implies SIGN) also serve signed args.json (+ .sig) and set the _stage2 payload's
# args_url, exercising stage1's signed-remote-args path.
# MANIFEST=1 (with SIGN=1) resolve _stage2 through a signed manifest: build + sign a
# `{ "_stage2": { "<arch>": { "payload": {…} } } }` fragment, serve it, and pin
# `{ "manifest": { url, ed25519 } }` — exercising stage1's manifest resolution +
# top-level merge. Pair with ARGS (bound inside the manifest), not SIGN_ARGS.
# ARGS='[..]' set the _stage2 payload's inline 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 \
Expand All @@ -261,35 +266,37 @@ test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \
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; \
INLINE_ARGS=""; \
if [ -n '$(ARGS)' ] && [ -z "$(SIGN_ARGS)" ]; then INLINE_ARGS=", \"args\": $$(printf '%s' '$(ARGS)')"; echo "inline payload args = $(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\""; \
S1="\"$*\": { \"payload\": { \"url\": \"$$H/linux.efi\", \"ed25519\": \"$$PUB\" } }"; \
P2="\"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)"; \
cp build/$*/args.json "$$D/args.json"; cp build/$*/args.json.sig "$$D/args.json.sig"; \
P2="$$P2, \"args_url\": \"$$H/args.json\""; \
fi; \
if [ -n "$(MANIFEST)" ]; then \
S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \
printf '{ "_stage2": { "%s": { "payload": { "url": "%s/stage2", "sha256": "%s"%s } } } }\n' "$*" "$$H" "$$S2_SHA" "$$INLINE_ARGS" > "$$D/stage2.manifest.json"; \
$(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c \
"openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $$D/stage2.manifest.json -out $$D/stage2.manifest.json.sig"; \
S2="\"$*\": { \"manifest\": { \"url\": \"$$H/stage2.manifest.json\", \"ed25519\": \"$$PUB\" } }"; \
echo "user-data: _stage2 via signed manifest (pubkey $$PUB)"; \
else \
cp build/$*/stage2.sig "$$D/stage2.sig"; \
S2="\"$*\": { \"payload\": { $$P2$$INLINE_ARGS } }"; \
echo "user-data: signed mode (pubkey $$PUB)"; \
fi; \
S2="$$S2 }"; \
else \
UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \
S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \
S1="\"$*\": { \"url\": \"$$H/linux.efi\", \"sha256\": \"$$UKI_SHA\" }"; \
S2="\"$*\": { \"url\": $$S2URL, \"sha256\": \"$$S2_SHA\" }"; \
S1="\"$*\": { \"payload\": { \"url\": \"$$H/linux.efi\", \"sha256\": \"$$UKI_SHA\" } }"; \
S2="\"$*\": { \"payload\": { \"url\": $$S2URL, \"sha256\": \"$$S2_SHA\"$$INLINE_ARGS } }"; \
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"; \
fi; \
printf '{\n "_stage1": { %s },\n "_stage2": { %s%s }\n}\n' "$$S1" "$$S2ARGS" "$$S2" > user-data.stage0.json; \
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
41 changes: 26 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,48 +24,59 @@ make test-chain-x86_64 # sha256 admission (default)
make test-chain-x86_64 SIGN=1 # ed25519 signed-manifest admission
```

## stage2 manifest (`_stage2`)
## stage2 admission (`_stage2`)

stage1 admits its stage2 payload from a `_stage2` block in the instance's user-data, per architecture. Choose **one** admission mode per entry.
stage1 admits its stage2 payload from a `_stage2` block in the instance's user-data, per architecture. 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 payload:
**payload / sha256** — pin an exact binary:

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

**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:
**payload / ed25519** — pin a long-term release **public key** (base64 of 32 bytes). The binary rolls 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": {
"x86_64": { "payload": {
"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`. You don't hand-write these docs: the [`deploy` tool](#deploy) below signs the payloads and generates the `user-data.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`.

**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):
**manifest** — pin a release key **and** a manifest URL. stage1 fetches the signed manifest (itself a `_stage2` user-data fragment), verifies its detached signature (`<url>.sig`, override with `sig_url`) against the pinned `ed25519` key, **deep-merges the whole document into the received user-data at the top level** (manifest wins on conflict), and re-evaluates the entry. It loops — a manifest may resolve to a `payload` (done) or delegate to a *fresh* `manifest` (per-hop key delegation) — until a payload is reached; a repeated `(url, sha256)` is a cycle and fails closed. Binding the binary + args under one manifest signature stops a hostile mirror from mixing-and-matching independently-signed pieces:

```json
{
"_stage2": {
"x86_64": {
"x86_64": { "manifest": { "url": "https://host/stage2.manifest.json", "ed25519": "BASE64_32BYTE_PUBKEY" } }
}
}
```

The optional `manifest.sha256` also pins the manifest's own bytes. Every hop is recorded in the merged entry's `resolved_manifests` array (each with the resolved hash + verifying key) — verifier-authoritative provenance the payload sees on stdin. You don't hand-write these docs: the [`deploy` tool](#deploy) below signs the payloads/manifests and generates the `user-data.json`.

**Fallback URLs.** Every URL field (`url`, `sig_url`, `args_url`, `args_sig_url`, `manifest.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 the `*_url` fields may contain a `{sha256}` placeholder (replaced with the payload's — or manifest's — hex digest, for content-addressed signatures):

```json
{
"_stage2": {
"x86_64": { "payload": {
"url": ["https://cdn1/stage2", "https://cdn2/stage2"],
"ed25519": "BASE64_32BYTE_PUBKEY",
"sig_url": ["https://cdn1/sigs/{sha256}.sig", "https://cdn2/sigs/{sha256}.sig"]
}
} }
}
}
```
Expand All @@ -81,13 +92,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 the payload's inline **`args`** or its signed `args_url` (which overrides inline); in manifest mode these ride inside the signed manifest. They 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 signs (or hashes) the UKI + stage2 as `payload` entries — or, with `--manifest`, wraps each in a **signed manifest** and pins a `manifest` entry — 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).

```bash
lockboot-deploy create --arch x86_64 \
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