diff --git a/Cargo.lock b/Cargo.lock index aeb4270..810f660 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,7 @@ name = "deploy" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", "ed25519-sign", "metadata", diff --git a/Makefile b/Makefile index ebea154..d579074 100644 --- a/Makefile +++ b/Makefile @@ -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 .sig; each verifier fetches .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 @@ -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; \ @@ -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; \ diff --git a/README.md b/README.md index 4491f55..cf13051 100644 --- a/README.md +++ b/README.md @@ -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 `.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 `.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 `.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.` 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`. @@ -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 \ @@ -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//{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//{linux.efi,stage2}` (plus `.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 diff --git a/crates/deploy/Cargo.toml b/crates/deploy/Cargo.toml index df5c740..e0e822f 100644 --- a/crates/deploy/Cargo.toml +++ b/crates/deploy/Cargo.toml @@ -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 } diff --git a/crates/deploy/src/main.rs b/crates/deploy/src/main.rs index 2132140..20d7bd0 100644 --- a/crates/deploy/src/main.rs +++ b/crates/deploy/src/main.rs @@ -9,7 +9,7 @@ use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Parser, Subcommand}; use ed25519_sign::{sha256_hex, sign_payload}; -use metadata::{ArchConfig, Profile, StageConfig, UrlList, UserData}; +use metadata::{ArchConfig, Manifest, Profile, StageConfig, UrlList, UserData}; use serde_json::{Map, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -44,20 +44,21 @@ struct CreateArgs { /// The stage2 payload served for the _stage2 hop (admitted by stage1). #[arg(long)] stage2: PathBuf, - /// ed25519 PKCS#8 PEM. Given → sign both payloads and pin the pubkey (roll-forward); - /// omitted → pin an exact sha256 of each. + /// ed25519 PKCS#8 PEM. Given → emit a signed manifest per payload and pin the pubkey + /// (roll-forward); omitted → pin an exact sha256 of each inline. #[arg(long)] key: Option, /// Mirror base URL (repeatable). http:// only — stage0 has no TLS; integrity is the pin. /// URLs are composed as `//`. #[arg(long = "base-url", required = true)] base_url: Vec, - /// Inline args for stage2: a JSON array of strings, e.g. '["--flag","v"]'. + /// Args for stage2: a JSON array of strings, e.g. '["--flag","v"]'. In ed25519 mode these + /// ride inside the signed manifest; in sha256 mode they are inline in the (trusted) user-data. #[arg(long)] args: Option, - /// Serve --args as a signed remote blob (ed25519 mode) instead of inline. - #[arg(long, requires = "key", requires = "args")] - sign_args: bool, + /// Release version stamped into the signed manifest (ed25519 mode); an anti-rollback hint. + #[arg(long, default_value_t = 1)] + version: u64, /// Output directory (created if missing). user-data.json is merged across arches. #[arg(long)] out: PathBuf, @@ -85,8 +86,10 @@ fn compose_urls(bases: &[String], arch: &str, filename: &str) -> UrlList { UrlList(bases.iter().map(|b| format!("{b}/{arch}/{filename}")).collect()) } -/// Write `src`'s bytes into `/`, then either sign it (→ `.sig` + pinned -/// pubkey) or hash it (→ sha256 pin). Returns the arch entry with its composed URL list. +/// Write `src`'s bytes into `/` (served), then admit-pin it. With a key +/// (ed25519 mode) emit a **signed manifest** — `{ url, sha256, args, version }` written to +/// `.manifest.json` (+ `.sig`) — and return an entry pointing at it. Without a key +/// (sha256 mode) return an inline `{ url, sha256 }` entry (`args`/`version` are unused there). fn build_entry( arch_dir: &Path, arch: &str, @@ -94,20 +97,42 @@ fn build_entry( filename: &str, src: &Path, key_pem: Option<&str>, + args: Option<&[String]>, + version: u64, ) -> Result { let bytes = fs::read(src).with_context(|| format!("reading {}", src.display()))?; fs::write(arch_dir.join(filename), &bytes) .with_context(|| format!("writing {}/{filename}", arch_dir.display()))?; let url = compose_urls(bases, arch, filename); - let (sha256, ed25519) = match key_pem { + match key_pem { Some(pem) => { - let s = sign_payload(pem, &bytes)?; - fs::write(arch_dir.join(format!("{filename}.sig")), &s.signature)?; - (None, Some(s.pubkey_b64)) + let manifest = Manifest { + url, + sha256: sha256_hex(&bytes), + args: args.map(<[String]>::to_vec), + version, + }; + let manifest_json = serde_json::to_vec_pretty(&manifest)?; + let manifest_name = format!("{filename}.manifest.json"); + fs::write(arch_dir.join(&manifest_name), &manifest_json)?; + let s = sign_payload(pem, &manifest_json)?; + fs::write(arch_dir.join(format!("{manifest_name}.sig")), &s.signature)?; + Ok(ArchConfig { + url: None, + sha256: None, + ed25519: Some(s.pubkey_b64), + manifest_url: Some(compose_urls(bases, arch, &manifest_name)), + manifest_sig_url: None, // verifier defaults to .sig (co-located) + }) } - None => (Some(sha256_hex(&bytes)), None), - }; - Ok(ArchConfig { url, sha256, ed25519, sig_url: None, args_url: None, args_sig_url: None }) + None => Ok(ArchConfig { + url: Some(url), + sha256: Some(sha256_hex(&bytes)), + ed25519: None, + manifest_url: None, + manifest_sig_url: None, + }), + } } fn create(a: CreateArgs) -> Result<()> { @@ -126,25 +151,22 @@ fn create(a: CreateArgs) -> Result<()> { .map(|p| fs::read_to_string(p).with_context(|| format!("reading key {}", p.display()))) .transpose()?; - let uki_entry = build_entry(&arch_dir, &a.arch, &bases, "linux.efi", &a.uki, key_pem.as_deref())?; - let mut stage2_entry = build_entry(&arch_dir, &a.arch, &bases, "stage2", &a.stage2, key_pem.as_deref())?; - - // Args: inline, or signed-and-served remotely. - let mut inline_args: Option> = None; - if let Some(args_json) = &a.args { - let parsed: Vec = - serde_json::from_str(args_json).context("--args must be a JSON array of strings")?; - if a.sign_args { - let blob = serde_json::to_vec(&parsed)?; - fs::write(arch_dir.join("args.json"), &blob)?; - let pem = key_pem.as_deref().expect("clap requires --key with --sign-args"); - let s = sign_payload(pem, &blob)?; - fs::write(arch_dir.join("args.json.sig"), &s.signature)?; - stage2_entry.args_url = Some(compose_urls(&bases, &a.arch, "args.json")); - } else { - inline_args = Some(parsed); - } - } + // stage2 args: in ed25519 mode they ride inside the signed manifest; in sha256 mode they are + // inline in the (trusted) user-data. The UKI (_stage1) takes no args here. + let args_parsed: Option> = a + .args + .as_ref() + .map(|j| serde_json::from_str(j).context("--args must be a JSON array of strings")) + .transpose()?; + let signed = key_pem.is_some(); + let manifest_args = if signed { args_parsed.as_deref() } else { None }; + let inline_args = if signed { None } else { args_parsed.clone() }; + + let uki_entry = + build_entry(&arch_dir, &a.arch, &bases, "linux.efi", &a.uki, key_pem.as_deref(), None, a.version)?; + let stage2_entry = build_entry( + &arch_dir, &a.arch, &bases, "stage2", &a.stage2, key_pem.as_deref(), manifest_args, a.version, + )?; // Fail early on a bad config, in the profile each hop will actually be checked under. uki_entry.validate(Profile::Stage0).map_err(|m| anyhow!("_stage1 entry invalid: {m}"))?; @@ -242,7 +264,7 @@ fn modify(a: ModifyArgs) -> Result<()> { continue; } let Some(em) = entry.as_object_mut() else { continue }; - for field in ["url", "sig_url", "args_url", "args_sig_url"] { + for field in ["url", "manifest_url", "manifest_sig_url"] { if let Some(v) = em.get_mut(field) { *v = rewrite_urls(v, &adds, &rems)?; } @@ -337,7 +359,7 @@ mod tests { key: None, base_url: vec!["http://cdn1".into(), "http://cdn2".into()], args: None, - sign_args: false, + version: 1, out: out.clone(), }) .unwrap(); @@ -346,8 +368,64 @@ mod tests { validate(&out).unwrap(); let ud: UserData = serde_json::from_str(&fs::read_to_string(out.join("user-data.json")).unwrap()).unwrap(); - assert_eq!(ud.stage2.unwrap().x86_64.unwrap().url.0.len(), 2); + assert_eq!(ud.stage2.unwrap().x86_64.unwrap().url.unwrap().0.len(), 2); assert!(ud.stage1.unwrap().x86_64.unwrap().sha256.is_some()); let _ = fs::remove_dir_all(&dir); } + + /// PKCS#8 PEM for an ed25519 key with a fixed 32-byte seed (matches ed25519-sign's format). + fn test_pem() -> String { + use base64::Engine as _; + let mut der = vec![ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, + 0x04, 0x20, + ]; + der.extend_from_slice(&[7u8; 32]); + let b64 = base64::engine::general_purpose::STANDARD.encode(&der); + format!("-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----\n") + } + + #[test] + fn create_ed25519_emits_verifiable_manifest() { + let dir = std::env::temp_dir().join(format!("deploy-test-ed-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let uki = dir.join("uki.bin"); + fs::write(&uki, b"fake uki").unwrap(); + let s2 = dir.join("s2.bin"); + fs::write(&s2, b"fake stage2").unwrap(); + let key = dir.join("key.pem"); + fs::write(&key, test_pem()).unwrap(); + let out = dir.join("out"); + + create(CreateArgs { + arch: "x86_64".into(), + uki, + stage2: s2, + key: Some(key), + base_url: vec!["http://cdn1".into()], + args: Some(r#"["--serve","0.0.0.0:8080"]"#.into()), + version: 7, + out: out.clone(), + }) + .unwrap(); + + // The emitted user-data validates and pins the pubkey + manifest_url (no inline payload). + validate(&out).unwrap(); + let ud: UserData = + serde_json::from_str(&fs::read_to_string(out.join("user-data.json")).unwrap()).unwrap(); + let s2e = ud.stage2.unwrap().x86_64.unwrap(); + assert!(s2e.ed25519.is_some()); + assert!(s2e.manifest_url.is_some()); + assert!(s2e.url.is_none()); + + // The emitted manifest verifies against the pinned key and carries args + version. + let mbytes = fs::read(out.join("x86_64/stage2.manifest.json")).unwrap(); + let sig = fs::read(out.join("x86_64/stage2.manifest.json.sig")).unwrap(); + ed25519_sign::verify(&s2e.ed25519.unwrap(), &mbytes, &sig).unwrap(); + let m = Manifest::parse(&mbytes, Profile::Stage1).unwrap(); + assert_eq!(m.version, 7); + assert_eq!(m.args.unwrap(), vec!["--serve", "0.0.0.0:8080"]); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index a022c42..651c3a2 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -1,12 +1,20 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Lock.Boot admission metadata — the `_stage1` / `_stage2` wire types + validation. +//! Lock.Boot admission metadata -- the `_stage1` / `_stage2` wire types + validation. //! -//! Shared by the stage1 **verifier** (deserialize + `validate` + `Verify`) and the -//! deploy **emitter** (construct + serialize), so the format has one definition and can't -//! drift. `_stage1` (stage0, the UKI hop) and `_stage2` (stage1, the payload hop) have the -//! same shape; they differ only in transport policy, captured by [`Profile`] — stage0 is -//! http-only (no TLS), stage1 allows https. Both allow fallback URL lists. +//! Shared by the stage1 **verifier** (deserialize + `validate` + `Verify`) and the deploy +//! **emitter** (construct + serialize), so the format has one definition and can't drift. +//! `_stage1` (stage0, the UKI hop) and `_stage2` (stage1, the payload hop) have the same shape; +//! they differ only in transport policy, captured by [`Profile`] -- stage0 is http-only (no TLS), +//! stage1 allows https. +//! +//! Two admission modes per arch entry: +//! - **sha256** -- pin an exact hash inline (`url` + `sha256`; static payload). +//! - **ed25519** -- pin a release pubkey + a `manifest_url`. The stage fetches a signed +//! **manifest** (`{ url, sha256, args, version }`), verifies it against the pinned key, then +//! admits the payload by the manifest's exact `sha256`. Binding the payload + args under one +//! signature stops a hostile mirror from mixing-and-matching independently-signed pieces or +//! swapping the payload, while the release rolls forward by re-signing a new manifest. use base64::engine::general_purpose::STANDARD; use base64::Engine as _; @@ -21,7 +29,7 @@ pub struct UserData { pub stage2: Option, } -/// One stage's config: shared inline `args` plus a per-architecture admission entry. +/// One stage's config: an optional inline `args` (sha256 mode) plus a per-architecture entry. #[derive(Debug, Serialize, Deserialize)] pub struct StageConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -51,9 +59,9 @@ impl StageConfig { } } -/// One URL or a fallback list, tried in order. Deserializes from a string or an array, -/// and serializes back to a bare string when singular. Trying mirrors is safe: the -/// payload is cryptographically pinned, so bytes from any URL must still verify. +/// One URL or a fallback list, tried in order. Deserializes from a string or an array, and +/// serializes back to a bare string when singular. Trying mirrors is safe: the payload is +/// cryptographically pinned, so bytes from any URL must still verify. #[derive(Debug, Clone)] pub struct UrlList(pub Vec); @@ -81,98 +89,138 @@ impl Serialize for UrlList { } } -/// One architecture's admission entry. Exactly one of `sha256` (pin an exact payload) or -/// `ed25519` (pin a release pubkey; the payload rolls forward via a detached `.sig`) sets -/// the mode — see [`ArchConfig::validate`]. Every URL field takes a string or a fallback -/// list; `sig_url`/`args_url`/`args_sig_url` may contain a `{sha256}` placeholder. +/// One architecture's admission entry. Exactly one of `sha256` (static, inline `url`+`sha256`) +/// or `ed25519` (roll-forward via a signed manifest at `manifest_url`) selects the mode -- see +/// [`ArchConfig::validate`]. #[derive(Debug, Serialize, Deserialize)] pub struct ArchConfig { - pub url: UrlList, + /// sha256 mode: the payload URL(s). Unused in ed25519 mode (the manifest carries it). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// sha256 mode: the payload's exact 64-hex digest. #[serde(default, skip_serializing_if = "Option::is_none")] pub sha256: Option, + /// ed25519 mode: base64 32-byte release pubkey. #[serde(default, skip_serializing_if = "Option::is_none")] pub ed25519: Option, - /// Detached signature location(s); `{sha256}` → payload digest. Defaults to `.sig`. + /// ed25519 mode: where the signed manifest is fetched from. #[serde(default, skip_serializing_if = "Option::is_none")] - pub sig_url: Option, - /// Signed remote args (ed25519 only): a JSON string array, verified against the same - /// key via `args_sig_url` (else `.sig`), overriding inline `args`. + pub manifest_url: Option, + /// ed25519 mode: manifest signature location; `{sha256}` -> the retrieved manifest's digest. + /// Defaults to `.sig`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub args_url: Option, + pub manifest_sig_url: Option, +} + +/// The signed release manifest (ed25519 mode). Fetched from `manifest_url`, verified against the +/// pinned release key; the payload is then admitted by its `sha256`. The verifier also merges the +/// manifest back into the `_stage2` entry before handing the doc to the payload on stdin. +#[derive(Debug, Serialize, Deserialize)] +pub struct Manifest { + /// Payload URL(s) (mirror list). `{sha256}` is replaced with `sha256` below. + pub url: UrlList, + /// The payload's exact 64-hex digest. + pub sha256: String, + /// Args passed to the payload as argv (stage1) or LoadOptions (stage0). #[serde(default, skip_serializing_if = "Option::is_none")] - pub args_sig_url: Option, + pub args: Option>, + /// Monotonic release version (anti-rollback hint; enforcement is future work). + #[serde(default)] + pub version: u64, } -/// Transport policy per stage: stage0 (the UKI hop) has no TLS, stage1 (the payload hop) -/// does. Both allow fallback URL lists. +/// Transport policy per stage: stage0 (the UKI hop) has no TLS, stage1 (the payload hop) does. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Profile { - /// `_stage1` — http:// only. + /// `_stage1` -- http:// only. Stage0, - /// `_stage2` — http:// or https://. + /// `_stage2` -- http:// or https://. Stage1, } -/// Resolved admission mode. `*_url` templates still carry a raw `{sha256}`; the consumer -/// substitutes it once the payload digest is known. +/// Resolved admission mode. `manifest_sig_url` still carries a raw `{sha256}`; the consumer +/// substitutes the retrieved manifest's digest. pub enum Verify { Sha256(String), Ed25519 { pubkey: String, - sig_url: Option, - args_url: Option, - args_sig_url: Option, + manifest_url: UrlList, + manifest_sig_url: Option, }, } -impl ArchConfig { - /// Validate the URL(s) and the single verification field, returning the selected - /// [`Verify`] mode. `profile` chooses the transport policy (see [`Profile`]). - pub fn validate(&self, profile: Profile) -> Result { +fn ok_url(s: &str, allow_https: bool) -> bool { + (s.starts_with("http://") || (allow_https && s.starts_with("https://"))) + && s.chars().all(|c| c.is_ascii_graphic()) +} +fn ok_list(l: &UrlList, allow_https: bool) -> bool { + !l.0.is_empty() && l.0.iter().all(|s| ok_url(s, allow_https)) +} +fn ok_sha256(hex: &str) -> bool { + hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +impl Manifest { + /// Parse + validate a fetched manifest (called after its signature is verified). + pub fn parse(json: &[u8], profile: Profile) -> Result { + let m: Manifest = + serde_json::from_slice(json).map_err(|_| "manifest is not valid JSON")?; let allow_https = matches!(profile, Profile::Stage1); - let ok_url = |s: &str| { - (s.starts_with("http://") || (allow_https && s.starts_with("https://"))) - && s.chars().all(|c| c.is_ascii_graphic()) - }; - let ok_list = |l: &UrlList| !l.0.is_empty() && l.0.iter().all(|s| ok_url(s)); - if !ok_list(&self.url) { - return Err("url must be a non-empty http(s):// URL (or list of them), printable ASCII"); + if !ok_list(&m.url, allow_https) { + return Err("manifest url must be a non-empty http(s):// URL (or list), printable ASCII"); } - if self.sig_url.as_ref().is_some_and(|l| !ok_list(l)) { - return Err("sig_url must be http(s):// URL(s), printable ASCII"); + if !ok_sha256(&m.sha256) { + return Err("manifest sha256 must be exactly 64 hex characters"); } - if self.args_url.as_ref().is_some_and(|l| !ok_list(l)) { - return Err("args_url must be http(s):// URL(s), printable ASCII"); + Ok(m) + } +} + +impl ArchConfig { + /// Validate the arch entry and return the selected [`Verify`] mode. `profile` chooses the + /// transport policy (see [`Profile`]). + pub fn validate(&self, profile: Profile) -> Result { + let allow_https = matches!(profile, Profile::Stage1); + if self.manifest_url.as_ref().is_some_and(|l| !ok_list(l, allow_https)) { + return Err("manifest_url must be http(s):// URL(s), printable ASCII"); } - if self.args_sig_url.as_ref().is_some_and(|l| !ok_list(l)) { - return Err("args_sig_url must be http(s):// URL(s), printable ASCII"); + if self.manifest_sig_url.as_ref().is_some_and(|l| !ok_list(l, allow_https)) { + return Err("manifest_sig_url must be http(s):// URL(s), printable ASCII"); } - if self.args_sig_url.is_some() && self.args_url.is_none() { - return Err("args_sig_url requires args_url"); + if self.manifest_sig_url.is_some() && self.manifest_url.is_none() { + return Err("manifest_sig_url requires manifest_url"); } match (&self.sha256, &self.ed25519) { (Some(_), Some(_)) => Err("specify only one of sha256 / ed25519"), (None, None) => Err("must specify one of sha256 / ed25519"), (Some(hex), None) => { - // Signed args need the release key, which only signed mode pins. - if self.args_url.is_some() { - return Err("args_url requires ed25519 signed mode"); + if self.manifest_url.is_some() { + return Err("manifest_url requires ed25519 signed mode"); + } + let url = self.url.as_ref().ok_or("sha256 mode requires url")?; + if !ok_list(url, allow_https) { + return Err("url must be a non-empty http(s):// URL (or list), printable ASCII"); } - if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + if !ok_sha256(hex) { return Err("sha256 must be exactly 64 hex characters"); } Ok(Verify::Sha256(hex.clone())) } - (None, Some(pubkey)) => match STANDARD.decode(pubkey.trim()) { - Ok(bytes) if bytes.len() == 32 => Ok(Verify::Ed25519 { - pubkey: pubkey.clone(), - sig_url: self.sig_url.clone(), - args_url: self.args_url.clone(), - args_sig_url: self.args_sig_url.clone(), - }), - Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), - Err(_) => Err("ed25519 pubkey must be base64"), - }, + (None, Some(pubkey)) => { + let manifest_url = self + .manifest_url + .clone() + .ok_or("ed25519 mode requires manifest_url")?; + match STANDARD.decode(pubkey.trim()) { + Ok(bytes) if bytes.len() == 32 => Ok(Verify::Ed25519 { + pubkey: pubkey.clone(), + manifest_url, + manifest_sig_url: self.manifest_sig_url.clone(), + }), + Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), + Err(_) => Err("ed25519 pubkey must be base64"), + } + } } } } @@ -189,79 +237,124 @@ mod tests { STANDARD.encode(*KeyPair::from_seed(Seed::new([3u8; 32])).pk) } - fn ac(url: &str, sha256: Option<&str>, ed25519: Option<&str>) -> ArchConfig { + /// A minimal sha256-mode entry. + fn sha256_entry(url: &str, sha256: &str) -> ArchConfig { ArchConfig { - url: UrlList(vec![url.into()]), - sha256: sha256.map(Into::into), - ed25519: ed25519.map(Into::into), - sig_url: None, - args_url: None, - args_sig_url: None, + url: Some(UrlList(vec![url.into()])), + sha256: Some(sha256.into()), + ed25519: None, + manifest_url: None, + manifest_sig_url: None, } } - #[test] - fn sha256_mode_ok() { - assert!(matches!(ac("http://h/p", Some(HASH64), None).validate(Profile::Stage1), Ok(Verify::Sha256(_)))); + /// A minimal ed25519-mode entry. + fn ed25519_entry(pubkey: &str, manifest_url: &str) -> ArchConfig { + ArchConfig { + url: None, + sha256: None, + ed25519: Some(pubkey.into()), + manifest_url: Some(UrlList(vec![manifest_url.into()])), + manifest_sig_url: None, + } } #[test] - fn https_allowed_on_stage1_only() { - assert!(ac("https://h/p", Some(HASH64), None).validate(Profile::Stage1).is_ok()); - assert!(ac("https://h/p", Some(HASH64), None).validate(Profile::Stage0).is_err()); - assert!(ac("http://h/p", Some(HASH64), None).validate(Profile::Stage0).is_ok()); + fn sha256_mode_ok() { + assert!(matches!( + sha256_entry("http://h/p", HASH64).validate(Profile::Stage1), + Ok(Verify::Sha256(_)) + )); } #[test] fn ed25519_mode_ok() { let pk = pubkey_b64(); - assert!(matches!(ac("http://h/p", None, Some(&pk)).validate(Profile::Stage1), Ok(Verify::Ed25519 { .. }))); + assert!(matches!( + ed25519_entry(&pk, "http://h/m.json").validate(Profile::Stage1), + Ok(Verify::Ed25519 { .. }) + )); } #[test] - fn both_modes_is_error() { + fn https_allowed_on_stage1_only() { + assert!(sha256_entry("https://h/p", HASH64).validate(Profile::Stage1).is_ok()); + assert!(sha256_entry("https://h/p", HASH64).validate(Profile::Stage0).is_err()); let pk = pubkey_b64(); - assert!(ac("http://h/p", Some(HASH64), Some(&pk)).validate(Profile::Stage1).is_err()); + assert!(ed25519_entry(&pk, "https://h/m.json").validate(Profile::Stage1).is_ok()); + assert!(ed25519_entry(&pk, "https://h/m.json").validate(Profile::Stage0).is_err()); } #[test] - fn neither_mode_is_error() { - assert!(ac("http://h/p", None, None).validate(Profile::Stage1).is_err()); + fn ed25519_requires_manifest_url() { + let pk = pubkey_b64(); + let mut c = ed25519_entry(&pk, "http://h/m.json"); + c.manifest_url = None; + assert!(c.validate(Profile::Stage1).is_err()); } #[test] - fn bad_hex_is_error() { - assert!(ac("http://h/p", Some("zz"), None).validate(Profile::Stage1).is_err()); - let sixtyfour_nonhex = "z".repeat(64); - assert!(ac("http://h/p", Some(&sixtyfour_nonhex), None).validate(Profile::Stage1).is_err()); + fn manifest_url_requires_ed25519() { + let mut c = sha256_entry("http://h/p", HASH64); + c.manifest_url = Some(UrlList(vec!["http://h/m.json".into()])); + assert!(c.validate(Profile::Stage1).is_err()); } #[test] - fn bad_pubkey_is_error() { - assert!(ac("http://h/p", None, Some("not-base64!!")).validate(Profile::Stage1).is_err()); - assert!(ac("http://h/p", None, Some("AAAA")).validate(Profile::Stage1).is_err()); + fn manifest_sig_url_requires_manifest_url() { + let mut c = sha256_entry("http://h/p", HASH64); + c.manifest_sig_url = Some(UrlList(vec!["http://h/m.json.sig".into()])); + assert!(c.validate(Profile::Stage1).is_err()); } #[test] - fn non_http_url_is_error() { - assert!(ac("ftp://h/p", Some(HASH64), None).validate(Profile::Stage1).is_err()); + fn sha256_mode_requires_url() { + let mut c = sha256_entry("http://h/p", HASH64); + c.url = None; + assert!(c.validate(Profile::Stage1).is_err()); } #[test] - fn args_url_requires_ed25519() { - let mut c = ac("http://h/p", Some(HASH64), None); - c.args_url = Some(UrlList(vec!["http://h/args".into()])); + fn both_modes_is_error() { + let pk = pubkey_b64(); + let mut c = sha256_entry("http://h/p", HASH64); + c.ed25519 = Some(pk); + c.manifest_url = Some(UrlList(vec!["http://h/m.json".into()])); assert!(c.validate(Profile::Stage1).is_err()); } #[test] - fn args_sig_url_requires_args_url() { - let pk = pubkey_b64(); - let mut c = ac("http://h/p", None, Some(&pk)); - c.args_sig_url = Some(UrlList(vec!["http://h/args.sig".into()])); + fn neither_mode_is_error() { + let c = ArchConfig { url: None, sha256: None, ed25519: None, manifest_url: None, manifest_sig_url: None }; assert!(c.validate(Profile::Stage1).is_err()); } + #[test] + fn bad_hex_is_error() { + assert!(sha256_entry("http://h/p", "zz").validate(Profile::Stage1).is_err()); + let sixtyfour_nonhex = "z".repeat(64); + assert!(sha256_entry("http://h/p", &sixtyfour_nonhex).validate(Profile::Stage1).is_err()); + } + + #[test] + fn bad_pubkey_is_error() { + assert!(ed25519_entry("not-base64!!", "http://h/m.json").validate(Profile::Stage1).is_err()); + assert!(ed25519_entry("AAAA", "http://h/m.json").validate(Profile::Stage1).is_err()); + } + + #[test] + fn manifest_parse_validates() { + let good = br#"{ "url": "https://h/p", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "args": ["--x"], "version": 3 }"#; + let m = Manifest::parse(good, Profile::Stage1).unwrap(); + assert_eq!(m.sha256.len(), 64); + assert_eq!(m.version, 3); + // https rejected under Stage0 (no TLS) + assert!(Manifest::parse(good, Profile::Stage0).is_err()); + // bad sha256 rejected + let bad = br#"{ "url": "http://h/p", "sha256": "nope" }"#; + assert!(Manifest::parse(bad, Profile::Stage1).is_err()); + } + #[test] fn urllist_accepts_string_or_array() { let one: UrlList = serde_json::from_str(r#""http://a/x""#).unwrap(); @@ -274,10 +367,10 @@ mod tests { #[test] fn url_list_validates_and_rejects_empty() { - let mut c = ac("http://h/p", Some(HASH64), None); - c.url = UrlList(vec!["http://h/p".into(), "https://mirror/p".into()]); + let mut c = sha256_entry("http://h/p", HASH64); + c.url = Some(UrlList(vec!["http://h/p".into(), "https://mirror/p".into()])); assert!(c.validate(Profile::Stage1).is_ok()); - c.url = UrlList(vec![]); + c.url = Some(UrlList(vec![])); assert!(c.validate(Profile::Stage1).is_err()); } } diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index a69c9cd..1a02a6e 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use bytes::Bytes; -use metadata::{Profile, UrlList, UserData, Verify}; +use metadata::{Manifest, Profile, UrlList, UserData, Verify}; use reqwest::blocking::Client; use rustls::crypto::CryptoProvider; use sha2::{Digest, Sha256}; @@ -206,36 +206,43 @@ fn download_first(urls: &[String]) -> Result { Err(last.unwrap_or_else(|| anyhow!("no url provided"))) } -/// Fetch + verify signed remote args (a JSON string array) against `pubkey`, returning -/// argv that overrides inline args. Signature from `args_sig_url`, else `.sig`. -fn fetch_signed_args( - args_url: &UrlList, - args_sig_url: Option<&UrlList>, - pubkey: &str, - payload_hash: &str, -) -> Result> { - let args_urls = substitute(&args_url.0, payload_hash); - let args_sig_urls = match args_sig_url { - Some(u) => substitute(&u.0, payload_hash), - None => args_urls.iter().map(|u| format!("{u}.sig")).collect(), - }; - let args_bytes = download_first(&args_urls)?; - let signature = download_first(&args_sig_urls)?; - ed25519_sign::verify(pubkey, &args_bytes, &signature) - .map_err(|m| anyhow!("signed args verification failed: {m}"))?; - let args: Vec = serde_json::from_slice(&args_bytes) - .context("signed args must be a JSON array of strings")?; - ktseprintln!("args: {} signed (ed25519)", args.len()); - Ok(args) -} - -/// Try each payload URL until one downloads and admits (mirrors are safe — every -/// candidate must still pass the same pin/signature). -fn admit_payload(urls: &[String], mode: &Verify) -> Result<(Bytes, Option>)> { +/// Admit the payload. sha256 mode downloads from the inline `url` and checks the pin; ed25519 +/// mode fetches + verifies a signed manifest, then admits the payload by the manifest's hash. +/// Returns the payload bytes, the manifest's `args` (ed25519 only), and the verified manifest +/// (so the caller can merge it into the doc handed to stage2 on stdin). +fn admit_payload( + mode: &Verify, + sha256_urls: Option<&UrlList>, +) -> Result<(Bytes, Option>, Option)> { + match mode { + Verify::Sha256(expected) => { + let urls = sha256_urls.ok_or_else(|| anyhow!("sha256 mode requires url"))?; + let binary = admit_by_hash(&urls.0, expected)?; + Ok((binary, None, None)) + } + Verify::Ed25519 { pubkey, manifest_url, manifest_sig_url } => { + // One signed manifest binds the payload hash + args under one key, so a hostile + // mirror can neither mix-and-match nor swap the payload. + let manifest = fetch_manifest(pubkey, manifest_url, manifest_sig_url.as_ref())?; + let urls = substitute(&manifest.url.0, &manifest.sha256); + let binary = admit_by_hash(&urls, &manifest.sha256)?; + let args = manifest.args.clone(); + Ok((binary, args, Some(manifest))) + } + } +} + +/// Try each mirror until one downloads and matches `expected` (content is pinned, so any mirror +/// yielding the right bytes is acceptable). +fn admit_by_hash(urls: &[String], expected: &str) -> Result { let mut last: Option = None; for url in urls { - match admit_from(url, mode) { - Ok(result) => return Ok(result), + match download_binary(url).and_then(|b| { + verify_checksum(&b, expected)?; + ktseprintln!("verified: sha256:{}", hex::encode(sha256!(&b))); + Ok(b) + }) { + Ok(binary) => return Ok(binary), Err(e) => { ktseprintln!("payload url rejected: {url} ({e:#})"); last = Some(e); @@ -245,31 +252,50 @@ fn admit_payload(urls: &[String], mode: &Verify) -> Result<(Bytes, Option Result<(Bytes, Option>)> { - let binary = download_binary(url)?; - let hash = hex::encode(sha256!(&binary)); - let mut signed_args = None; - match mode { - Verify::Sha256(expected) => { - verify_checksum(&binary, expected)?; - ktseprintln!("verified: sha256:{hash} (sha256 pin)"); - } - Verify::Ed25519 { pubkey, sig_url, args_url, args_sig_url } => { - let sig_urls = match sig_url { - Some(u) => substitute(&u.0, &hash), - None => vec![format!("{url}.sig")], - }; - let signature = download_first(&sig_urls)?; - ed25519_sign::verify(pubkey, &binary, &signature) - .map_err(|m| anyhow!("ed25519 verification failed: {m}"))?; - ktseprintln!("verified: sha256:{hash} (ed25519 key:{pubkey})"); - if let Some(au) = args_url { - signed_args = Some(fetch_signed_args(au, args_sig_url.as_ref(), pubkey, &hash)?); +/// Fetch + verify the signed release manifest (ed25519 mode). Tries each `manifest_url` mirror; +/// a candidate is accepted only if it downloads, its detached signature verifies against +/// `pubkey`, and it parses as a valid [`Manifest`]. The signature comes from `manifest_sig_url` +/// (`{sha256}` -> the retrieved manifest's digest), else `.sig`. +fn fetch_manifest( + pubkey: &str, + manifest_url: &UrlList, + manifest_sig_url: Option<&UrlList>, +) -> Result { + let mut last: Option = None; + for murl in &manifest_url.0 { + match try_fetch_manifest(pubkey, murl, manifest_sig_url) { + Ok(m) => return Ok(m), + Err(e) => { + ktseprintln!("manifest rejected: {murl} ({e:#})"); + last = Some(e); } } } - Ok((binary, signed_args)) + Err(last.unwrap_or_else(|| anyhow!("no manifest url provided"))) +} + +fn try_fetch_manifest( + pubkey: &str, + murl: &str, + manifest_sig_url: Option<&UrlList>, +) -> Result { + let bytes = download_binary(murl)?; + let mhash = hex::encode(sha256!(&bytes)); + let sig_urls = match manifest_sig_url { + Some(u) => substitute(&u.0, &mhash), + None => vec![format!("{murl}.sig")], + }; + let signature = download_first(&sig_urls)?; + ed25519_sign::verify(pubkey, &bytes, &signature) + .map_err(|m| anyhow!("manifest verification failed: {m}"))?; + let manifest = Manifest::parse(&bytes, Profile::Stage1) + .map_err(|m| anyhow!("invalid manifest: {m}"))?; + ktseprintln!( + "manifest verified (sha256:{}, version {}, ed25519 key:{pubkey})", + manifest.sha256, + manifest.version + ); + Ok(manifest) } fn stage2(parsed: ParsedData) -> Result<()> { @@ -285,20 +311,48 @@ fn stage2(parsed: ParsedData) -> Result<()> { .validate(Profile::Stage1) .map_err(|m| anyhow!("invalid _stage2 config: {m}"))?; - let (binary_data, signed_args) = admit_payload(&arch_config.url.0, &mode)?; + let (binary_data, manifest_args, manifest) = admit_payload(&mode, arch_config.url.as_ref())?; if is_root() { generate_pre_execution_attestation(&binary_data)?; extend_pcrs(&binary_data)?; } - // Signed remote args, when present, override inline args. + // argv: the signed manifest's args (ed25519 mode) override inline `_stage2.args`. let inline_args = stage2.args.as_deref().unwrap_or(&[]); - let args: &[String] = signed_args.as_deref().unwrap_or(inline_args); - execute_binary(&binary_data, args, &parsed.raw_json)?; + let args: &[String] = manifest_args.as_deref().unwrap_or(inline_args); + + // stdin: the received user-data with the verified manifest merged into `_stage2.` + // (sha256 mode passes the doc through unchanged). Top-level operator keys pass through. + let stdin_config = build_stdin_config(&parsed.raw_json, manifest.as_ref())?; + execute_binary(&binary_data, args, &stdin_config)?; Ok(()) } +/// The doc handed to stage2 on stdin. In ed25519 mode the verified manifest is merged +/// **additively** into `_stage2.` (the `{ed25519, manifest_url}` pointer stays; `url` / +/// `sha256` / `args` / `version` are added), so the payload sees resolved, release-signed values +/// alongside the operator's top-level keys. In sha256 mode the received doc is returned unchanged. +fn build_stdin_config(raw_json: &[u8], manifest: Option<&Manifest>) -> Result> { + let manifest = match manifest { + Some(m) => m, + None => return Ok(raw_json.to_vec()), + }; + let mut doc: serde_json::Value = + serde_json::from_slice(raw_json).context("re-parse user-data for manifest merge")?; + let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }; + let entry = doc + .get_mut("_stage2") + .and_then(|s| s.get_mut(arch)) + .and_then(|e| e.as_object_mut()) + .ok_or_else(|| anyhow!("_stage2.{arch} object vanished on re-parse"))?; + let merged = serde_json::to_value(manifest).context("serialize manifest for merge")?; + for (k, v) in merged.as_object().expect("Manifest serializes to an object") { + entry.insert(k.clone(), v.clone()); + } + serde_json::to_vec(&doc).context("re-serialize merged user-data") +} + fn log_hash(label: &str, data: &[u8]) { ktseprintln!("{} sha256={}", label, hex::encode(sha256!(data))); }