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..7ce8b09 100644 --- a/Makefile +++ b/Makefile @@ -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": { "": { "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 \ @@ -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 $* \ diff --git a/README.md b/README.md index 4491f55..853386f 100644 --- a/README.md +++ b/README.md @@ -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 `.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 `.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 `.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 `.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 (`.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"] - } + } } } } ``` @@ -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 \ 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..a67da18 100644 --- a/crates/deploy/src/main.rs +++ b/crates/deploy/src/main.rs @@ -9,8 +9,8 @@ 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 serde_json::{Map, Value}; +use metadata::{ArchConfig, Entry, ManifestRef, Payload, Profile, StageConfig, UrlList, UserData}; +use serde_json::{json, Map, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -58,6 +58,11 @@ struct CreateArgs { /// Serve --args as a signed remote blob (ed25519 mode) instead of inline. #[arg(long, requires = "key", requires = "args")] sign_args: bool, + /// Wrap each payload in a signed manifest (requires --key): the operator doc pins + /// `{"manifest":{url,ed25519}}` and the sha256-pinned payload + args ride inside the signed + /// manifest, binding them under one signature (roll forward by re-signing the manifest). + #[arg(long, requires = "key")] + manifest: bool, /// Output directory (created if missing). user-data.json is merged across arches. #[arg(long)] out: PathBuf, @@ -86,15 +91,15 @@ fn compose_urls(bases: &[String], arch: &str, filename: &str) -> UrlList { } /// 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. -fn build_entry( +/// pubkey, ed25519 mode) or hash it (→ sha256 pin). Returns a [`Payload`] with its composed URL list. +fn build_payload( arch_dir: &Path, arch: &str, bases: &[String], filename: &str, src: &Path, key_pem: Option<&str>, -) -> Result { +) -> 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()))?; @@ -107,7 +112,42 @@ fn build_entry( } None => (Some(sha256_hex(&bytes)), None), }; - Ok(ArchConfig { url, sha256, ed25519, sig_url: None, args_url: None, args_sig_url: None }) + Ok(Payload { url, sha256, ed25519, sig_url: None, args: None, args_url: None, args_sig_url: None }) +} + +/// Wrap a `payload` directly in an operator arch entry (direct admission — no manifest). +fn direct_entry(payload: Payload) -> ArchConfig { + ArchConfig { entry: Entry::Payload(payload), resolved_manifests: Vec::new() } +} + +/// Wrap a `payload` in a **signed manifest** (a `` user-data fragment) written to +/// `.manifest.json` (+ `.sig`), and return an operator entry that pins +/// `{"manifest":{url,ed25519}}`. The verifier fetches + verifies the manifest and deep-merges it, +/// so the payload + args are bound under the single manifest signature. +fn wrap_manifest( + arch_dir: &Path, + arch: &str, + bases: &[String], + stage_key: &str, + filename: &str, + payload: Payload, + pem: &str, +) -> Result { + let manifest = json!({ stage_key: { arch: { "payload": serde_json::to_value(&payload)? } } }); + let bytes = serde_json::to_vec_pretty(&manifest)?; + let name = format!("{filename}.manifest.json"); + fs::write(arch_dir.join(&name), &bytes)?; + let s = sign_payload(pem, &bytes)?; + fs::write(arch_dir.join(format!("{name}.sig")), &s.signature)?; + Ok(ArchConfig { + entry: Entry::Manifest(ManifestRef { + url: compose_urls(bases, arch, &name), + ed25519: s.pubkey_b64, + sig_url: None, // verifier defaults to .sig (co-located) + sha256: None, + }), + resolved_manifests: Vec::new(), + }) } fn create(a: CreateArgs) -> Result<()> { @@ -126,11 +166,18 @@ 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())?; + if a.sign_args && a.manifest { + bail!("--sign-args is redundant with --manifest (the signed manifest already binds args)"); + } + + // In manifest mode the inner payload is sha256-pinned (the manifest signature covers it); + // in direct mode the payload itself is signed with the key (or sha256-pinned when no key). + let payload_key = if a.manifest { None } else { key_pem.as_deref() }; + let uki_payload = build_payload(&arch_dir, &a.arch, &bases, "linux.efi", &a.uki, payload_key)?; + let mut stage2_payload = build_payload(&arch_dir, &a.arch, &bases, "stage2", &a.stage2, payload_key)?; - // Args: inline, or signed-and-served remotely. - let mut inline_args: Option> = None; + // Args ride inside the stage2 payload: inline, or served as a separately-signed blob + // (ed25519 direct mode). In manifest mode args are always inline (bound by the manifest sig). 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")?; @@ -140,58 +187,57 @@ fn create(a: CreateArgs) -> Result<()> { 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")); + stage2_payload.args_url = Some(compose_urls(&bases, &a.arch, "args.json")); } else { - inline_args = Some(parsed); + stage2_payload.args = Some(parsed); } } + let (uki_entry, stage2_entry) = if a.manifest { + let pem = key_pem.as_deref().expect("clap requires --key with --manifest"); + ( + wrap_manifest(&arch_dir, &a.arch, &bases, "_stage1", "linux.efi", uki_payload, pem)?, + wrap_manifest(&arch_dir, &a.arch, &bases, "_stage2", "stage2", stage2_payload, pem)?, + ) + } else { + (direct_entry(uki_payload), direct_entry(stage2_payload)) + }; + // 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}"))?; stage2_entry.validate(Profile::Stage1).map_err(|m| anyhow!("_stage2 entry invalid: {m}"))?; let ud_path = a.out.join("user-data.json"); - merge_user_data(&ud_path, &a.arch, uki_entry, stage2_entry, inline_args)?; - let mode = if key_pem.is_some() { "ed25519 (signed)" } else { "sha256 (pinned)" }; + merge_user_data(&ud_path, &a.arch, uki_entry, stage2_entry)?; + let mode = match (a.manifest, key_pem.is_some()) { + (true, _) => "ed25519 signed manifest", + (false, true) => "ed25519 (signed)", + (false, false) => "sha256 (pinned)", + }; println!("wrote {} + {}/ artifacts [{mode}, {} mirror(s)]", ud_path.display(), a.arch, bases.len()); Ok(()) } /// Merge one arch's `_stage1`/`_stage2` entries into `user-data.json` (creating it if absent), /// preserving any other arch already present. -fn merge_user_data( - path: &Path, - arch: &str, - uki: ArchConfig, - stage2: ArchConfig, - inline_args: Option>, -) -> Result<()> { +fn merge_user_data(path: &Path, arch: &str, uki: ArchConfig, stage2: ArchConfig) -> Result<()> { let mut doc: Value = if path.exists() { serde_json::from_str(&fs::read_to_string(path)?).context("parsing existing user-data.json")? } else { Value::Object(Map::new()) }; let obj = doc.as_object_mut().ok_or_else(|| anyhow!("user-data must be a JSON object"))?; - set_arch(obj, "_stage1", arch, serde_json::to_value(&uki)?, None)?; - set_arch(obj, "_stage2", arch, serde_json::to_value(&stage2)?, inline_args)?; + set_arch(obj, "_stage1", arch, serde_json::to_value(&uki)?)?; + set_arch(obj, "_stage2", arch, serde_json::to_value(&stage2)?)?; fs::write(path, format!("{}\n", serde_json::to_string_pretty(&doc)?)) .with_context(|| format!("writing {}", path.display()))?; Ok(()) } -fn set_arch( - obj: &mut Map, - stage_key: &str, - arch: &str, - entry: Value, - inline_args: Option>, -) -> Result<()> { +fn set_arch(obj: &mut Map, stage_key: &str, arch: &str, entry: Value) -> Result<()> { let stage = obj.entry(stage_key).or_insert_with(|| Value::Object(Map::new())); let smap = stage.as_object_mut().ok_or_else(|| anyhow!("{stage_key} must be a JSON object"))?; smap.insert(arch.to_string(), entry); - if let Some(args) = inline_args { - smap.insert("args".to_string(), serde_json::to_value(args)?); - } Ok(()) } @@ -237,14 +283,15 @@ fn modify(a: ModifyArgs) -> Result<()> { let obj = doc.as_object_mut().ok_or_else(|| anyhow!("user-data must be a JSON object"))?; for stage_key in ["_stage1", "_stage2"] { let Some(stage) = obj.get_mut(stage_key).and_then(|v| v.as_object_mut()) else { continue }; - for (arch, entry) in stage.iter_mut() { - if arch == "args" { - continue; - } + for (_arch, entry) in stage.iter_mut() { let Some(em) = entry.as_object_mut() else { continue }; - for field in ["url", "sig_url", "args_url", "args_sig_url"] { - if let Some(v) = em.get_mut(field) { - *v = rewrite_urls(v, &adds, &rems)?; + // URL fields live inside the `payload` / `manifest` sub-object of the union entry. + for variant in ["payload", "manifest"] { + let Some(sub) = em.get_mut(variant).and_then(|v| v.as_object_mut()) else { continue }; + for field in ["url", "sig_url", "args_url", "args_sig_url"] { + if let Some(v) = sub.get_mut(field) { + *v = rewrite_urls(v, &adds, &rems)?; + } } } } @@ -338,6 +385,7 @@ mod tests { base_url: vec!["http://cdn1".into(), "http://cdn2".into()], args: None, sign_args: false, + manifest: false, out: out.clone(), }) .unwrap(); @@ -346,8 +394,62 @@ 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!(ud.stage1.unwrap().x86_64.unwrap().sha256.is_some()); + let Entry::Payload(s2p) = ud.stage2.unwrap().x86_64.unwrap().entry else { panic!("expected payload") }; + assert_eq!(s2p.url.0.len(), 2); + let Entry::Payload(ukip) = ud.stage1.unwrap().x86_64.unwrap().entry else { panic!("expected payload") }; + assert!(ukip.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_manifest_emits_verifiable_fragment() { + let dir = std::env::temp_dir().join(format!("deploy-test-m-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("uki.bin"), b"fake uki").unwrap(); + fs::write(dir.join("s2.bin"), b"fake stage2").unwrap(); + fs::write(dir.join("key.pem"), test_pem()).unwrap(); + let out = dir.join("out"); + + create(CreateArgs { + arch: "x86_64".into(), + uki: dir.join("uki.bin"), + stage2: dir.join("s2.bin"), + key: Some(dir.join("key.pem")), + base_url: vec!["http://cdn1".into()], + args: Some(r#"["--serve","0.0.0.0:8080"]"#.into()), + sign_args: false, + manifest: true, + out: out.clone(), + }) + .unwrap(); + + // The operator doc pins a manifest (not an inline payload) for both hops. + validate(&out).unwrap(); + let ud: UserData = + serde_json::from_str(&fs::read_to_string(out.join("user-data.json")).unwrap()).unwrap(); + let Entry::Manifest(mref) = ud.stage2.unwrap().x86_64.unwrap().entry else { panic!("expected manifest") }; + + // The emitted manifest verifies against the pinned key and is a _stage2 fragment whose + // payload carries the sha256 pin + the (bound) args. + 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(&mref.ed25519, &mbytes, &sig).unwrap(); + let frag: UserData = serde_json::from_slice(&mbytes).unwrap(); + let Entry::Payload(p) = frag.stage2.unwrap().x86_64.unwrap().entry else { panic!("expected payload") }; + assert!(p.sha256.is_some()); + assert_eq!(p.args.unwrap(), vec!["--serve", "0.0.0.0:8080"]); let _ = fs::remove_dir_all(&dir); } } diff --git a/crates/metadata/schema/stage2.schema.json b/crates/metadata/schema/stage2.schema.json index 4648977..58a3914 100644 --- a/crates/metadata/schema/stage2.schema.json +++ b/crates/metadata/schema/stage2.schema.json @@ -2,26 +2,21 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/lockboot/stage1/schema/stage2.schema.json", "title": "Lock.Boot _stage2 metadata", - "description": "The `_stage2` block of a Lock.Boot instance's user-data: how stage1 admits and runs the stage2 payload. stage1 reads only `_stage2`; a full user-data document may also carry `_stage1` (stage0's UKI-admission format, specified separately and http-only). Runtime parsing ignores unknown keys; this schema is stricter to catch authoring mistakes.", + "description": "The `_stage2` block of a Lock.Boot instance's user-data: how stage1 admits and runs the stage2 payload. Each per-arch entry is a discriminated union -- exactly one of `payload` (admit a binary now) or `manifest` (fetch + verify a signed manifest, deep-merge it into the doc, and re-evaluate). stage1 reads only `_stage2`; a full user-data document may also carry `_stage1` (stage0's UKI-admission format, same shape, http-only). Runtime parsing ignores unknown keys; this schema is stricter to catch authoring mistakes.", "type": "object", "required": ["_stage2"], "properties": { - "_stage2": { "$ref": "#/$defs/stage2Config" }, + "_stage2": { "$ref": "#/$defs/stageConfig" }, "_stage1": { "type": "object", - "description": "stage0's UKI-admission config (separate schema, http-only, single url). Ignored by stage1." + "description": "stage0's UKI-admission config (same discriminated-union shape, http-only). Ignored by stage1." } }, "$defs": { - "stage2Config": { + "stageConfig": { "type": "object", - "description": "Per-architecture stage2 admission config, plus shared inline args.", + "description": "Per-architecture admission config. The running arch's entry must be present.", "properties": { - "args": { - "type": "array", - "items": { "type": "string" }, - "description": "Inline argv passed to stage2. Overridden by verified args_url in ed25519 mode." - }, "x86_64": { "$ref": "#/$defs/archConfig" }, "aarch64": { "$ref": "#/$defs/archConfig" } }, @@ -43,22 +38,47 @@ { "type": "array", "items": { "$ref": "#/$defs/url" }, "minItems": 1 } ] }, + "sha256": { + "type": "string", + "description": "Lowercase or uppercase hex SHA-256.", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "ed25519": { + "type": "string", + "description": "Base64 (standard, padded) of a 32-byte ed25519 public key.", + "pattern": "^[A-Za-z0-9+/]{43}=$" + }, "archConfig": { "type": "object", - "description": "How stage1 admits the stage2 payload for one architecture. Exactly one of `sha256` (pin an exact payload) or `ed25519` (pin a release pubkey; payload rolls forward via a detached `.sig`). Every URL field accepts a string or a fallback list; `sig_url`/`args_url`/`args_sig_url` may contain a `{sha256}` placeholder replaced with the payload's hex digest.", + "description": "Discriminated union for one architecture: exactly one of `payload` or `manifest`. `resolved_manifests` is verifier-written resolution provenance (operators do not author it).", + "properties": { + "payload": { "$ref": "#/$defs/payload" }, + "manifest": { "$ref": "#/$defs/manifest" }, + "resolved_manifests": { + "type": "array", + "description": "Resolution history stamped by stage1: one record per manifest hop, each carrying the fetched manifest's resolved `sha256`. Not authored by operators.", + "items": { "$ref": "#/$defs/manifest" } + } + }, + "additionalProperties": false, + "oneOf": [ + { "required": ["payload"], "not": { "required": ["manifest"] } }, + { "required": ["manifest"], "not": { "required": ["payload"] } } + ] + }, + "payload": { + "type": "object", + "description": "Admit a concrete binary. Exactly one of `sha256` (pin an exact payload) or `ed25519` (pin a release pubkey; the payload rolls forward via a detached `.sig`). Every URL field accepts a string or a fallback list; `sig_url`/`args_url`/`args_sig_url` may contain a `{sha256}` placeholder replaced with the payload's hex digest.", "properties": { "url": { "$ref": "#/$defs/urlOrList" }, - "sha256": { - "type": "string", - "description": "Lowercase or uppercase hex SHA-256 of the payload.", - "pattern": "^[0-9a-fA-F]{64}$" - }, - "ed25519": { - "type": "string", - "description": "Base64 (standard, padded) of a 32-byte ed25519 public key.", - "pattern": "^[A-Za-z0-9+/]{43}=$" - }, + "sha256": { "$ref": "#/$defs/sha256" }, + "ed25519": { "$ref": "#/$defs/ed25519" }, "sig_url": { "$ref": "#/$defs/urlOrList" }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Inline argv passed to the payload. Overridden by verified args_url in ed25519 mode." + }, "args_url": { "$ref": "#/$defs/urlOrList" }, "args_sig_url": { "$ref": "#/$defs/urlOrList" } }, @@ -77,23 +97,42 @@ "dependentRequired": { "args_sig_url": ["args_url"] } + }, + "manifest": { + "type": "object", + "description": "Resolve a signed manifest (itself a `_stage2` user-data fragment). stage1 fetches it from `url`, verifies its detached signature (`sig_url`, else `.sig`) against the pinned `ed25519` key, deep-merges the whole document into the doc at the top level, and re-evaluates the entry -- looping until it reaches a `payload`. Optional `sha256` pins the manifest's own bytes.", + "properties": { + "url": { "$ref": "#/$defs/urlOrList" }, + "ed25519": { "$ref": "#/$defs/ed25519" }, + "sig_url": { "$ref": "#/$defs/urlOrList" }, + "sha256": { "$ref": "#/$defs/sha256" } + }, + "required": ["url", "ed25519"], + "additionalProperties": false } }, "examples": [ { "_stage2": { - "x86_64": { "url": "https://host/stage2-amd64", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } + "x86_64": { "payload": { "url": "https://host/stage2-amd64", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "args": ["--flag", "value"] } } } }, { "_stage2": { "x86_64": { - "url": ["https://cdn1/stage2", "https://cdn2/stage2"], - "ed25519": "7UdK/khl49Mb0ADEENiSe/U8uXKX34koFYhdcTVyhpI=", - "sig_url": ["https://cdn1/sigs/{sha256}.sig", "https://cdn2/sigs/{sha256}.sig"], - "args_url": "https://cdn1/args.json" + "payload": { + "url": ["https://cdn1/stage2", "https://cdn2/stage2"], + "ed25519": "7UdK/khl49Mb0ADEENiSe/U8uXKX34koFYhdcTVyhpI=", + "sig_url": ["https://cdn1/sigs/{sha256}.sig", "https://cdn2/sigs/{sha256}.sig"], + "args_url": "https://cdn1/args.json" + } } } + }, + { + "_stage2": { + "x86_64": { "manifest": { "url": "https://host/stage2.manifest.json", "ed25519": "7UdK/khl49Mb0ADEENiSe/U8uXKX34koFYhdcTVyhpI=" } } + } } ] } diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index a022c42..0f6d5c9 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -2,11 +2,19 @@ //! 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 + `admission`/`validate`) 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. +//! +//! Each per-architecture entry ([`ArchConfig`]) is a **discriminated union** — either a +//! [`Payload`] (admit a concrete binary now, by sha256 pin or ed25519-signed) or a [`ManifestRef`] +//! (fetch a signed manifest, itself a user-data fragment, and resolve it). The verifier follows a +//! `manifest` by fetching + verifying it against the pinned key, **deep-merging the whole document +//! into the received user-data at the top level**, and re-evaluating — a loop that terminates at a +//! `payload` (or a cycle). Each hop is appended to [`ArchConfig::resolved_manifests`] (provenance, +//! carrying the resolved hash), which rides along in the doc handed to the payload. use base64::engine::general_purpose::STANDARD; use base64::Engine as _; @@ -21,11 +29,9 @@ pub struct UserData { pub stage2: Option, } -/// One stage's config: shared inline `args` plus a per-architecture admission entry. +/// One stage's config: a per-architecture admission entry (args live inside the [`Payload`]). #[derive(Debug, Serialize, Deserialize)] pub struct StageConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub args: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub aarch64: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -81,12 +87,36 @@ 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: the discriminated union ([`Entry`]) plus the resolution +/// history accumulated by the manifest loop. The `payload` / `manifest` key selects the mode; +/// `resolved_manifests` is a sibling record, appended one entry per resolved manifest hop. #[derive(Debug, Serialize, Deserialize)] pub struct ArchConfig { + #[serde(flatten)] + pub entry: Entry, + /// Provenance of the manifest chain resolved to reach the payload (empty for a direct + /// `payload` entry). Each record carries the fetched manifest's resolved `sha256`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub resolved_manifests: Vec, +} + +/// The per-arch discriminated union: a concrete payload to admit, or a signed manifest to resolve. +#[derive(Debug, Serialize, Deserialize)] +pub enum Entry { + /// `"payload": { url, (sha256 | ed25519 + sig_url), args… }`. + #[serde(rename = "payload")] + Payload(Payload), + /// `"manifest": { url, ed25519, sig_url?, sha256? }`. + #[serde(rename = "manifest")] + Manifest(ManifestRef), +} + +/// A concrete payload admission. Exactly one of `sha256` (pin an exact binary) or `ed25519` +/// (pin a release pubkey; the binary rolls forward via a detached `.sig`) selects the mode — see +/// [`Payload::admission`]. `sig_url` / `args_url` / `args_sig_url` may contain a `{sha256}` +/// placeholder (replaced with the payload digest). +#[derive(Debug, Serialize, Deserialize)] +pub struct Payload { pub url: UrlList, #[serde(default, skip_serializing_if = "Option::is_none")] pub sha256: Option, @@ -95,14 +125,32 @@ pub struct ArchConfig { /// Detached signature location(s); `{sha256}` → payload digest. Defaults to `.sig`. #[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`. + /// Inline argv for the payload (overridden by verified `args_url` when present). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option>, + /// Signed remote args (ed25519 only): a JSON string array, verified against the same key + /// via `args_sig_url` (else `.sig`), overriding inline `args`. #[serde(default, skip_serializing_if = "Option::is_none")] pub args_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub args_sig_url: Option, } +/// A pointer to a signed manifest — and the shape of a resolution-history record. The manifest is +/// fetched from `url`, its detached signature (`sig_url`, else `.sig`) verified against the +/// pinned `ed25519` key, then deep-merged into the doc. `sha256` optionally pins the manifest's own +/// bytes; the loop fills it with the resolved hash when recording the hop in `resolved_manifests`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestRef { + pub url: UrlList, + pub ed25519: String, + /// Manifest signature location; `{sha256}` → manifest digest. Defaults to `.sig`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sig_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, +} + /// Transport policy per stage: stage0 (the UKI hop) has no TLS, stage1 (the payload hop) /// does. Both allow fallback URL lists. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -113,9 +161,9 @@ pub enum Profile { Stage1, } -/// Resolved admission mode. `*_url` templates still carry a raw `{sha256}`; the consumer +/// Resolved payload-admission mode. `*_url` templates still carry a raw `{sha256}`; the consumer /// substitutes it once the payload digest is known. -pub enum Verify { +pub enum Admit { Sha256(String), Ed25519 { pubkey: String, @@ -125,26 +173,48 @@ pub enum Verify { }, } +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()) +} +fn ok_pubkey(s: &str) -> Result<(), &'static str> { + match STANDARD.decode(s.trim()) { + Ok(bytes) if bytes.len() == 32 => Ok(()), + Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), + Err(_) => Err("ed25519 pubkey must be base64"), + } +} + 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 { + /// Validate whichever variant this entry holds, under `profile`'s transport policy. + pub fn validate(&self, profile: Profile) -> Result<(), &'static str> { + match &self.entry { + Entry::Payload(p) => p.admission(profile).map(|_| ()), + Entry::Manifest(m) => m.validate(profile), + } + } +} + +impl Payload { + /// Validate the URL(s) + the single verification field, returning the selected [`Admit`] mode. + pub fn admission(&self, profile: Profile) -> Result { 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) { + if !ok_list(&self.url, allow_https) { return Err("url must be a non-empty http(s):// URL (or list of them), printable ASCII"); } - if self.sig_url.as_ref().is_some_and(|l| !ok_list(l)) { + if self.sig_url.as_ref().is_some_and(|l| !ok_list(l, allow_https)) { return Err("sig_url must be http(s):// URL(s), printable ASCII"); } - if self.args_url.as_ref().is_some_and(|l| !ok_list(l)) { + if self.args_url.as_ref().is_some_and(|l| !ok_list(l, allow_https)) { return Err("args_url must be http(s):// URL(s), printable ASCII"); } - if self.args_sig_url.as_ref().is_some_and(|l| !ok_list(l)) { + if self.args_sig_url.as_ref().is_some_and(|l| !ok_list(l, allow_https)) { return Err("args_sig_url must be http(s):// URL(s), printable ASCII"); } if self.args_sig_url.is_some() && self.args_url.is_none() { @@ -152,31 +222,47 @@ impl ArchConfig { } match (&self.sha256, &self.ed25519) { (Some(_), Some(_)) => Err("specify only one of sha256 / ed25519"), - (None, None) => Err("must specify one of sha256 / ed25519"), + (None, None) => Err("payload 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 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())) + Ok(Admit::Sha256(hex.clone())) } - (None, Some(pubkey)) => match STANDARD.decode(pubkey.trim()) { - Ok(bytes) if bytes.len() == 32 => Ok(Verify::Ed25519 { + (None, Some(pubkey)) => { + ok_pubkey(pubkey)?; + Ok(Admit::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"), - }, + }) + } } } } +impl ManifestRef { + /// Validate the manifest pointer under `profile`'s transport policy. + pub fn validate(&self, profile: Profile) -> Result<(), &'static str> { + let allow_https = matches!(profile, Profile::Stage1); + if !ok_list(&self.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, allow_https)) { + return Err("manifest sig_url must be http(s):// URL(s), printable ASCII"); + } + if self.sha256.as_ref().is_some_and(|h| !ok_sha256(h)) { + return Err("manifest sha256 must be exactly 64 hex characters"); + } + ok_pubkey(&self.ed25519) + } +} + #[cfg(test)] mod tests { use super::*; @@ -189,12 +275,13 @@ mod tests { STANDARD.encode(*KeyPair::from_seed(Seed::new([3u8; 32])).pk) } - fn ac(url: &str, sha256: Option<&str>, ed25519: Option<&str>) -> ArchConfig { - ArchConfig { + fn payload(url: &str, sha256: Option<&str>, ed25519: Option<&str>) -> Payload { + Payload { url: UrlList(vec![url.into()]), sha256: sha256.map(Into::into), ed25519: ed25519.map(Into::into), sig_url: None, + args: None, args_url: None, args_sig_url: None, } @@ -202,64 +289,79 @@ mod tests { #[test] fn sha256_mode_ok() { - assert!(matches!(ac("http://h/p", Some(HASH64), None).validate(Profile::Stage1), Ok(Verify::Sha256(_)))); + assert!(matches!(payload("http://h/p", Some(HASH64), None).admission(Profile::Stage1), Ok(Admit::Sha256(_)))); } #[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()); + assert!(payload("https://h/p", Some(HASH64), None).admission(Profile::Stage1).is_ok()); + assert!(payload("https://h/p", Some(HASH64), None).admission(Profile::Stage0).is_err()); + assert!(payload("http://h/p", Some(HASH64), None).admission(Profile::Stage0).is_ok()); } #[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!(payload("http://h/p", None, Some(&pk)).admission(Profile::Stage1), Ok(Admit::Ed25519 { .. }))); } #[test] fn both_modes_is_error() { let pk = pubkey_b64(); - assert!(ac("http://h/p", Some(HASH64), Some(&pk)).validate(Profile::Stage1).is_err()); + assert!(payload("http://h/p", Some(HASH64), Some(&pk)).admission(Profile::Stage1).is_err()); } #[test] fn neither_mode_is_error() { - assert!(ac("http://h/p", None, None).validate(Profile::Stage1).is_err()); + assert!(payload("http://h/p", None, None).admission(Profile::Stage1).is_err()); } #[test] fn bad_hex_is_error() { - assert!(ac("http://h/p", Some("zz"), None).validate(Profile::Stage1).is_err()); + assert!(payload("http://h/p", Some("zz"), None).admission(Profile::Stage1).is_err()); let sixtyfour_nonhex = "z".repeat(64); - assert!(ac("http://h/p", Some(&sixtyfour_nonhex), None).validate(Profile::Stage1).is_err()); + assert!(payload("http://h/p", Some(&sixtyfour_nonhex), None).admission(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()); + assert!(payload("http://h/p", None, Some("not-base64!!")).admission(Profile::Stage1).is_err()); + assert!(payload("http://h/p", None, Some("AAAA")).admission(Profile::Stage1).is_err()); } #[test] fn non_http_url_is_error() { - assert!(ac("ftp://h/p", Some(HASH64), None).validate(Profile::Stage1).is_err()); + assert!(payload("ftp://h/p", Some(HASH64), None).admission(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()])); - assert!(c.validate(Profile::Stage1).is_err()); + let mut p = payload("http://h/p", Some(HASH64), None); + p.args_url = Some(UrlList(vec!["http://h/args".into()])); + assert!(p.admission(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()])); - assert!(c.validate(Profile::Stage1).is_err()); + let mut p = payload("http://h/p", None, Some(&pk)); + p.args_sig_url = Some(UrlList(vec!["http://h/args.sig".into()])); + assert!(p.admission(Profile::Stage1).is_err()); + } + + #[test] + fn manifest_validate_ok_and_errors() { + let pk = pubkey_b64(); + let mut m = ManifestRef { url: UrlList(vec!["http://h/m".into()]), ed25519: pk, sig_url: None, sha256: None }; + assert!(m.validate(Profile::Stage1).is_ok()); + // optional sha256 pin is checked + m.sha256 = Some("nope".into()); + assert!(m.validate(Profile::Stage1).is_err()); + m.sha256 = Some(HASH64.into()); + assert!(m.validate(Profile::Stage1).is_ok()); + // bad pubkey rejected + m.ed25519 = "AAAA".into(); + assert!(m.validate(Profile::Stage1).is_err()); } #[test] @@ -272,12 +374,47 @@ mod tests { assert_eq!(serde_json::to_string(&many).unwrap(), r#"["http://a/x","http://b/x"]"#); } + // --- Discriminated-union wire representation (the serde-flatten round-trip risk) --- + + #[test] + fn payload_entry_roundtrips() { + let json = format!(r#"{{"payload":{{"url":"http://h/p","sha256":"{HASH64}"}}}}"#); + let ac: ArchConfig = serde_json::from_str(&json).unwrap(); + assert!(matches!(ac.entry, Entry::Payload(_))); + assert!(ac.resolved_manifests.is_empty()); + // re-serialize keeps the "payload" tag and drops the empty history + assert_eq!(serde_json::to_string(&ac).unwrap(), json); + } + #[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()]); - assert!(c.validate(Profile::Stage1).is_ok()); - c.url = UrlList(vec![]); - assert!(c.validate(Profile::Stage1).is_err()); + fn manifest_entry_roundtrips() { + let pk = pubkey_b64(); + let json = format!(r#"{{"manifest":{{"url":"http://h/m","ed25519":"{pk}"}}}}"#); + let ac: ArchConfig = serde_json::from_str(&json).unwrap(); + assert!(matches!(ac.entry, Entry::Manifest(_))); + assert_eq!(serde_json::to_string(&ac).unwrap(), json); + } + + #[test] + fn resolved_manifests_is_a_sibling_of_the_union() { + // The flatten case: the union key AND resolved_manifests coexist on one object. + let pk = pubkey_b64(); + let json = format!( + r#"{{"payload":{{"url":"http://h/p","sha256":"{HASH64}"}},"resolved_manifests":[{{"url":"http://h/m","ed25519":"{pk}","sha256":"{HASH64}"}}]}}"# + ); + let ac: ArchConfig = serde_json::from_str(&json).unwrap(); + assert!(matches!(ac.entry, Entry::Payload(_))); + assert_eq!(ac.resolved_manifests.len(), 1); + assert_eq!(ac.resolved_manifests[0].sha256.as_deref(), Some(HASH64)); + assert_eq!(serde_json::to_string(&ac).unwrap(), json); + } + + #[test] + fn arch_validate_dispatches_on_variant() { + let pk = pubkey_b64(); + let p: ArchConfig = serde_json::from_str(&format!(r#"{{"payload":{{"url":"http://h/p","sha256":"{HASH64}"}}}}"#)).unwrap(); + assert!(p.validate(Profile::Stage1).is_ok()); + let m: ArchConfig = serde_json::from_str(&format!(r#"{{"manifest":{{"url":"http://h/m","ed25519":"{pk}"}}}}"#)).unwrap(); + assert!(m.validate(Profile::Stage1).is_ok()); } } diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index a69c9cd..5740ad0 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -3,7 +3,8 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use bytes::Bytes; -use metadata::{Profile, UrlList, UserData, Verify}; +use metadata::{Admit, ArchConfig, Entry, ManifestRef, Profile, UrlList, UserData}; +use serde_json::Value; use reqwest::blocking::Client; use rustls::crypto::CryptoProvider; use sha2::{Digest, Sha256}; @@ -159,15 +160,14 @@ fn poweroff() { } struct ParsedData { - config: UserData, raw_json: Vec, } fn parse_json_to_config(data: Vec) -> Result { - Ok(ParsedData { - config: serde_json::from_slice(&data).context("Failed to parse JSON")?, - raw_json: data, - }) + // Validate it is a well-formed user-data document up front (clear early error), then keep the + // raw bytes: resolution works on a `serde_json::Value` so it can deep-merge signed manifests. + let _: UserData = serde_json::from_slice(&data).context("Failed to parse JSON")?; + Ok(ParsedData { raw_json: data }) } /// Quote the pre-exec PCR state, binding the about-to-run binary via extra_data (PCR 14 @@ -231,7 +231,7 @@ fn fetch_signed_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>)> { +fn admit_payload(urls: &[String], mode: &Admit) -> Result<(Bytes, Option>)> { let mut last: Option = None; for url in urls { match admit_from(url, mode) { @@ -246,16 +246,16 @@ fn admit_payload(urls: &[String], mode: &Verify) -> Result<(Bytes, Option Result<(Bytes, Option>)> { +fn admit_from(url: &str, mode: &Admit) -> Result<(Bytes, Option>)> { let binary = download_binary(url)?; let hash = hex::encode(sha256!(&binary)); let mut signed_args = None; match mode { - Verify::Sha256(expected) => { + Admit::Sha256(expected) => { verify_checksum(&binary, expected)?; ktseprintln!("verified: sha256:{hash} (sha256 pin)"); } - Verify::Ed25519 { pubkey, sig_url, args_url, args_sig_url } => { + Admit::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")], @@ -273,32 +273,151 @@ fn admit_from(url: &str, mode: &Verify) -> Result<(Bytes, Option>)> } fn stage2(parsed: ParsedData) -> Result<()> { - let stage2 = parsed - .config - .stage2 - .as_ref() - .ok_or_else(|| anyhow!("no _stage2 section in user-data"))?; - let arch_config = stage2 - .for_this_arch() - .ok_or_else(|| anyhow!("no _stage2 config for this architecture"))?; - let mode = arch_config - .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, args, stdin_config) = resolve_payload(&parsed.raw_json)?; if is_root() { generate_pre_execution_attestation(&binary_data)?; extend_pcrs(&binary_data)?; } - // Signed remote args, when present, override inline 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)?; + execute_binary(&binary_data, &args, &stdin_config)?; Ok(()) } +/// Resolve `_stage2.` to a concrete payload and admit it. The entry is a discriminated union: +/// a `payload` is admitted directly (sha256 pin or ed25519-signed, + signed args); a `manifest` is +/// fetched, verified against its pinned key, **deep-merged into the doc at the top level**, and the +/// merged entry re-evaluated — a loop that follows a chain of signed manifests (per-hop key +/// delegation) until it reaches a payload. Each hop is recorded in `_stage2..resolved_manifests` +/// (with its resolved hash); a repeated (url,hash) is a cycle and fails closed. Returns the payload +/// bytes, its argv, and the JSON handed to stage2 on stdin (the merged doc, or — when no manifest was +/// resolved — the received bytes byte-for-byte). +fn resolve_payload(raw_json: &[u8]) -> Result<(Bytes, Vec, Vec)> { + let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }; + let mut doc: Value = serde_json::from_slice(raw_json).context("re-parse user-data")?; + // Verifier-authoritative resolution history (a signed manifest cannot forge or erase it). + let mut history: Vec = Vec::new(); + + loop { + let entry_val = doc + .get("_stage2") + .and_then(|s| s.get(arch)) + .ok_or_else(|| anyhow!("no _stage2 config for this architecture"))?; + let ac: ArchConfig = serde_json::from_value(entry_val.clone()) + .map_err(|e| anyhow!("invalid _stage2 entry: {e}"))?; + + match ac.entry { + Entry::Payload(p) => { + let mode = p + .admission(Profile::Stage1) + .map_err(|m| anyhow!("invalid _stage2 payload: {m}"))?; + let (binary, signed_args) = admit_payload(&p.url.0, &mode)?; + let args = signed_args.or(p.args).unwrap_or_default(); + let stdin = if history.is_empty() { + raw_json.to_vec() // no manifest resolved: pass the received doc through unchanged + } else { + // Stamp the authoritative history over the arch entry, then serialize. + set_resolved_manifests(&mut doc, arch, &history)?; + serde_json::to_vec(&doc).context("re-serialize merged user-data")? + }; + return Ok((binary, args, stdin)); + } + Entry::Manifest(m) => { + m.validate(Profile::Stage1) + .map_err(|e| anyhow!("invalid _stage2 manifest: {e}"))?; + let (murl, bytes, hash) = fetch_manifest(&m)?; + if history + .iter() + .any(|r| r.sha256.as_deref() == Some(hash.as_str()) && r.url.0 == [murl.clone()]) + { + return Err(anyhow!("manifest resolution cycle at {murl} (sha256:{hash})")); + } + history.push(ManifestRef { + url: UrlList(vec![murl]), + ed25519: m.ed25519.clone(), + sig_url: m.sig_url.clone(), + sha256: Some(hash), + }); + // Consume the pointer, then deep-merge the manifest fragment (manifest wins). The + // merged entry re-populates with a `payload` (stop) or a fresh `manifest` (delegate). + if let Some(e) = doc.get_mut("_stage2").and_then(|s| s.get_mut(arch)).and_then(Value::as_object_mut) { + e.remove("manifest"); + } + let manifest_doc: Value = + serde_json::from_slice(&bytes).context("manifest is not valid JSON")?; + deep_merge(&mut doc, &manifest_doc); + } + } + } +} + +/// Overwrite `_stage2..resolved_manifests` with the verifier's authoritative chain, so the +/// payload sees exactly the manifests that were fetched + verified (a signed manifest cannot inject +/// its own provenance — we control this key). +fn set_resolved_manifests(doc: &mut Value, arch: &str, history: &[ManifestRef]) -> Result<()> { + let entry = doc + .get_mut("_stage2") + .and_then(|s| s.get_mut(arch)) + .and_then(Value::as_object_mut) + .ok_or_else(|| anyhow!("_stage2.{arch} vanished during resolution"))?; + entry.insert("resolved_manifests".into(), serde_json::to_value(history)?); + Ok(()) +} + +/// Fetch a signed manifest (mirror fallback) and verify its detached signature against the pinned +/// key. Returns the serving URL, the verified bytes, and their hex sha256. +fn fetch_manifest(m: &ManifestRef) -> Result<(String, Bytes, String)> { + let mut last: Option = None; + for url in &m.url.0 { + match try_fetch_manifest(m, url) { + Ok((bytes, hash)) => return Ok((url.clone(), bytes, hash)), + Err(e) => { + ktseprintln!("manifest rejected: {url} ({e:#})"); + last = Some(e); + } + } + } + Err(last.unwrap_or_else(|| anyhow!("no manifest url provided"))) +} + +fn try_fetch_manifest(m: &ManifestRef, url: &str) -> Result<(Bytes, String)> { + let bytes = download_binary(url)?; + let hash = hex::encode(sha256!(&bytes)); + if let Some(pin) = &m.sha256 { + if !pin.eq_ignore_ascii_case(&hash) { + return Err(anyhow!("manifest sha256 mismatch: expected {pin}, got {hash}")); + } + } + let sig_urls = match &m.sig_url { + Some(u) => substitute(&u.0, &hash), + None => vec![format!("{url}.sig")], + }; + let signature = download_first(&sig_urls)?; + ed25519_sign::verify(&m.ed25519, &bytes, &signature) + .map_err(|e| anyhow!("manifest verification failed: {e}"))?; + ktseprintln!("manifest verified: sha256:{hash} (ed25519 key:{})", m.ed25519); + Ok((bytes, hash)) +} + +/// Recursively merge `overlay` into `base`: two objects merge key-by-key (recursing on shared +/// keys); anything else (a leaf, or a type mismatch) takes `overlay`. `overlay` (the signed +/// manifest) therefore wins on every conflict. +fn deep_merge(base: &mut Value, overlay: &Value) { + match (base, overlay) { + (Value::Object(b), Value::Object(o)) => { + for (k, v) in o { + match b.get_mut(k) { + Some(existing) => deep_merge(existing, v), + None => { + b.insert(k.clone(), v.clone()); + } + } + } + } + (b, o) => *b = o.clone(), + } +} + fn log_hash(label: &str, data: &[u8]) { ktseprintln!("{} sha256={}", label, hex::encode(sha256!(data))); } @@ -502,3 +621,63 @@ fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<() } Err(anyhow!("execveat stage2 failed: {}", io::Error::last_os_error())) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A resolved manifest is deep-merged into the received doc at the **top level**: the operator's + /// unique keys survive, shared objects merge recursively, and the manifest's unique keys (e.g. a + /// release-injected `blah`) appear at the top level. After the `manifest` pointer is consumed, + /// the manifest supplies the `payload` for the same arch entry. + #[test] + fn manifest_deep_merges_at_top_level() { + // operator entry after the `manifest` key has been consumed (only resolution state left) + let mut doc = json!({ + "_stage1": { "x86_64": { "payload": { "url": "http://h/uki", "sha256": "aa" } } }, + "_stage2": { "x86_64": {} }, + }); + let manifest = json!({ + "_stage2": { "x86_64": { "payload": { "url": "http://h/stage2", "sha256": "bb", "args": ["--x"] } } }, + "blah": { "z": 2 }, + }); + deep_merge(&mut doc, &manifest); + assert_eq!( + doc, + json!({ + "_stage1": { "x86_64": { "payload": { "url": "http://h/uki", "sha256": "aa" } } }, + "_stage2": { "x86_64": { "payload": { "url": "http://h/stage2", "sha256": "bb", "args": ["--x"] } } }, + "blah": { "z": 2 }, + }) + ); + } + + /// On a genuine leaf conflict the manifest (overlay) wins. + #[test] + fn manifest_wins_on_conflict() { + let mut base = json!({ "_stage2": { "x86_64": { "payload": { "args": ["operator"] } } }, "keep": 1 }); + deep_merge(&mut base, &json!({ "_stage2": { "x86_64": { "payload": { "args": ["release"] } } } })); + assert_eq!(base, json!({ "_stage2": { "x86_64": { "payload": { "args": ["release"] } } }, "keep": 1 })); + } + + /// The verifier stamps the authoritative chain over the arch entry (overwriting any value a + /// manifest tried to inject), as a sibling of the union key. + #[test] + fn resolved_manifests_are_verifier_authoritative() { + let mut doc = json!({ "_stage2": { "x86_64": { "payload": { "url": "http://h/p", "sha256": "bb" }, + "resolved_manifests": [{ "url": "http://evil", "ed25519": "FORGED" }] } } }); + let history = vec![ManifestRef { + url: UrlList(vec!["http://h/m".into()]), + ed25519: "REALKEY".into(), + sig_url: None, + sha256: Some("cc".into()), + }]; + set_resolved_manifests(&mut doc, "x86_64", &history).unwrap(); + let rm = &doc["_stage2"]["x86_64"]["resolved_manifests"]; + assert_eq!(rm.as_array().unwrap().len(), 1); + assert_eq!(rm[0]["ed25519"], "REALKEY"); + assert_eq!(rm[0]["url"], "http://h/m"); + assert_eq!(rm[0]["sha256"], "cc"); + } +}