diff --git a/Cargo.lock b/Cargo.lock index 7cd023b..ca37089 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1783,6 +1783,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", + "ed25519-compact", "hex", "libc", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index c7aba25..92bb1f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ sha2 = "0.10" anyhow = "1.0" hex = "0.4" libc = "0.2" +# ed25519 verification for signed-mode payloads (verify-only). Same crate stage0/mkuki +# use — the detached .sig is a cross-repo wire contract. +ed25519-compact = { version = "2", default-features = false } vaportpm-attest = { git = "https://github.com/lockboot/vaportpm" } diff --git a/Makefile b/Makefile index eefd232..4d37f2b 100644 --- a/Makefile +++ b/Makefile @@ -203,51 +203,85 @@ build/keys/release.pem: docker-build-base openssl pkey -in build/keys/release.pem -pubout -outform DER \ | tail -c 32 | base64 -w0 > build/keys/release.pub.b64" -# Detached ed25519 signature over the whole UKI (SIGN=1). Deterministic per RFC 8032, -# so `openssl pkeyutl -rawin` yields the exact bytes stage0 verifies with the pinned -# pubkey (same approach stage0 uses to sign its own test payload). Served as -# linux.efi.sig; stage0 fetches .sig when the manifest carries `ed25519`. +# Detached ed25519 sigs over the UKI and stage2 (SIGN=1). ed25519 is deterministic, so +# `openssl pkeyutl -rawin` yields the exact bytes stage0/stage1 verify against the pinned +# pubkey. Served as .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" + # Guard the arch-less form with a helpful message instead of "no rule to make target". .PHONY: test-chain test-chain: @echo "'$@' needs an arch suffix, e.g. 'make $@-x86_64' or 'make $@-aarch64'." >&2 @exit 2 -# Full-chain end-to-end test: stage0 -> UKI -> stage1 -> example-stage2, all served -# from one local dir (no S3). A single served user-data carries `_stage1` (stage0 -# admits the UKI) and `_stage2` (stage1 admits the leaf by sha256); the two parsers -# coexist on distinct keys. Hashes are computed from the local files so the doc can -# never go stale. SIGN=1 additionally serves linux.efi.sig and pins the ed25519 -# pubkey for `_stage1` instead of a sha256. -test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 $(if $(SIGN),build/%/linux.efi.sig) +# Full-chain end-to-end test: stage0 -> UKI -> stage1 -> example-stage2, served from one +# local dir. One served user-data carries `_stage1` (stage0 admits the UKI) and `_stage2` +# (stage1 admits the leaf); the two parsers coexist on distinct keys. Hashes are computed +# from the local files so the doc can't go stale. Modes: +# (default) sha256 pins for both hops. +# SIGN=1 ed25519 for BOTH hops: serve linux.efi.sig + stage2.sig, pin the release +# pubkey in _stage1 and _stage2 (payloads roll forward under a stable key). +# SIGN_ARGS=1 (implies SIGN) also serve signed args.json (+ .sig) and set _stage2.args_url, +# exercising stage1's signed-remote-args path. +# FALLBACK=1 make the _stage2 url a list [dead 127.0.0.1:9, real] so stage1's mirror +# fallback is exercised (the first url refuses, the second serves). +test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \ + $(if $(SIGN),build/%/linux.efi.sig build/%/stage2.sig) \ + $(if $(SIGN_ARGS),build/%/args.json.sig) @if [ ! -f "$(STAGE0_BOOT_DISK)" ]; then \ echo "Missing external stage0 boot disk: $(STAGE0_BOOT_DISK)" >&2; \ echo "Build it first: (cd $(STAGE0_DIR) && make build-$*)" >&2; \ echo "or set STAGE0_BOOT_DISK= to one unpacked from a stage0 release." >&2; \ exit 1; \ fi - @D="build/$*/chain"; rm -rf "$$D"; mkdir -p "$$D"; \ + @D="build/$*/chain"; rm -rf "$$D"; mkdir -p "$$D"; H="http://$(SERVE_HOST)"; \ cp tools/build-uki/$*/linux.efi "$$D/linux.efi"; \ cp build/$*/stage2 "$$D/stage2"; \ - S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \ + S2URL="\"$$H/stage2\""; \ + if [ -n "$(FALLBACK)" ]; then S2URL="[ \"http://127.0.0.1:9/stage2\", \"$$H/stage2\" ]"; echo "fallback: stage2 url = [dead 127.0.0.1:9, $$H/stage2]"; fi; \ if [ -n "$(SIGN)" ]; then \ cp build/$*/linux.efi.sig "$$D/linux.efi.sig"; \ + cp build/$*/stage2.sig "$$D/stage2.sig"; \ PUB=$$(cat build/keys/release.pub.b64); \ - printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "ed25519": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \ - "$*" "$(SERVE_HOST)" "$$PUB" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (signed UKI, pubkey $$PUB; stage2 sha256 $$S2_SHA)"; \ + S1="\"$*\": { \"url\": \"$$H/linux.efi\", \"ed25519\": \"$$PUB\" }"; \ + S2="\"$*\": { \"url\": $$S2URL, \"ed25519\": \"$$PUB\""; \ + if [ -n "$(SIGN_ARGS)" ]; then \ + cp build/$*/args.json "$$D/args.json"; \ + cp build/$*/args.json.sig "$$D/args.json.sig"; \ + S2="$$S2, \"args_url\": \"$$H/args.json\""; \ + echo "user-data: signed mode + signed args (pubkey $$PUB)"; \ + else \ + echo "user-data: signed mode (pubkey $$PUB)"; \ + fi; \ + S2="$$S2 }"; \ else \ UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \ - printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "sha256": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \ - "$*" "$(SERVE_HOST)" "$$UKI_SHA" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (chain: UKI sha256 $$UKI_SHA, stage2 sha256 $$S2_SHA)"; \ + S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \ + S1="\"$*\": { \"url\": \"$$H/linux.efi\", \"sha256\": \"$$UKI_SHA\" }"; \ + S2="\"$*\": { \"url\": $$S2URL, \"sha256\": \"$$S2_SHA\" }"; \ + echo "user-data: sha256 mode (UKI $$UKI_SHA, stage2 $$S2_SHA)"; \ fi; \ + printf '{\n "_stage1": { %s },\n "_stage2": { %s }\n}\n' "$$S1" "$$S2" > user-data.stage0.json; \ $(DOCKER_RUN) $(DOCKER_OPT_KVM) \ -e YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1 --cap-add=NET_ADMIN --device=/dev/net/tun \ $(HARNESS_IMAGE) --kind stage0 --arch $* \ diff --git a/README.md b/README.md index 8e49959..d456825 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Part of [Lock.Boot](https://github.com/lockboot) — see the org page for the whole boot chain. **stage1** is the netboot **UKI**: a Unified Kernel Image (Linux kernel + minimal initramfs + the `stage1` bootloader as PID 1) that [stage0](https://github.com/lockboot/stage0) fetches over the network, verifies, measures into PCR 14, and chain-loads. -Once running, `stage1` reads a `_stage2` manifest from cloud metadata (IMDSv2), downloads the stage2 payload, verifies it by `sha256`, extends a TPM PCR, generates an attestation document, and `exec`s it as PID 1. +Once running, `stage1` reads a `_stage2` manifest from cloud metadata (IMDSv2), downloads the stage2 payload, **admits it by a pinned `sha256` or an `ed25519` signature**, extends **PCR 14 with the payload hash** (loaded code only — never config), generates an attestation, and `exec`s it as PID 1. ## Build @@ -26,18 +26,52 @@ make test-chain-x86_64 SIGN=1 # ed25519 signed-manifest admission ## stage2 manifest (`_stage2`) -stage1 admits its payload from a `_stage2` block in the instance's user-data: +stage1 admits its stage2 payload from a `_stage2` block in the instance's user-data, per architecture. Choose **one** admission mode per entry. + +**sha256** — pin an exact payload: ```json { "_stage2": { - "x86_64": { "url": "https://example.com/stage2-amd64", "sha256": "abc123..." }, - "aarch64": { "url": "https://example.com/stage2-arm64", "sha256": "def456..." }, + "x86_64": { "url": "https://host/stage2-amd64", "sha256": "abc123..." }, + "aarch64": { "url": "https://host/stage2-arm64", "sha256": "def456..." }, "args": ["--flag", "value"] } } ``` +**ed25519** — pin a long-term release **public key** (base64 of 32 bytes). The payload can then roll forward with **no reconfiguration**: re-sign it, push it, reboot. stage1 fetches a detached signature at `.sig` (override with `sig_url`; `{sha256}` is substituted) and verifies it against the pinned key: + +```json +{ + "_stage2": { + "x86_64": { + "url": "https://host/stage2-amd64", + "ed25519": "BASE64_32BYTE_PUBKEY", + "args_url": "https://host/args.json" + } + } +} +``` + +`args_url` (ed25519 mode only) fetches a **signed** JSON array of strings — verified against the same key via `.sig` (or an explicit `args_sig_url`) — that **overrides** inline `args`. Generate configs with `stage1 --make-config ` (sha256) or `stage1 --make-config-ed25519 `; sign payloads with `openssl pkeyutl -sign -rawin` (the same key format `mkuki` uses, wire-compatible with stage0). + +**Fallback URLs.** Every URL field (`url`, `sig_url`, `args_url`, `args_sig_url`) accepts either a single string **or a list of strings** tried in order — for mirror resiliency. Because the payload is cryptographically pinned, any mirror that yields verifying bytes is accepted; a dead or wrong mirror is simply skipped. URLs may be `http://` or `https://`, and `sig_url`/`args_url`/`args_sig_url` may contain a `{sha256}` placeholder (replaced with the payload's hex digest, for content-addressed signatures): + +```json +{ + "_stage2": { + "x86_64": { + "url": ["https://cdn1/stage2", "https://cdn2/stage2"], + "ed25519": "BASE64_32BYTE_PUBKEY", + "sig_url": ["https://cdn1/sigs/{sha256}.sig", "https://cdn2/sigs/{sha256}.sig"] + } + } +} +``` + +**Measurement is code-only.** stage1 extends **PCR 14** with the SHA-256 of the stage2 binary and nothing else — the admission pin / key / signature and the config JSON are *not* measured. This keeps the platform quote reproducible from the boot artifacts alone (stage0 → UKI → app), and leaves a stage2 app free to measure whatever config *it* deems trust-relevant (PCR 15 is left untouched for it). + Any statically-linked Linux ELF works; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`. ## Publish the UKI diff --git a/crates/stage1/Cargo.toml b/crates/stage1/Cargo.toml index 4bc8eb7..33b88e6 100644 --- a/crates/stage1/Cargo.toml +++ b/crates/stage1/Cargo.toml @@ -14,5 +14,6 @@ sha2 = { workspace = true } hex = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } +ed25519-compact = { workspace = true } vaportpm-attest = { workspace = true } libc = { workspace = true } diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index 0aa296d..120c26d 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -5,8 +5,7 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use vaportpm_attest::{Tpm, PcrOps}; use reqwest::blocking::Client; use rustls::crypto::CryptoProvider; -use serde::{Deserialize, Serialize}; -use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; use vaportpm_attest as tpm; use std::fs; @@ -17,14 +16,47 @@ use std::process::Command; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +mod sig; + const EC2_TOKEN_URL: &str = "http://169.254.169.254/latest/api/token"; const EC2_METADATA_URL: &str = "http://169.254.169.254/latest/user-data"; const GCP_METADATA_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/attributes/user-data"; const AZURE_METADATA_URL: &str = "http://169.254.169.254/metadata/instance/compute/userData?api-version=2021-02-01&format=text"; const TMP_DIR: &str = "/tmp"; -const PCR_BINARY: u8 = 14; // PCR 14: Stage2 binary hash -const PCR_CONFIG: u8 = 15; // PCR 15: Configuration data hash +// stage1 measures only loaded code: PCR 14 = SHA-256 of the stage2 binary, nothing else. +// Config (and the admission pin/key) is left for the app to measure if it cares. +const PCR_BINARY: u8 = 14; + +/// 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)] +struct UrlList(Vec); + +impl<'de> Deserialize<'de> for UrlList { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + Ok(match OneOrMany::deserialize(d)? { + OneOrMany::One(s) => UrlList(vec![s]), + OneOrMany::Many(v) => UrlList(v), + }) + } +} + +impl Serialize for UrlList { + fn serialize(&self, s: S) -> Result { + match self.0.as_slice() { + [one] => one.serialize(s), + many => many.serialize(s), + } + } +} #[derive(Debug, Serialize, Deserialize)] struct UserData { @@ -41,12 +73,90 @@ struct Stage2Config { x86_64: Option, } +/// One architecture's stage2 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 admission 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. #[derive(Debug, Serialize, Deserialize)] struct ArchConfig { - #[serde(deserialize_with = "deserialize_http_url")] - url: String, - #[serde(deserialize_with = "deserialize_sha256")] - sha256: String, + url: UrlList, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ed25519: Option, + /// Detached signature location(s); `{sha256}` → payload digest. Defaults to `.sig`. + #[serde(default, skip_serializing_if = "Option::is_none")] + 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`. + #[serde(default, skip_serializing_if = "Option::is_none")] + args_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + args_sig_url: Option, +} + +/// Resolved admission mode for a stage2 payload. `*_url` templates still carry a raw +/// `{sha256}`; the consumer substitutes it once the payload digest is known. +enum Verify { + Sha256(String), + Ed25519 { + pubkey: String, + sig_url: Option, + args_url: Option, + args_sig_url: Option, + }, +} + +impl ArchConfig { + /// Validate the URL(s) and the single verification field, returning the selected + /// [`Verify`] mode. Unlike stage0 (a plain-HTTP UEFI stack), stage1 has TLS, so + /// `https://` is allowed alongside `http://`. + fn validate(&self) -> Result { + let ok_url = |s: &str| { + (s.starts_with("http://") || 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 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 self.args_url.as_ref().is_some_and(|l| !ok_list(l)) { + 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)) { + 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() { + return Err("args_sig_url requires args_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 hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + 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"), + }, + } + } } fn main() { @@ -84,7 +194,7 @@ fn main_inner() -> Result<()> { }; return Ok(println!("{}", tpm::attest(&nonce)?)); } - // Handle --make-config command + // Handle --make-config command (sha256 pin: download the payload and hash it) if args[1] == "--make-config" { if args.len() < 4 || args.len() > 5 { return Err(anyhow!("Usage: stage1 --make-config [config.json]")); @@ -95,6 +205,17 @@ fn main_inner() -> Result<()> { } return make_config(arch, &args[3], args.get(4).map(|s| s.as_str())); } + // Handle --make-config-ed25519 command (signed mode: pin a release pubkey) + if args[1] == "--make-config-ed25519" { + if args.len() < 5 || args.len() > 6 { + return Err(anyhow!("Usage: stage1 --make-config-ed25519 [config.json]")); + } + let arch = &args[2]; + if arch != "aarch64" && arch != "x86_64" { + return Err(anyhow!("Architecture must be either 'aarch64' or 'x86_64'")); + } + return make_config_ed25519(arch, &args[3], &args[4], args.get(5).map(|s| s.as_str())); + } // Handle other arguments (--url, --file) if args.len() == 3 { return stage2( @@ -106,11 +227,12 @@ fn main_inner() -> Result<()> { })?); } Err(anyhow!( - "Usage: stage1 [--url | --file | --make-config [config.json] | --attest]\n\ + "Usage: stage1 [--url | --file | --make-config [config.json] | --make-config-ed25519 [config.json] | --attest]\n\ If no arguments are provided (or pid==1): fetches from EC2 metadata service.\n\ - --make-config: Download a file, compute SHA256, and output a JSON config with _stage2.\n\ - ARCH must be 'aarch64' or 'x86_64'. Can be run multiple times with different\n\ - architectures and the same config.json to build a multi-arch config.\n\ + --make-config: Download a file, compute SHA256, and output a JSON config with _stage2. (sha256 pin).\n\ + --make-config-ed25519: Emit a signed-mode config pinning the base64 ed25519 (payload rolls forward).\n\ + ARCH must be 'aarch64' or 'x86_64'. Either can be run repeatedly with the same config.json\n\ + to build a multi-arch config. Add fallback URLs / {{sha256}}-templated sig URLs by editing the JSON.\n\ --attest: Generate TPM attestation with EK certificates, PCRs, and certified signing key" )) } @@ -194,32 +316,6 @@ fn poweroff() { } } -fn deserialize_http_url<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - String::deserialize(deserializer).and_then(|s| { - if !s.starts_with("http://") && !s.starts_with("https://") { - Err(D::Error::custom("url must start with http:// or https://")) - } else if !s.chars().all(|c| c.is_ascii_graphic()) { - Err(D::Error::custom("url must contain only printable ASCII characters (no spaces, tabs, newlines, or control characters)")) - } else { - Ok(s) - } - }) -} - -fn deserialize_sha256<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - String::deserialize(deserializer).and_then(|s| match s.len() { - 64 if s.chars().all(|c| c.is_ascii_hexdigit()) => Ok(s), - 64 => Err(D::Error::custom("sha256 must contain only hexadecimal characters")), - _ => Err(D::Error::custom("sha256 must be exactly 64 characters")), - }) -} - struct ParsedData { config: UserData, raw_json: Vec, @@ -232,15 +328,8 @@ fn parse_json_to_config(data: Vec) -> Result { }) } -fn make_config(arch: &str, url: &str, config_file: Option<&str>) -> Result<()> { - let binary_data = download_binary(url)?; - let sha256_hash = hex::encode(sha256!(binary_data)); - - let arch_config = ArchConfig { - url: url.to_string(), - sha256: sha256_hash, - }; - +/// Merge one arch entry into `_stage2` of an existing (or new) config doc and print it. +fn emit_config(arch: &str, arch_config: ArchConfig, config_file: Option<&str>) -> Result<()> { // Read existing config if provided, otherwise start with empty object let mut config: serde_json::Value = if let Some(path) = config_file { let contents = fs::read_to_string(path) @@ -251,7 +340,6 @@ fn make_config(arch: &str, url: &str, config_file: Option<&str>) -> Result<()> { serde_json::json!({}) }; - // Ensure _stage2 exists if !config.is_object() { return Err(anyhow!("Config file must contain a JSON object")); } @@ -275,34 +363,158 @@ fn make_config(arch: &str, url: &str, config_file: Option<&str>) -> Result<()> { Ok(()) } -/// Generate attestation before modifying PCRs -/// extra_data = H(H(binary),H(config)) -fn generate_pre_execution_attestation(binary_data: &[u8], config_json: &[u8]) -> Result<()> { +/// sha256-pin config: download the payload, hash it, emit `{url, sha256}`. +fn make_config(arch: &str, url: &str, config_file: Option<&str>) -> Result<()> { + let binary_data = download_binary(url)?; + let sha256_hash = hex::encode(sha256!(&binary_data)); + emit_config( + arch, + ArchConfig { + url: UrlList(vec![url.to_string()]), + sha256: Some(sha256_hash), + ed25519: None, + sig_url: None, + args_url: None, + args_sig_url: None, + }, + config_file, + ) +} + +/// signed-mode config: emit `{url, ed25519}` pinning the release pubkey. No download — +/// the payload rolls forward; stage1 fetches `.sig` at boot and verifies it. +fn make_config_ed25519(arch: &str, url: &str, pubkey_b64: &str, config_file: Option<&str>) -> Result<()> { + let arch_config = ArchConfig { + url: UrlList(vec![url.to_string()]), + sha256: None, + ed25519: Some(pubkey_b64.to_string()), + sig_url: None, + args_url: None, + args_sig_url: None, + }; + // Validate the url + pubkey up front so a bad config fails at generation time. + arch_config.validate().map_err(|m| anyhow!("invalid ed25519 config: {m}"))?; + emit_config(arch, arch_config, config_file) +} + +/// Quote the pre-exec PCR state, binding the about-to-run binary via extra_data (PCR 14 +/// does not yet contain it). Code only — config is deliberately not bound. +fn generate_pre_execution_attestation(binary_data: &[u8]) -> Result<()> { let path = format!("{}/stage1.attest", TMP_DIR); - let contents = tpm::attest(&sha256!(sha256!(config_json), sha256!(binary_data)))?; + let contents = tpm::attest(&sha256!(binary_data))?; fs::write(&path, contents).context(format!("Failed to write attestation to {}", &path))?; Ok(()) } -/// Extend PCRs with binary and config data if running as root -/// PCR 14 is extended with the SHA256 hash of the stage2 binary -/// PCR 15 is extended with the SHA256 hash of the config JSON -fn extend_pcrs(binary_data: &[u8], config_json: &[u8]) -> Result<()> { +/// Extend PCR 14 with the stage2 binary hash — the only thing stage1 measures. +fn extend_pcrs(binary_data: &[u8]) -> Result<()> { let mut tpm = Tpm::open()?; tpm.pcr_extend(PCR_BINARY, &sha256!(binary_data))?; - tpm.pcr_extend(PCR_CONFIG, &sha256!(config_json))?; Ok(()) } +/// Replace `{sha256}` in each URL with the payload's hex digest (content-addressing). +fn substitute(urls: &[String], hash: &str) -> Vec { + urls.iter().map(|u| u.replace("{sha256}", hash)).collect() +} + +/// Download the first URL that responds (fallback across mirrors for resiliency). +fn download_first(urls: &[String]) -> Result> { + let mut last: Option = None; + for url in urls { + match download_binary(url) { + Ok(bytes) => return Ok(bytes), + Err(e) => { + ktseprintln!("url unavailable: {url} ({e:#})"); + last = Some(e); + } + } + } + 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)?; + sig::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<(Vec, Option>)> { + let mut last: Option = None; + for url in urls { + match admit_from(url, mode) { + Ok(result) => return Ok(result), + Err(e) => { + ktseprintln!("payload url rejected: {url} ({e:#})"); + last = Some(e); + } + } + } + Err(last.unwrap_or_else(|| anyhow!("no payload url provided"))) +} + +/// Download one payload candidate and run admission control (a GATE — never measured). +fn admit_from(url: &str, mode: &Verify) -> Result<(Vec, 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)?; + sig::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)?); + } + } + } + Ok((binary, signed_args)) +} + fn stage2(parsed: ParsedData) -> Result<()> { let arch_config = get_arch_config(&parsed.config._stage2)?; - let binary_data = download_binary(&arch_config.url)?; - verify_checksum(&binary_data, &arch_config.sha256)?; + let mode = arch_config + .validate() + .map_err(|m| anyhow!("invalid _stage2 config: {m}"))?; + + let (binary_data, signed_args) = admit_payload(&arch_config.url.0, &mode)?; + if is_root() { - generate_pre_execution_attestation(&binary_data, &parsed.raw_json)?; - extend_pcrs(&binary_data, &parsed.raw_json)?; + generate_pre_execution_attestation(&binary_data)?; + extend_pcrs(&binary_data)?; } - let args = parsed.config._stage2.args.as_deref().unwrap_or(&[]); + + // Signed remote args, when present, override inline args. + let inline_args = parsed.config._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)?; Ok(()) } @@ -412,6 +624,8 @@ fn download_binary(url: &str) -> Result> { .get(url) .send() .context("Failed to download binary")? + .error_for_status() + .context("Server returned an error status")? .bytes() .context("Failed to read binary data")? .to_vec(); @@ -430,7 +644,7 @@ fn verify_checksum(data: &[u8], expected_hex: &str) -> Result<()> { } fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<()> { - let tmp_path = format!("{}/stage2.exe", TMP_DIR); + let tmp_path = format!("{}/stage2.exe", TMP_DIR); fs::write(&tmp_path, data) .context(format!("Failed to write binary to {}", tmp_path))?; @@ -453,3 +667,128 @@ fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<() .exec(); Err(anyhow!("Failed to exec binary: {}", err)) } + +#[cfg(test)] +mod tests { + use super::*; + + const HASH64: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn pubkey_b64() -> String { + // base64 of a valid 32-byte ed25519 public key (from a fixed seed). + use ed25519_compact::{KeyPair, Seed}; + STANDARD.encode(*KeyPair::from_seed(Seed::new([3u8; 32])).pk) + } + + fn ac(url: &str, sha256: Option<&str>, ed25519: Option<&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, + } + } + + #[test] + fn sha256_mode_ok() { + assert!(matches!(ac("http://h/p", Some(HASH64), None).validate(), Ok(Verify::Sha256(_)))); + } + + #[test] + fn https_is_allowed() { + assert!(ac("https://h/p", Some(HASH64), None).validate().is_ok()); + } + + #[test] + fn ed25519_mode_ok() { + let pk = pubkey_b64(); + assert!(matches!(ac("http://h/p", None, Some(&pk)).validate(), Ok(Verify::Ed25519 { .. }))); + } + + #[test] + fn both_modes_is_error() { + let pk = pubkey_b64(); + assert!(ac("http://h/p", Some(HASH64), Some(&pk)).validate().is_err()); + } + + #[test] + fn neither_mode_is_error() { + assert!(ac("http://h/p", None, None).validate().is_err()); + } + + #[test] + fn bad_hex_is_error() { + assert!(ac("http://h/p", Some("zz"), None).validate().is_err()); + let sixtyfour_nonhex = "z".repeat(64); + assert!(ac("http://h/p", Some(&sixtyfour_nonhex), None).validate().is_err()); + } + + #[test] + fn bad_pubkey_is_error() { + assert!(ac("http://h/p", None, Some("not-base64!!")).validate().is_err()); // not base64 + assert!(ac("http://h/p", None, Some("AAAA")).validate().is_err()); // wrong length + } + + #[test] + fn non_http_url_is_error() { + assert!(ac("ftp://h/p", Some(HASH64), None).validate().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().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().is_err()); + } + + #[test] + fn urllist_accepts_string_or_array() { + let one: UrlList = serde_json::from_str(r#""http://a/x""#).unwrap(); + assert_eq!(one.0, vec!["http://a/x".to_string()]); + let many: UrlList = serde_json::from_str(r#"["http://a/x","http://b/x"]"#).unwrap(); + assert_eq!(many.0, vec!["http://a/x".to_string(), "http://b/x".to_string()]); + // serializes back as a bare string when single, array when multiple + assert_eq!(serde_json::to_string(&one).unwrap(), r#""http://a/x""#); + assert_eq!(serde_json::to_string(&many).unwrap(), r#"["http://a/x","http://b/x"]"#); + } + + #[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().is_ok()); + c.url = UrlList(vec![]); + assert!(c.validate().is_err()); + } + + #[test] + fn substitute_replaces_sha256_in_each() { + let urls = vec!["http://h/{sha256}.sig".to_string(), "http://m/x".to_string()]; + let out = substitute(&urls, "deadbeef"); + assert_eq!(out, vec!["http://h/deadbeef.sig".to_string(), "http://m/x".to_string()]); + } + + #[test] + fn parse_ed25519_with_fallback_and_templated_args() { + let pk = pubkey_b64(); + let json = format!( + r#"{{"url":["http://a/p","http://b/p"],"ed25519":"{pk}","args_url":"http://a/args-{{sha256}}.json"}}"# + ); + let c: ArchConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(c.url.0.len(), 2); + match c.validate().unwrap() { + Verify::Ed25519 { args_url: Some(a), .. } => assert!(a.0[0].contains("{sha256}")), + _ => panic!("expected ed25519 mode"), + } + } +} diff --git a/crates/stage1/src/sig.rs b/crates/stage1/src/sig.rs new file mode 100644 index 0000000..2c84be7 --- /dev/null +++ b/crates/stage1/src/sig.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! ed25519 signature verification for "signed mode" stage2 payloads. +//! +//! Wire contract — must stay byte-identical to mkuki's signer and stage0's verifier +//! (github.com/lockboot/stage0, crates/stage0/src/sig.rs): message = raw payload bytes, +//! signature = detached 64 raw bytes, pinned key = base64 of the 32-byte public key. +//! Admission control only: neither the signature nor the key is ever measured. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use ed25519_compact::{PublicKey, Signature}; + +/// Verify a detached ed25519 `signature` over `message` against the base64 +/// `pubkey_b64` pinned in the metadata. +pub fn verify(pubkey_b64: &str, message: &[u8], signature: &[u8]) -> Result<(), &'static str> { + let key_bytes = STANDARD + .decode(pubkey_b64.trim()) + .map_err(|_| "ed25519 pubkey is not valid base64")?; + let public_key = + PublicKey::from_slice(&key_bytes).map_err(|_| "ed25519 pubkey wrong length")?; + let signature = + Signature::from_slice(signature).map_err(|_| "ed25519 signature wrong length")?; + public_key + .verify(message, &signature) + .map_err(|_| "ed25519 signature verification failed") +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_compact::{KeyPair, Seed}; + + #[test] + fn sign_verify_roundtrip() { + // Deterministic keypair from a fixed seed (no RNG needed). + let kp = KeyPair::from_seed(Seed::new([7u8; 32])); + let pubkey_b64 = STANDARD.encode(*kp.pk); + let msg = b"stage2 payload bytes"; + let sig = kp.sk.sign(msg, None).to_vec(); + + // Correct message + signature verifies. + assert!(verify(&pubkey_b64, msg, &sig).is_ok()); + // Tampered message is rejected. + assert!(verify(&pubkey_b64, b"tampered payload!!!!", &sig).is_err()); + // Tampered signature is rejected. + let mut bad = sig.clone(); + bad[0] ^= 0x01; + assert!(verify(&pubkey_b64, msg, &bad).is_err()); + // Wrong-length key / sig are rejected. + assert!(verify("not-base64!!", msg, &sig).is_err()); + assert!(verify(&pubkey_b64, msg, &sig[..63]).is_err()); + } +} diff --git a/schema/stage2.schema.json b/schema/stage2.schema.json new file mode 100644 index 0000000..4648977 --- /dev/null +++ b/schema/stage2.schema.json @@ -0,0 +1,99 @@ +{ + "$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.", + "type": "object", + "required": ["_stage2"], + "properties": { + "_stage2": { "$ref": "#/$defs/stage2Config" }, + "_stage1": { + "type": "object", + "description": "stage0's UKI-admission config (separate schema, http-only, single url). Ignored by stage1." + } + }, + "$defs": { + "stage2Config": { + "type": "object", + "description": "Per-architecture stage2 admission config, plus shared inline args.", + "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" } + }, + "anyOf": [ + { "required": ["x86_64"] }, + { "required": ["aarch64"] } + ], + "additionalProperties": false + }, + "url": { + "type": "string", + "description": "http(s):// URL, printable ASCII (no spaces or control characters).", + "pattern": "^https?://[!-~]+$" + }, + "urlOrList": { + "description": "A single URL, or a non-empty fallback list tried in order.", + "oneOf": [ + { "$ref": "#/$defs/url" }, + { "type": "array", "items": { "$ref": "#/$defs/url" }, "minItems": 1 } + ] + }, + "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.", + "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}=$" + }, + "sig_url": { "$ref": "#/$defs/urlOrList" }, + "args_url": { "$ref": "#/$defs/urlOrList" }, + "args_sig_url": { "$ref": "#/$defs/urlOrList" } + }, + "required": ["url"], + "additionalProperties": false, + "oneOf": [ + { "required": ["sha256"], "not": { "required": ["ed25519"] } }, + { "required": ["ed25519"], "not": { "required": ["sha256"] } } + ], + "allOf": [ + { + "if": { "required": ["args_url"] }, + "then": { "required": ["ed25519"] } + } + ], + "dependentRequired": { + "args_sig_url": ["args_url"] + } + } + }, + "examples": [ + { + "_stage2": { + "x86_64": { "url": "https://host/stage2-amd64", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } + } + }, + { + "_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" + } + } + } + ] +}