From c4c7a9f8cf922f086ee211631622ac4809cc3723 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:39:24 +0000 Subject: [PATCH] ci: add fmt/clippy/test gate + clean the tree to pass it Add workspace-wide `ci` + `fmt-fix` Makefile targets (fmt-check + clippy -D warnings + test) and a `ci` job to the workflow, with `build` renamed to the clean contexts build-x86_64 / build-aarch64 (needs: ci). The uniform CI gate the workspace branch ruleset requires. Bring the tree clean, no #[allow]: - rustfmt across the workspace - deploy: bundle the shared (dir, arch, bases) triple into an ArchCtx to fix too_many_arguments on build_payload / wrap_manifest - stage1: fix unit_arg (split Ok(println!(..))), unnecessary u32 casts, and a needless question-mark (clippy --fix) make ci green (fmt + clippy + 27 tests). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 10 ++ Makefile | 10 ++ crates/deploy/src/main.rs | 250 +++++++++++++++++++++++++-------- crates/ed25519-sign/src/lib.rs | 29 +++- crates/metadata/src/lib.rs | 109 +++++++++++--- crates/mkuki/src/cpio.rs | 40 +++++- crates/mkuki/src/lib.rs | 56 ++++++-- crates/mkuki/src/main.rs | 13 +- crates/mkuki/src/uki.rs | 30 ++-- crates/stage1/src/main.rs | 131 ++++++++++++----- 10 files changed, 525 insertions(+), 153 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65d2c12..4c8e23f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,17 @@ on: workflow_dispatch: jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: CI (fmt-check + clippy + test) + run: make ci + build: + name: build-${{ matrix.arch }} + needs: ci runs-on: ubuntu-latest strategy: matrix: diff --git a/Makefile b/Makefile index 1ee9553..1754bc3 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,16 @@ docker-shell-base: docker-build-base docker-clean: docker rmi $(BUILD_IMAGE) || true +# ---- CI gate: fmt + clippy + unit tests across the workspace (what the branch ruleset requires) ---- +.PHONY: ci fmt-fix +ci: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ + cargo fmt --all --check && \ + cargo clippy --workspace --all-targets --locked -- -D warnings && \ + cargo test --workspace --locked" +fmt-fix: docker-build-base ## Apply rustfmt across the whole workspace (no --check) + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo fmt --all + ##################################################################### # UKI build (stage0 serves linux.efi over the network and admits it by diff --git a/crates/deploy/src/main.rs b/crates/deploy/src/main.rs index 256ee32..9ae860a 100644 --- a/crates/deploy/src/main.rs +++ b/crates/deploy/src/main.rs @@ -15,7 +15,11 @@ use std::fs; use std::path::{Path, PathBuf}; #[derive(Parser)] -#[command(name = "lockboot-deploy", version, about = "Sign payloads, compose mirror URLs, and emit Lock.Boot user-data + an upload-ready directory.")] +#[command( + name = "lockboot-deploy", + version, + about = "Sign payloads, compose mirror URLs, and emit Lock.Boot user-data + an upload-ready directory." +)] struct Cli { #[command(subcommand)] cmd: Cmd, @@ -146,12 +150,22 @@ fn random_seed() -> Result<[u8; 32]> { /// Sign one file for `--domain` with `--key`, writing the detached signature to `--out`. fn sign_file(a: SignArgs) -> Result<()> { - let domain: Domain = a.domain.parse().map_err(|e| anyhow!("--domain {}: {e}", a.domain))?; - let pem = fs::read_to_string(&a.key).with_context(|| format!("reading key {}", a.key.display()))?; + let domain: Domain = a + .domain + .parse() + .map_err(|e| anyhow!("--domain {}: {e}", a.domain))?; + let pem = + fs::read_to_string(&a.key).with_context(|| format!("reading key {}", a.key.display()))?; let bytes = fs::read(&a.input).with_context(|| format!("reading {}", a.input.display()))?; let s = sign(&pem, domain, &bytes)?; fs::write(&a.out, &s.signature).with_context(|| format!("writing {}", a.out.display()))?; - println!("signed {} [{}] -> {} (pubkey {})", a.input.display(), domain.tag(), a.out.display(), s.pubkey_b64); + println!( + "signed {} [{}] -> {} (pubkey {})", + a.input.display(), + domain.tag(), + a.out.display(), + s.pubkey_b64 + ); Ok(()) } @@ -166,42 +180,65 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { .mode(0o600) .open(path) .with_context(|| format!("creating {}", path.display()))?; - f.write_all(bytes).with_context(|| format!("writing {}", path.display())) + f.write_all(bytes) + .with_context(|| format!("writing {}", path.display())) } fn compose_urls(bases: &[String], arch: &str, filename: &str) -> UrlList { - UrlList(bases.iter().map(|b| format!("{b}/{arch}/{filename}")).collect()) + UrlList( + bases + .iter() + .map(|b| format!("{b}/{arch}/{filename}")) + .collect(), + ) } -/// Write `src`'s bytes into `/`, then either sign it (→ `.sig` + pinned +/// Per-arch output context shared by the payload/manifest writers: where files land, and how their +/// URLs are composed. +struct ArchCtx<'a> { + dir: &'a Path, + arch: &'a str, + bases: &'a [String], +} + +/// Write `src`'s bytes into `/`, then either sign it (→ `.sig` + pinned /// 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], + ctx: &ArchCtx, filename: &str, src: &Path, key_pem: Option<&str>, domain: Domain, ) -> Result { let bytes = fs::read(src).with_context(|| format!("reading {}", src.display()))?; - fs::write(arch_dir.join(filename), &bytes) - .with_context(|| format!("writing {}/{filename}", arch_dir.display()))?; - let url = compose_urls(bases, arch, filename); + fs::write(ctx.dir.join(filename), &bytes) + .with_context(|| format!("writing {}/{filename}", ctx.dir.display()))?; + let url = compose_urls(ctx.bases, ctx.arch, filename); let (sha256, ed25519) = match key_pem { Some(pem) => { let s = sign(pem, domain, &bytes)?; - fs::write(arch_dir.join(format!("{filename}.sig")), &s.signature)?; + fs::write(ctx.dir.join(format!("{filename}.sig")), &s.signature)?; (None, Some(s.pubkey_b64)) } None => (Some(sha256_hex(&bytes)), None), }; - Ok(Payload { url, sha256, ed25519, sig_url: None, args: 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() } + ArchConfig { + entry: Entry::Payload(payload), + resolved_manifests: Vec::new(), + } } /// Wrap a `payload` in a **signed manifest** (a `` user-data fragment) written to @@ -209,24 +246,23 @@ fn direct_entry(payload: Payload) -> ArchConfig { /// `{"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], + ctx: &ArchCtx, stage_key: &str, filename: &str, payload: Payload, pem: &str, domain: Domain, ) -> Result { + let arch = ctx.arch; 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)?; + fs::write(ctx.dir.join(&name), &bytes)?; let s = sign(pem, domain, &bytes)?; - fs::write(arch_dir.join(format!("{name}.sig")), &s.signature)?; + fs::write(ctx.dir.join(format!("{name}.sig")), &s.signature)?; Ok(ArchConfig { entry: Entry::Manifest(ManifestRef { - url: compose_urls(bases, arch, &name), + url: compose_urls(ctx.bases, ctx.arch, &name), ed25519: s.pubkey_b64, sig_url: None, // verifier defaults to .sig (co-located) sha256: None, @@ -236,7 +272,11 @@ fn wrap_manifest( } fn create(a: CreateArgs) -> Result<()> { - let bases: Vec = a.base_url.iter().map(|b| b.trim_end_matches('/').to_string()).collect(); + let bases: Vec = a + .base_url + .iter() + .map(|b| b.trim_end_matches('/').to_string()) + .collect(); for b in &bases { if !b.starts_with("http://") { bail!("--base-url must be http:// (stage0 admits the UKI over plain HTTP; integrity is the pin, not TLS): {b}"); @@ -258,10 +298,19 @@ fn create(a: CreateArgs) -> Result<()> { // 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, Domain::Stage1Uki)?; - let mut stage2_payload = - build_payload(&arch_dir, &a.arch, &bases, "stage2", &a.stage2, payload_key, Domain::Stage2Payload)?; + let ctx = ArchCtx { + dir: &arch_dir, + arch: &a.arch, + bases: &bases, + }; + let uki_payload = build_payload(&ctx, "linux.efi", &a.uki, payload_key, Domain::Stage1Uki)?; + let mut stage2_payload = build_payload( + &ctx, + "stage2", + &a.stage2, + payload_key, + Domain::Stage2Payload, + )?; // 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). @@ -271,7 +320,9 @@ fn create(a: CreateArgs) -> Result<()> { if a.sign_args { let blob = serde_json::to_vec(&parsed)?; fs::write(arch_dir.join("args.json"), &blob)?; - let pem = key_pem.as_deref().expect("clap requires --key with --sign-args"); + let pem = key_pem + .as_deref() + .expect("clap requires --key with --sign-args"); let s = sign(pem, Domain::Stage2Args, &blob)?; fs::write(arch_dir.join("args.json.sig"), &s.signature)?; stage2_payload.args_url = Some(compose_urls(&bases, &a.arch, "args.json")); @@ -281,18 +332,38 @@ fn create(a: CreateArgs) -> Result<()> { } let (uki_entry, stage2_entry) = if a.manifest { - let pem = key_pem.as_deref().expect("clap requires --key with --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, Domain::Stage1Manifest)?, - wrap_manifest(&arch_dir, &a.arch, &bases, "_stage2", "stage2", stage2_payload, pem, Domain::Stage2Manifest)?, + wrap_manifest( + &ctx, + "_stage1", + "linux.efi", + uki_payload, + pem, + Domain::Stage1Manifest, + )?, + wrap_manifest( + &ctx, + "_stage2", + "stage2", + stage2_payload, + pem, + Domain::Stage2Manifest, + )?, ) } 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}"))?; + 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)?; @@ -301,7 +372,12 @@ fn create(a: CreateArgs) -> Result<()> { (false, true) => "ed25519 (signed)", (false, false) => "sha256 (pinned)", }; - println!("wrote {} + {}/ artifacts [{mode}, {} mirror(s)]", ud_path.display(), a.arch, bases.len()); + println!( + "wrote {} + {}/ artifacts [{mode}, {} mirror(s)]", + ud_path.display(), + a.arch, + bases.len() + ); Ok(()) } @@ -309,11 +385,14 @@ fn create(a: CreateArgs) -> Result<()> { /// preserving any other arch already present. 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")? + 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"))?; + 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)?)?; set_arch(obj, "_stage2", arch, serde_json::to_value(&stage2)?)?; fs::write(path, format!("{}\n", serde_json::to_string_pretty(&doc)?)) @@ -322,8 +401,12 @@ fn merge_user_data(path: &Path, arch: &str, uki: ArchConfig, stage2: ArchConfig) } 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"))?; + 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); Ok(()) } @@ -349,7 +432,12 @@ fn validate(path: &Path) -> Result<()> { } } -fn check_stage(stage: &Option, profile: Profile, name: &str, errors: &mut Vec) { +fn check_stage( + stage: &Option, + profile: Profile, + name: &str, + errors: &mut Vec, +) { let Some(s) = stage else { return }; for (arch, entry) in [("x86_64", &s.x86_64), ("aarch64", &s.aarch64)] { if let Some(e) = entry { @@ -362,19 +450,36 @@ fn check_stage(stage: &Option, profile: Profile, name: &str, errors fn modify(a: ModifyArgs) -> Result<()> { let path = doc_path(&a.path); - let mut doc: Value = - serde_json::from_str(&fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?) - .context("parsing user-data JSON")?; - let adds: Vec = a.add_base_url.iter().map(|b| b.trim_end_matches('/').to_string()).collect(); - let rems: Vec = a.remove_base_url.iter().map(|b| b.trim_end_matches('/').to_string()).collect(); - let obj = doc.as_object_mut().ok_or_else(|| anyhow!("user-data must be a JSON object"))?; + let mut doc: Value = serde_json::from_str( + &fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?, + ) + .context("parsing user-data JSON")?; + let adds: Vec = a + .add_base_url + .iter() + .map(|b| b.trim_end_matches('/').to_string()) + .collect(); + let rems: Vec = a + .remove_base_url + .iter() + .map(|b| b.trim_end_matches('/').to_string()) + .collect(); + 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 }; + let Some(stage) = obj.get_mut(stage_key).and_then(|v| v.as_object_mut()) else { + continue; + }; for (_arch, entry) in stage.iter_mut() { - let Some(em) = entry.as_object_mut() else { continue }; + let Some(em) = entry.as_object_mut() else { + continue; + }; // 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 }; + 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)?; @@ -384,7 +489,12 @@ fn modify(a: ModifyArgs) -> Result<()> { } } fs::write(&path, format!("{}\n", serde_json::to_string_pretty(&doc)?))?; - println!("updated {} (+{} / -{} mirror(s))", path.display(), adds.len(), rems.len()); + println!( + "updated {} (+{} / -{} mirror(s))", + path.display(), + adds.len(), + rems.len() + ); Ok(()) } @@ -394,7 +504,10 @@ fn modify(a: ModifyArgs) -> Result<()> { fn rewrite_urls(v: &Value, adds: &[String], rems: &[String]) -> Result { let mut list: Vec = match v { Value::String(s) => vec![s.clone()], - Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(), + Value::Array(a) => a + .iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect(), _ => bail!("url field must be a string or array of strings"), }; let suffix = list.first().map(|u| url_path(u).to_string()); @@ -416,7 +529,10 @@ fn rewrite_urls(v: &Value, adds: &[String], rems: &[String]) -> Result { /// The path portion of an http(s) URL, e.g. `http://cdn/x86_64/linux.efi` → `/x86_64/linux.efi`. fn url_path(u: &str) -> &str { - let after = u.strip_prefix("http://").or_else(|| u.strip_prefix("https://")).unwrap_or(u); + let after = u + .strip_prefix("http://") + .or_else(|| u.strip_prefix("https://")) + .unwrap_or(u); match after.find('/') { Some(i) => &after[i..], None => "", @@ -437,8 +553,15 @@ mod tests { #[test] fn compose_and_url_path() { - let l = compose_urls(&["http://a".into(), "http://b".into()], "x86_64", "linux.efi"); - assert_eq!(l.0, vec!["http://a/x86_64/linux.efi", "http://b/x86_64/linux.efi"]); + let l = compose_urls( + &["http://a".into(), "http://b".into()], + "x86_64", + "linux.efi", + ); + assert_eq!( + l.0, + vec!["http://a/x86_64/linux.efi", "http://b/x86_64/linux.efi"] + ); assert_eq!(url_path("http://cdn/x86_64/linux.efi"), "/x86_64/linux.efi"); assert_eq!(url_path("https://h:8000/p/q"), "/p/q"); } @@ -447,7 +570,10 @@ mod tests { fn rewrite_urls_add_then_remove() { let v = Value::String("http://cdn1/x86_64/stage2".into()); let v = rewrite_urls(&v, &["http://cdn2".into()], &[]).unwrap(); - assert_eq!(v, serde_json::json!(["http://cdn1/x86_64/stage2", "http://cdn2/x86_64/stage2"])); + assert_eq!( + v, + serde_json::json!(["http://cdn1/x86_64/stage2", "http://cdn2/x86_64/stage2"]) + ); // removing back to one entry collapses to a bare string let v = rewrite_urls(&v, &[], &["http://cdn1".into()]).unwrap(); assert_eq!(v, Value::String("http://cdn2/x86_64/stage2".into())); @@ -481,9 +607,13 @@ mod tests { validate(&out).unwrap(); let ud: UserData = serde_json::from_str(&fs::read_to_string(out.join("user-data.json")).unwrap()).unwrap(); - let Entry::Payload(s2p) = ud.stage2.unwrap().x86_64.unwrap().entry else { panic!("expected payload") }; + 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") }; + 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); } @@ -520,7 +650,9 @@ mod tests { 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") }; + 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. @@ -528,7 +660,9 @@ mod tests { let sig = fs::read(out.join("x86_64/stage2.manifest.json.sig")).unwrap(); ed25519_sign::verify(&mref.ed25519, Domain::Stage2Manifest, &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") }; + 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/ed25519-sign/src/lib.rs b/crates/ed25519-sign/src/lib.rs index a9c8d15..d0d891e 100644 --- a/crates/ed25519-sign/src/lib.rs +++ b/crates/ed25519-sign/src/lib.rs @@ -90,8 +90,10 @@ pub fn verify( 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")?; + 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(preimage(domain, message), &signature) .map_err(|_| "ed25519 signature verification failed") @@ -177,7 +179,13 @@ mod tests { let msg = b"payload bytes"; let s = sign(&pem_from_seed(&[7u8; 32]), Domain::Stage2Payload, msg).unwrap(); assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, msg, &s.signature).is_ok()); - assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, b"tampered!!!!!", &s.signature).is_err()); + assert!(verify( + &s.pubkey_b64, + Domain::Stage2Payload, + b"tampered!!!!!", + &s.signature + ) + .is_err()); let mut bad = s.signature.clone(); bad[0] ^= 1; assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, msg, &bad).is_err()); @@ -199,13 +207,24 @@ mod tests { /// this fails. Values are base64. #[test] fn golden_kat() { - let s = sign(&pem_from_seed(&[7u8; 32]), Domain::Stage1Manifest, b"lockboot-kat").unwrap(); + let s = sign( + &pem_from_seed(&[7u8; 32]), + Domain::Stage1Manifest, + b"lockboot-kat", + ) + .unwrap(); assert_eq!(s.pubkey_b64, "6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw="); assert_eq!( STANDARD.encode(&s.signature), "nVZgjXp9d4zjnj9axtTQALlMADGqGKPTnR6RjMr8h8nI3wNpsBy0M4ZBjVfjlLKRZTN0pH3AAsGJqU0tJRTQDA==" ); - assert!(verify(&s.pubkey_b64, Domain::Stage1Manifest, b"lockboot-kat", &s.signature).is_ok()); + assert!(verify( + &s.pubkey_b64, + Domain::Stage1Manifest, + b"lockboot-kat", + &s.signature + ) + .is_ok()); } #[test] diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 0f6d5c9..cea929e 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -206,15 +206,29 @@ impl Payload { pub fn admission(&self, profile: Profile) -> Result { let allow_https = matches!(profile, Profile::Stage1); if !ok_list(&self.url, allow_https) { - return Err("url must be a non-empty http(s):// URL (or list of them), printable ASCII"); + 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, allow_https)) { + 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, allow_https)) { + 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, allow_https)) { + 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() { @@ -251,9 +265,15 @@ impl ManifestRef { 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"); + 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)) { + 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)) { @@ -289,49 +309,75 @@ mod tests { #[test] fn sha256_mode_ok() { - assert!(matches!(payload("http://h/p", Some(HASH64), None).admission(Profile::Stage1), Ok(Admit::Sha256(_)))); + assert!(matches!( + payload("http://h/p", Some(HASH64), None).admission(Profile::Stage1), + Ok(Admit::Sha256(_)) + )); } #[test] fn https_allowed_on_stage1_only() { - 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()); + 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!(payload("http://h/p", None, Some(&pk)).admission(Profile::Stage1), Ok(Admit::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!(payload("http://h/p", Some(HASH64), Some(&pk)).admission(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!(payload("http://h/p", None, None).admission(Profile::Stage1).is_err()); + assert!(payload("http://h/p", None, None) + .admission(Profile::Stage1) + .is_err()); } #[test] fn bad_hex_is_error() { - assert!(payload("http://h/p", Some("zz"), None).admission(Profile::Stage1).is_err()); + assert!(payload("http://h/p", Some("zz"), None) + .admission(Profile::Stage1) + .is_err()); let sixtyfour_nonhex = "z".repeat(64); - assert!(payload("http://h/p", Some(&sixtyfour_nonhex), None).admission(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!(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()); + 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!(payload("ftp://h/p", Some(HASH64), None).admission(Profile::Stage1).is_err()); + assert!(payload("ftp://h/p", Some(HASH64), None) + .admission(Profile::Stage1) + .is_err()); } #[test] @@ -352,7 +398,12 @@ mod tests { #[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 }; + 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()); @@ -369,9 +420,15 @@ mod tests { 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()]); + assert_eq!( + many.0, + vec!["http://a/x".to_string(), "http://b/x".to_string()] + ); 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"]"#); + 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) --- @@ -412,9 +469,15 @@ mod tests { #[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(); + 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(); + 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/mkuki/src/cpio.rs b/crates/mkuki/src/cpio.rs index 9cc065f..31a4f6f 100644 --- a/crates/mkuki/src/cpio.rs +++ b/crates/mkuki/src/cpio.rs @@ -47,8 +47,8 @@ impl CpioBuilder { /// Ingest a flat root filesystem from a directory tree. pub fn add_dir(&mut self, root: &Path) -> Result<()> { - use std::os::unix::fs::MetadataExt; use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; for dent in WalkDir::new(root).min_depth(1).sort_by_file_name() { let dent = dent?; @@ -63,7 +63,12 @@ impl CpioBuilder { let entry = if ft.is_symlink() { let target = std::fs::read_link(dent.path())?; - Entry::new(name, S_IFLNK | 0o777, 1, target.to_string_lossy().as_bytes().to_vec()) + Entry::new( + name, + S_IFLNK | 0o777, + 1, + target.to_string_lossy().as_bytes().to_vec(), + ) } else if ft.is_dir() { Entry::new(name, S_IFDIR | perms, 2, Vec::new()) } else if ft.is_file() { @@ -173,18 +178,39 @@ impl CpioBuilder { impl Entry { fn new(name: String, mode: u32, nlink: u32, data: Vec) -> Self { - Entry { name, mode, nlink, rdevmajor: 0, rdevminor: 0, data } + Entry { + name, + mode, + nlink, + rdevmajor: 0, + rdevminor: 0, + data, + } } fn dev(name: String, mode: u32, rdev: u64) -> Self { // Linux dev_t encoding (glibc gnu_dev_major/minor). let major = (((rdev >> 8) & 0xfff) | ((rdev >> 32) & !0xfffu64)) as u32; let minor = ((rdev & 0xff) | ((rdev >> 12) & !0xffu64)) as u32; - Entry { name, mode, nlink: 1, rdevmajor: major, rdevminor: minor, data: Vec::new() } + Entry { + name, + mode, + nlink: 1, + rdevmajor: major, + rdevminor: minor, + data: Vec::new(), + } } fn dev_split(name: String, mode: u32, major: u32, minor: u32) -> Self { - Entry { name, mode, nlink: 1, rdevmajor: major, rdevminor: minor, data: Vec::new() } + Entry { + name, + mode, + nlink: 1, + rdevmajor: major, + rdevminor: minor, + data: Vec::new(), + } } fn write(&self, out: &mut Vec, ino: u32) -> Result<()> { @@ -259,7 +285,9 @@ pub fn gzip(data: &[u8]) -> Result> { use flate2::{Compression, GzBuilder}; let mut buf = Vec::new(); { - let mut enc = GzBuilder::new().mtime(0).write(&mut buf, Compression::best()); + let mut enc = GzBuilder::new() + .mtime(0) + .write(&mut buf, Compression::best()); enc.write_all(data)?; enc.finish()?; } diff --git a/crates/mkuki/src/lib.rs b/crates/mkuki/src/lib.rs index 8e36c44..baddbe9 100644 --- a/crates/mkuki/src/lib.rs +++ b/crates/mkuki/src/lib.rs @@ -45,8 +45,8 @@ impl LayerSource { if path.is_dir() { return Ok(LayerSource::Dir(path.to_path_buf())); } - let raw = std::fs::read(path) - .with_context(|| format!("reading layer {}", path.display()))?; + let raw = + std::fs::read(path).with_context(|| format!("reading layer {}", path.display()))?; if raw.starts_with(&[0x1f, 0x8b]) { let mut dec = flate2::read::GzDecoder::new(&raw[..]); let mut tar = Vec::new(); @@ -68,7 +68,10 @@ pub struct Layer { impl Layer { pub fn new(label: impl Into, source: LayerSource) -> Self { - Layer { label: label.into(), source } + Layer { + label: label.into(), + source, + } } /// Build a layer from a path, deriving the label from the file name (up to the @@ -128,14 +131,29 @@ pub struct UkiSpec<'a> { pub fn assemble(spec: &UkiSpec) -> Result> { info!("assembling UKI"); let mut sections = vec![ - uki::Section { name: ".osrel", data: spec.os_release }, - uki::Section { name: ".cmdline", data: spec.cmdline }, + uki::Section { + name: ".osrel", + data: spec.os_release, + }, + uki::Section { + name: ".cmdline", + data: spec.cmdline, + }, ]; if let Some(u) = spec.uname { - sections.push(uki::Section { name: ".uname", data: u.as_bytes() }); + sections.push(uki::Section { + name: ".uname", + data: u.as_bytes(), + }); } - sections.push(uki::Section { name: ".linux", data: spec.kernel }); - sections.push(uki::Section { name: ".initrd", data: spec.initrd }); + sections.push(uki::Section { + name: ".linux", + data: spec.kernel, + }); + sections.push(uki::Section { + name: ".initrd", + data: spec.initrd, + }); uki::build(spec.stub, §ions) } @@ -153,7 +171,13 @@ fn build_layer(src: &LayerSource) -> Result> { /// `.`, falling back to "layer". fn layer_label(p: &Path) -> String { p.file_name() - .map(|s| s.to_string_lossy().split('.').next().unwrap_or("layer").to_string()) + .map(|s| { + s.to_string_lossy() + .split('.') + .next() + .unwrap_or("layer") + .to_string() + }) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "layer".to_string()) } @@ -167,7 +191,10 @@ pub fn docker_export(engine: &str, image: &str) -> Result> { .output() .with_context(|| format!("running `{engine} create`"))?; if !create.status.success() { - bail!("`{engine} create {image}` failed: {}", String::from_utf8_lossy(&create.stderr)); + bail!( + "`{engine} create {image}` failed: {}", + String::from_utf8_lossy(&create.stderr) + ); } let cid = String::from_utf8(create.stdout)?.trim().to_string(); @@ -180,7 +207,10 @@ pub fn docker_export(engine: &str, image: &str) -> Result> { let _ = Command::new(engine).args(["rm", "-f", &cid]).output(); if !export.status.success() { - bail!("`{engine} export {cid}` failed: {}", String::from_utf8_lossy(&export.stderr)); + bail!( + "`{engine} export {cid}` failed: {}", + String::from_utf8_lossy(&export.stderr) + ); } Ok(export.stdout) } @@ -214,7 +244,5 @@ pub fn generate_os_release( short_d(cmdline), initrd, ); - format!( - "ID={id}\nNAME=\"{id}\"\nPRETTY_NAME=\"{id}\"\nBUILD_ID={build_id}\n" - ) + format!("ID={id}\nNAME=\"{id}\"\nPRETTY_NAME=\"{id}\"\nBUILD_ID={build_id}\n") } diff --git a/crates/mkuki/src/main.rs b/crates/mkuki/src/main.rs index 93bbe6b..6a15526 100644 --- a/crates/mkuki/src/main.rs +++ b/crates/mkuki/src/main.rs @@ -112,8 +112,13 @@ fn main() -> Result<()> { // --- os-release (.osrel) --- let osrel = match &args.os_release { Some(p) => std::fs::read(p).with_context(|| format!("reading {}", p.display()))?, - None => generate_os_release(&args.id, &kernel, args.cmdline.as_bytes(), &initrd.layer_hashes) - .into_bytes(), + None => generate_os_release( + &args.id, + &kernel, + args.cmdline.as_bytes(), + &initrd.layer_hashes, + ) + .into_bytes(), }; // --- assemble UKI --- @@ -164,7 +169,9 @@ fn resolve_layers(args: &Args) -> Result> { // os-release stays byte-identical to the pre-layering output. Ok(vec![Layer::new("rootfs", LayerSource::from_path(rootfs)?)]) } else { - bail!("provide --layer (repeatable), --rootfs , or --from-docker "); + bail!( + "provide --layer (repeatable), --rootfs , or --from-docker " + ); } } diff --git a/crates/mkuki/src/uki.rs b/crates/mkuki/src/uki.rs index 0653616..38608ab 100644 --- a/crates/mkuki/src/uki.rs +++ b/crates/mkuki/src/uki.rs @@ -24,9 +24,9 @@ pub struct Section<'a> { } struct PeView { - pe_off: usize, // offset of "PE\0\0" - opt_off: usize, // offset of the optional header - sect_table: usize, // offset of the first section header + pe_off: usize, // offset of "PE\0\0" + opt_off: usize, // offset of the optional header + sect_table: usize, // offset of the first section header num_sections: usize, section_align: u32, file_align: u32, @@ -55,14 +55,20 @@ impl PeView { fn parse(b: &[u8]) -> Result { ensure!(b.len() > 0x40 && &b[0..2] == b"MZ", "not a PE/MZ image"); let pe_off = rd_u32(b, 0x3c) as usize; - ensure!(pe_off + 24 <= b.len() && &b[pe_off..pe_off + 4] == b"PE\0\0", "bad PE signature"); + ensure!( + pe_off + 24 <= b.len() && &b[pe_off..pe_off + 4] == b"PE\0\0", + "bad PE signature" + ); let coff = pe_off + 4; let num_sections = rd_u16(b, coff + 2) as usize; let size_opt = rd_u16(b, coff + 16) as usize; let opt_off = coff + 20; // PE32+ (0x20b) is what UEFI images use; the field offsets below assume it. let magic = rd_u16(b, opt_off); - ensure!(magic == 0x20b, "expected PE32+ image (magic 0x20b), got {magic:#x}"); + ensure!( + magic == 0x20b, + "expected PE32+ image (magic 0x20b), got {magic:#x}" + ); let section_align = rd_u32(b, opt_off + 32); let file_align = rd_u32(b, opt_off + 36); let size_of_headers = rd_u32(b, opt_off + 60); @@ -125,7 +131,11 @@ pub fn build(stub: &[u8], sections: &[Section]) -> Result> { for s in sections { let name_bytes = s.name.as_bytes(); - ensure!(name_bytes.len() <= 8, "section name {:?} exceeds 8 bytes", s.name); + ensure!( + name_bytes.len() <= 8, + "section name {:?} exceeds 8 bytes", + s.name + ); // Append raw data at an aligned file offset. let raw_ptr = align_up(out.len() as u64, file_align); @@ -140,8 +150,12 @@ pub fn build(stub: &[u8], sections: &[Section]) -> Result> { wr_u32(&mut sh, 12, vma as u32); // VirtualAddress wr_u32(&mut sh, 16, raw_size as u32); // SizeOfRawData wr_u32(&mut sh, 20, raw_ptr as u32); // PointerToRawData - // PointerToRelocations/Linenumbers + counts stay 0. - wr_u32(&mut sh, 36, IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ); + // PointerToRelocations/Linenumbers + counts stay 0. + wr_u32( + &mut sh, + 36, + IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ, + ); headers.push(sh); vma = align_up(vma + s.data.len() as u64, section_align); diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index d4da785..0a4cfdc 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -4,12 +4,10 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use bytes::Bytes; use metadata::{Admit, ArchConfig, Entry, ManifestRef, Profile, UrlList, UserData}; -use serde_json::Value; use reqwest::blocking::Client; use rustls::crypto::CryptoProvider; +use serde_json::Value; use sha2::{Digest, Sha256}; -use vaportpm_attest::{PcrOps, Tpm}; -use vaportpm_attest as tpm; use std::ffi::CString; use std::fs; use std::io::{self, Read, Write}; @@ -17,11 +15,15 @@ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStringExt; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use vaportpm_attest as tpm; +use vaportpm_attest::{PcrOps, Tpm}; 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 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"; // stage1 measures only loaded code: PCR 14 = SHA-256 of the stage2 binary, nothing else. @@ -74,7 +76,8 @@ fn main_inner() -> Result<()> { .to_string() .into_bytes() }; - return Ok(println!("{}", tpm::attest(&nonce)?)); + println!("{}", tpm::attest(&nonce)?); + return Ok(()); } // Default (and the PID-1 boot path): fetch the user-data doc from cloud metadata. @@ -90,12 +93,14 @@ fn stdin_config() -> Result>> { if unsafe { libc::fstat(fd, &mut st) } != 0 { return Ok(None); } - let kind = st.st_mode as u32 & libc::S_IFMT as u32; - if kind != libc::S_IFREG as u32 && kind != libc::S_IFIFO as u32 { + let kind = st.st_mode & libc::S_IFMT; + if kind != libc::S_IFREG && kind != libc::S_IFIFO { return Ok(None); // tty / char device / socket -> not a piped config } let mut buf = Vec::new(); - io::stdin().read_to_end(&mut buf).context("read config from stdin")?; + io::stdin() + .read_to_end(&mut buf) + .context("read config from stdin")?; if buf.is_empty() { return Ok(None); } @@ -221,8 +226,13 @@ fn fetch_signed_args( }; let args_bytes = download_first(&args_urls)?; let signature = download_first(&args_sig_urls)?; - ed25519_sign::verify(pubkey, ed25519_sign::Domain::Stage2Args, &args_bytes, &signature) - .map_err(|m| anyhow!("signed args verification failed: {m}"))?; + ed25519_sign::verify( + pubkey, + ed25519_sign::Domain::Stage2Args, + &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()); @@ -255,14 +265,24 @@ fn admit_from(url: &str, mode: &Admit) -> Result<(Bytes, Option>)> { verify_checksum(&binary, expected)?; ktseprintln!("verified: sha256:{hash} (sha256 pin)"); } - Admit::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")], }; let signature = download_first(&sig_urls)?; - ed25519_sign::verify(pubkey, ed25519_sign::Domain::Stage2Payload, &binary, &signature) - .map_err(|m| anyhow!("ed25519 verification failed: {m}"))?; + ed25519_sign::verify( + pubkey, + ed25519_sign::Domain::Stage2Payload, + &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)?); @@ -293,7 +313,11 @@ fn stage2(parsed: ParsedData) -> Result<()> { /// 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 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(); @@ -326,11 +350,12 @@ fn resolve_payload(raw_json: &[u8]) -> Result<(Bytes, Vec, Vec)> { 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})")); + 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]), @@ -340,7 +365,11 @@ fn resolve_payload(raw_json: &[u8]) -> Result<(Bytes, Vec, Vec)> { }); // 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) { + 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 = @@ -385,7 +414,9 @@ fn try_fetch_manifest(m: &ManifestRef, url: &str) -> Result<(Bytes, String)> { 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}")); + return Err(anyhow!( + "manifest sha256 mismatch: expected {pin}, got {hash}" + )); } } let sig_urls = match &m.sig_url { @@ -393,9 +424,17 @@ fn try_fetch_manifest(m: &ManifestRef, url: &str) -> Result<(Bytes, String)> { None => vec![format!("{url}.sig")], }; let signature = download_first(&sig_urls)?; - ed25519_sign::verify(&m.ed25519, ed25519_sign::Domain::Stage2Manifest, &bytes, &signature) - .map_err(|e| anyhow!("manifest verification failed: {e}"))?; - ktseprintln!("manifest verified: sha256:{hash} (ed25519 key:{})", m.ed25519); + ed25519_sign::verify( + &m.ed25519, + ed25519_sign::Domain::Stage2Manifest, + &bytes, + &signature, + ) + .map_err(|e| anyhow!("manifest verification failed: {e}"))?; + ktseprintln!( + "manifest verified: sha256:{hash} (ed25519 key:{})", + m.ed25519 + ); Ok((bytes, hash)) } @@ -451,7 +490,7 @@ fn try_fetch_ec2(client: &Client) -> Result { .context("Failed to read EC2 user-data response")? .to_vec(); log_hash(EC2_METADATA_URL, &body); - Ok(parse_json_to_config(body)?) + parse_json_to_config(body) } /// Try to fetch user-data from GCP metadata service @@ -466,7 +505,7 @@ fn try_fetch_gcp(client: &Client) -> Result { .context("Failed to read GCP user-data response")? .to_vec(); log_hash(GCP_METADATA_URL, &body); - Ok(parse_json_to_config(body)?) + parse_json_to_config(body) } /// Try to fetch user-data from Azure IMDS @@ -518,7 +557,8 @@ fn verify_checksum(data: &[u8], expected_hex: &str) -> Result<()> { if actual_hex.to_lowercase() != expected_hex.to_lowercase() { return Err(anyhow!( "SHA256 checksum mismatch!\nExpected: {}\nActual: {}", - expected_hex, actual_hex + expected_hex, + actual_hex )); } Ok(()) @@ -535,7 +575,8 @@ const MFD_EXEC: libc::c_uint = 0x0010; fn make_memfd(name: &str, data: &[u8], seal: bool, exec: bool) -> Result { let cname = CString::new(name).expect("memfd name has no interior NUL"); let base: libc::c_uint = libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING; - let mut raw = unsafe { libc::memfd_create(cname.as_ptr(), base | if exec { MFD_EXEC } else { 0 }) }; + let mut raw = + unsafe { libc::memfd_create(cname.as_ptr(), base | if exec { MFD_EXEC } else { 0 }) }; if raw < 0 && exec && io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL) { // Pre-6.3 kernel: no MFD_EXEC. New memfds are executable by default there. raw = unsafe { libc::memfd_create(cname.as_ptr(), base) }; @@ -548,7 +589,11 @@ fn make_memfd(name: &str, data: &[u8], seal: bool, exec: bool) -> Result Result Result Vec<*const libc::c_char> { - v.iter().map(|c| c.as_ptr()).chain(std::iter::once(std::ptr::null())).collect() + v.iter() + .map(|c| c.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect() } /// Exec the stage2 payload from a sealed, anonymous memfd (nothing on any named path): @@ -619,7 +668,10 @@ fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<() libc::AT_EMPTY_PATH, ); } - Err(anyhow!("execveat stage2 failed: {}", io::Error::last_os_error())) + Err(anyhow!( + "execveat stage2 failed: {}", + io::Error::last_os_error() + )) } #[cfg(test)] @@ -656,9 +708,16 @@ mod tests { /// 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 })); + 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