From 6c0f4268d5fbcf965401cda8f4d1336d93dd4db3 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:17:34 +0000 Subject: [PATCH 1/2] stage1: split config-gen into a `deploy` tool; extract shared metadata + ed25519 libs The stage1 runtime binary was dual-purpose (boot on-instance AND generate deployment config via --make-config). Split config generation into a separate host-side tool so the on-instance binary carries only runtime concerns, and share the wire types + signer so "what we emit" and "what we verify" cannot drift. - crates/metadata (new): the _stage1/_stage2 wire types (UrlList, ArchConfig, StageConfig, UserData) + Verify + validate(Profile), extracted from the stage1 bin. The stage1 verifier and the deploy emitter both depend on it (one source of truth). Carries the JSON schema + the validate() test table. Profile::Stage0 is http-only; Stage1 allows https. - crates/ed25519-sign (new): the ed25519 wire primitive: sign_payload + verify + sha256_hex. Extracted from mkuki/sign.rs (mkuki re-exports it as `mkuki::sign`) with stage1's sig.rs verify folded in, so mkuki (sign), deploy (sign) and stage1 (verify) share one crate. - crates/deploy (new): `lockboot-deploy` create/validate/modify. `create` signs (or hashes) the UKI + stage2, composes mirror URL lists from repeated --base-url, and emits an upload-ready dir + a merged user-data.json carrying _stage1 + _stage2. `validate` checks a doc via the shared validate(); `modify` adds/removes mirror base URLs. - stage1 bin: slimmed to on-instance runtime only. Removed make_config*/emit_config and the --make-config* CLI; keeps --attest, --url/--file, PID1 boot, admission, measurement. Uses the metadata + ed25519-sign crates. Runtime behavior unchanged. - Makefile: exclude the host-only deploy tool from the payload-target cross-build. Repos stay fully separate: stage0 keeps its own config.rs/sig.rs (minimal root-of-trust audit surface); the shared crates live in the stage1 repo, consumed by its own crates. Verified: ed25519-sign (2) + metadata (12) + deploy (3) unit tests pass; both arches compile; the full chain (stage0 -> UKI -> stage1 -> example-stage2) boots and powers off cleanly against the deploy tool's OWN signed output: both ed25519 hops + signed remote args. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 285 ++---------- Cargo.toml | 2 +- Makefile | 8 +- README.md | 21 +- crates/deploy/Cargo.toml | 17 + crates/deploy/src/main.rs | 353 ++++++++++++++ crates/ed25519-sign/Cargo.toml | 11 + crates/ed25519-sign/src/lib.rs | 116 +++++ crates/metadata/Cargo.toml | 14 + .../metadata/schema}/stage2.schema.json | 0 crates/metadata/src/lib.rs | 283 ++++++++++++ crates/mkuki/Cargo.toml | 6 +- crates/mkuki/src/lib.rs | 5 +- crates/mkuki/src/sign.rs | 70 --- crates/stage1/Cargo.toml | 4 +- crates/stage1/src/main.rs | 436 ++---------------- crates/stage1/src/sig.rs | 54 --- 17 files changed, 889 insertions(+), 796 deletions(-) create mode 100644 crates/deploy/Cargo.toml create mode 100644 crates/deploy/src/main.rs create mode 100644 crates/ed25519-sign/Cargo.toml create mode 100644 crates/ed25519-sign/src/lib.rs create mode 100644 crates/metadata/Cargo.toml rename {schema => crates/metadata/schema}/stage2.schema.json (100%) create mode 100644 crates/metadata/src/lib.rs delete mode 100644 crates/mkuki/src/sign.rs delete mode 100644 crates/stage1/src/sig.rs diff --git a/Cargo.lock b/Cargo.lock index ca37089..abb9658 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,12 +341,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ct-codecs" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" - [[package]] name = "ctr" version = "0.9.2" @@ -383,6 +377,17 @@ dependencies = [ "syn", ] +[[package]] +name = "deploy" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ed25519-sign", + "metadata", + "serde_json", +] + [[package]] name = "der" version = "0.7.10" @@ -459,10 +464,6 @@ name = "ed25519-compact" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5c0284a5d4b1a2fae017a9fe55fd7d01699711f1b572493f16593e173ea2801" -dependencies = [ - "ct-codecs", - "getrandom 0.4.2", -] [[package]] name = "ed25519-dalek" @@ -478,6 +479,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ed25519-sign" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "ed25519-compact", + "sha2", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -499,12 +510,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -570,12 +575,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -657,21 +656,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasip3", - "wasm-bindgen", -] - [[package]] name = "ghash" version = "0.5.1" @@ -704,21 +688,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" @@ -930,12 +899,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -957,18 +920,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - [[package]] name = "inout" version = "0.1.4" @@ -1025,12 +976,6 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.180" @@ -1076,6 +1021,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "metadata" +version = "0.1.0" +dependencies = [ + "base64", + "ed25519-compact", + "serde", + "serde_json", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1104,7 +1059,7 @@ dependencies = [ "anyhow", "base64", "clap", - "ed25519-compact", + "ed25519-sign", "flate2", "sha2", "tar", @@ -1316,16 +1271,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -1353,12 +1298,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "rand" version = "0.8.5" @@ -1385,7 +1324,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.17", + "getrandom", ] [[package]] @@ -1462,7 +1401,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", @@ -1783,13 +1722,13 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", - "ed25519-compact", + "ed25519-sign", "hex", "libc", + "metadata", "reqwest", "rustls", "rustls-rustcrypto", - "serde", "serde_json", "sha2", "vaportpm-attest", @@ -2057,12 +1996,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -2160,24 +2093,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2237,40 +2152,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" version = "0.3.85" @@ -2461,100 +2342,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 92bb1f0..f168a89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/stage1", "crates/example-stage2", "crates/mkuki"] +members = ["crates/stage1", "crates/example-stage2", "crates/mkuki", "crates/ed25519-sign", "crates/metadata", "crates/deploy"] resolver = "2" [workspace.package] diff --git a/Makefile b/Makefile index 4d37f2b..797f8a6 100644 --- a/Makefile +++ b/Makefile @@ -128,13 +128,13 @@ tools/build-uki/%/linux.efi: tools/build-uki/%/busybox tools/build-uki/%/stage1 # Build AND extract stage1 inside the one container step, so the cp runs where # target/ exists rather than in the host/make context, which may not see the build -# container's target dir under nested docker (e.g. `act`). --exclude mkuki: it is a -# build-host tool, built separately for x86_64 by the tools/build-uki/mkuki rule, so -# it must not be cross-compiled for $* here. +# container's target dir under nested docker (e.g. `act`). --exclude mkuki/deploy: they +# are build-host tools (mkuki assembles the UKI; deploy generates deployment config), so +# they must not be cross-compiled for the payload target $* here. tools/build-uki/%/stage1: docker-build-base mkdir -p tools/build-uki/$* $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ - bash -c "rustup target add $*-unknown-linux-musl && cargo build --release --locked --workspace --exclude mkuki --target $*-unknown-linux-musl && cp -v target/$*-unknown-linux-musl/release/stage1 $@" + bash -c "rustup target add $*-unknown-linux-musl && cargo build --release --locked --workspace --exclude mkuki --exclude deploy --target $*-unknown-linux-musl && cp -v target/$*-unknown-linux-musl/release/stage1 $@" # mkuki assembles the UKI from inside build.sh. Unlike stage1 (which runs on the # target arch), mkuki is a build-host tool that runs in the x86_64 build container diff --git a/README.md b/README.md index d456825..618e084 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ stage1 admits its stage2 payload from a `_stage2` block in the instance's user-d } ``` -`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). +`args_url` (ed25519 mode only) fetches a **signed** JSON array of strings — verified against the same key via `.sig` (or an explicit `args_sig_url`) — that **overrides** inline `args`. You don't hand-write these docs: the [`deploy` tool](#deploy) below signs the payloads and generates the `user-data.json`. **Fallback URLs.** Every URL field (`url`, `sig_url`, `args_url`, `args_sig_url`) accepts either a single string **or a list of strings** tried in order — for mirror resiliency. Because the payload is cryptographically pinned, any mirror that yields verifying bytes is accepted; a dead or wrong mirror is simply skipped. URLs may be `http://` or `https://`, and `sig_url`/`args_url`/`args_sig_url` may contain a `{sha256}` placeholder (replaced with the payload's hex digest, for content-addressed signatures): @@ -74,20 +74,29 @@ stage1 admits its stage2 payload from a `_stage2` block in the instance's user-d Any statically-linked Linux ELF works; the minimal rootfs provides `/bin/{busybox,stage1}` (plus `udhcpc.script`) and `/tmp`. -## Publish the UKI +## Deploy -The UKI is served over HTTP(S) for stage0 to fetch. Upload it and print the matching `_stage1` block (the doc stage0 uses to admit the UKI): +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). ```bash -tools/publish.sh s3://bucket/prefix x86_64 local # or gs://bucket/prefix +lockboot-deploy create --arch x86_64 \ + --uki tools/build-uki/x86_64/linux.efi --stage2 build/x86_64/stage2 \ + --key release.pem \ # ed25519 signed mode (omit for sha256 pins) + --base-url http://cdn1 --base-url http://cdn2 \ + --out ./deploy +lockboot-deploy validate ./deploy # check against the admission rules +lockboot-deploy modify ./deploy --add-base-url http://cdn3 # add / --remove-base-url a mirror ``` -The bootable cloud image — the stage0 Secure Boot root — is published from the [stage0 repo](https://github.com/lockboot/stage0), not here. +`create` writes `deploy//{linux.efi,stage2}` (+ `.sig` in signed mode) and merges `deploy/user-data.json`; sync the directory to each mirror and pass `user-data.json` as the instance's user-data. It shares the `metadata` types with the stage1 verifier, so what it emits is exactly what stage0/stage1 accept. (`tools/publish.sh` remains as a simpler UKI-only uploader; the bootable cloud image — the stage0 Secure Boot root — is published from the [stage0 repo](https://github.com/lockboot/stage0).) ## Crates -- **`stage1`** — the PID-1 bootloader baked into the UKI. +- **`stage1`** — the on-instance PID-1 bootloader baked into the UKI (verify-only: admit → measure → exec). +- **`metadata`** — the `_stage1`/`_stage2` wire types + `validate()`, shared by the stage1 verifier and the deploy emitter (one source of truth, no drift). +- **`ed25519-sign`** — the ed25519 sign/verify + sha256 primitive (the cross-repo wire contract), used by `mkuki`, `deploy`, and `stage1`. - **`mkuki`** — reproducible UKI assembler (kernel + gzip'd cpio layers → PE, optional `ed25519` signature); a build-host tool. +- **`deploy`** — the deployment tool above (`lockboot-deploy`); a build-host tool. - **`example-stage2`** — a minimal example leaf payload; copy it as a template for your own stage2. ## License diff --git a/crates/deploy/Cargo.toml b/crates/deploy/Cargo.toml new file mode 100644 index 0000000..df5c740 --- /dev/null +++ b/crates/deploy/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deploy" +version = "0.1.0" +edition = "2021" +license.workspace = true +description = "Lock.Boot deploy tool: sign payloads, compose mirror URLs, and emit the _stage1/_stage2 user-data + an upload-ready directory." + +[[bin]] +name = "lockboot-deploy" +path = "src/main.rs" + +[dependencies] +metadata = { path = "../metadata" } +ed25519-sign = { path = "../ed25519-sign" } +clap = { version = "4", features = ["derive"] } +serde_json = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/deploy/src/main.rs b/crates/deploy/src/main.rs new file mode 100644 index 0000000..2132140 --- /dev/null +++ b/crates/deploy/src/main.rs @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Lock.Boot deploy tool. Signs (or hashes) the UKI + stage2 for an architecture, composes +//! mirror URL lists from repeated `--base-url`, and emits an upload-ready directory plus a +//! merged `user-data.json` carrying `_stage1` (the UKI hop, admitted by stage0) and +//! `_stage2` (the payload hop, admitted by stage1). Uses the shared `metadata` types (so +//! what we emit is exactly what the verifiers accept) and the shared `ed25519-sign` signer. + +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 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.")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Sign/pin the UKI and stage2 for one arch, compose mirror URLs, emit into --out. + Create(CreateArgs), + /// Validate a user-data doc (both _stage1 and _stage2) against the admission rules. + Validate { + /// A user-data.json file, or a directory containing one. + path: PathBuf, + }, + /// Edit an existing deployment's user-data.json: add/remove mirror base URLs. + Modify(ModifyArgs), +} + +#[derive(Args)] +struct CreateArgs { + #[arg(long, value_parser = ["x86_64", "aarch64"])] + arch: String, + /// The UKI (linux.efi) served for the _stage1 hop (admitted by stage0). + #[arg(long)] + uki: PathBuf, + /// The stage2 payload served for the _stage2 hop (admitted by stage1). + #[arg(long)] + stage2: PathBuf, + /// ed25519 PKCS#8 PEM. Given → sign both payloads and pin the pubkey (roll-forward); + /// omitted → pin an exact sha256 of each. + #[arg(long)] + key: Option, + /// Mirror base URL (repeatable). http:// only — stage0 has no TLS; integrity is the pin. + /// URLs are composed as `//`. + #[arg(long = "base-url", required = true)] + base_url: Vec, + /// Inline args for stage2: a JSON array of strings, e.g. '["--flag","v"]'. + #[arg(long)] + args: Option, + /// Serve --args as a signed remote blob (ed25519 mode) instead of inline. + #[arg(long, requires = "key", requires = "args")] + sign_args: bool, + /// Output directory (created if missing). user-data.json is merged across arches. + #[arg(long)] + out: PathBuf, +} + +#[derive(Args)] +struct ModifyArgs { + /// A user-data.json file, or a directory containing one. + path: PathBuf, + #[arg(long = "add-base-url")] + add_base_url: Vec, + #[arg(long = "remove-base-url")] + remove_base_url: Vec, +} + +fn main() -> Result<()> { + match Cli::parse().cmd { + Cmd::Create(a) => create(a), + Cmd::Validate { path } => validate(&path), + Cmd::Modify(a) => modify(a), + } +} + +fn compose_urls(bases: &[String], arch: &str, filename: &str) -> UrlList { + UrlList(bases.iter().map(|b| format!("{b}/{arch}/{filename}")).collect()) +} + +/// Write `src`'s bytes into `/`, then either sign it (→ `.sig` + pinned +/// pubkey) or hash it (→ sha256 pin). Returns the arch entry with its composed URL list. +fn build_entry( + arch_dir: &Path, + arch: &str, + bases: &[String], + filename: &str, + src: &Path, + key_pem: Option<&str>, +) -> Result { + let bytes = fs::read(src).with_context(|| format!("reading {}", src.display()))?; + fs::write(arch_dir.join(filename), &bytes) + .with_context(|| format!("writing {}/{filename}", arch_dir.display()))?; + let url = compose_urls(bases, arch, filename); + let (sha256, ed25519) = match key_pem { + Some(pem) => { + let s = sign_payload(pem, &bytes)?; + fs::write(arch_dir.join(format!("{filename}.sig")), &s.signature)?; + (None, Some(s.pubkey_b64)) + } + None => (Some(sha256_hex(&bytes)), None), + }; + Ok(ArchConfig { url, sha256, ed25519, sig_url: None, args_url: None, args_sig_url: None }) +} + +fn create(a: CreateArgs) -> Result<()> { + 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}"); + } + } + let arch_dir = a.out.join(&a.arch); + fs::create_dir_all(&arch_dir).with_context(|| format!("creating {}", arch_dir.display()))?; + + let key_pem = a + .key + .as_ref() + .map(|p| fs::read_to_string(p).with_context(|| format!("reading key {}", p.display()))) + .transpose()?; + + let uki_entry = build_entry(&arch_dir, &a.arch, &bases, "linux.efi", &a.uki, key_pem.as_deref())?; + let mut stage2_entry = build_entry(&arch_dir, &a.arch, &bases, "stage2", &a.stage2, key_pem.as_deref())?; + + // Args: inline, or signed-and-served remotely. + let mut inline_args: Option> = None; + if let Some(args_json) = &a.args { + let parsed: Vec = + serde_json::from_str(args_json).context("--args must be a JSON array of strings")?; + if a.sign_args { + let blob = serde_json::to_vec(&parsed)?; + fs::write(arch_dir.join("args.json"), &blob)?; + let pem = key_pem.as_deref().expect("clap requires --key with --sign-args"); + let s = sign_payload(pem, &blob)?; + fs::write(arch_dir.join("args.json.sig"), &s.signature)?; + stage2_entry.args_url = Some(compose_urls(&bases, &a.arch, "args.json")); + } else { + inline_args = Some(parsed); + } + } + + // 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)" }; + 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<()> { + 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)?; + 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<()> { + 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(()) +} + +fn validate(path: &Path) -> Result<()> { + let path = doc_path(path); + let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let ud: UserData = serde_json::from_str(&text).context("parsing user-data JSON")?; + let mut errors = Vec::new(); + check_stage(&ud.stage1, Profile::Stage0, "_stage1", &mut errors); + check_stage(&ud.stage2, Profile::Stage1, "_stage2", &mut errors); + if ud.stage1.is_none() && ud.stage2.is_none() { + errors.push("document has neither _stage1 nor _stage2".to_string()); + } + if errors.is_empty() { + println!("{}: valid", path.display()); + Ok(()) + } else { + for e in &errors { + eprintln!(" {e}"); + } + bail!("{} invalid ({} problem(s))", path.display(), errors.len()) + } +} + +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 { + if let Err(m) = e.validate(profile) { + errors.push(format!("{name}.{arch}: {m}")); + } + } + } +} + +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"))?; + 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; + } + 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)?; + } + } + } + } + fs::write(&path, format!("{}\n", serde_json::to_string_pretty(&doc)?))?; + println!("updated {} (+{} / -{} mirror(s))", path.display(), adds.len(), rems.len()); + Ok(()) +} + +/// Rewrite a url field (string or array): drop entries under any removed base, and append +/// `` for each add base (suffix taken from the first entry). Collapses +/// back to a bare string when a single URL remains. +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(), + _ => bail!("url field must be a string or array of strings"), + }; + let suffix = list.first().map(|u| url_path(u).to_string()); + list.retain(|u| !rems.iter().any(|b| u.starts_with(&format!("{b}/")))); + if let Some(suffix) = suffix { + for b in adds { + let cand = format!("{b}{suffix}"); + if !list.contains(&cand) { + list.push(cand); + } + } + } + Ok(if list.len() == 1 { + Value::String(list.remove(0)) + } else { + Value::Array(list.into_iter().map(Value::String).collect()) + }) +} + +/// 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); + match after.find('/') { + Some(i) => &after[i..], + None => "", + } +} + +fn doc_path(p: &Path) -> PathBuf { + if p.is_dir() { + p.join("user-data.json") + } else { + p.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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"]); + 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"); + } + + #[test] + 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"])); + // 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())); + } + + #[test] + fn create_sha256_roundtrips_through_validate() { + let dir = std::env::temp_dir().join(format!("deploy-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let uki = dir.join("uki.bin"); + fs::write(&uki, b"fake uki").unwrap(); + let s2 = dir.join("s2.bin"); + fs::write(&s2, b"fake stage2").unwrap(); + let out = dir.join("out"); + + create(CreateArgs { + arch: "x86_64".into(), + uki, + stage2: s2, + key: None, + base_url: vec!["http://cdn1".into(), "http://cdn2".into()], + args: None, + sign_args: false, + out: out.clone(), + }) + .unwrap(); + + // The emitted doc validates and re-parses into the shared type with both mirrors. + 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 _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/ed25519-sign/Cargo.toml b/crates/ed25519-sign/Cargo.toml new file mode 100644 index 0000000..91102cd --- /dev/null +++ b/crates/ed25519-sign/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ed25519-sign" +version = "0.1.0" +edition = "2021" +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +ed25519-compact = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/ed25519-sign/src/lib.rs b/crates/ed25519-sign/src/lib.rs new file mode 100644 index 0000000..82df574 --- /dev/null +++ b/crates/ed25519-sign/src/lib.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! ed25519 signing + sha256 — the cross-repo **wire contract** shared by `mkuki` and the +//! `deploy` tool. A signature this produces must verify byte-for-byte against stage0's and +//! stage1's admission check (github.com/lockboot/stage0, crates/stage0/src/sig.rs): the +//! signature is a detached 64-byte ed25519 over the raw payload bytes, and the pinned key +//! is the base64 of the 32-byte public key. + +use anyhow::{ensure, Context, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use ed25519_compact::{KeyPair, PublicKey, Seed, Signature}; +use sha2::{Digest, Sha256}; + +/// Verify a detached ed25519 `signature` over `message` against the base64 `pubkey_b64` +/// (32-byte public key). The verify counterpart of [`sign_payload`] — the on-instance +/// admission check (stage1) and any deploy-side self-check use this. +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") +} + +pub fn sha256_hex(data: &[u8]) -> String { + let digest = Sha256::digest(data); + let mut s = String::with_capacity(digest.len() * 2); + for b in digest { + s.push_str(&format!("{b:02x}")); + } + s +} + +pub struct Signed { + /// 64-byte detached ed25519 signature over the payload. + pub signature: Vec, + /// base64 of the 32-byte public key — paste into an `ed25519` metadata field. + pub pubkey_b64: String, +} + +/// Sign `payload` with the PKCS#8 (PEM) ed25519 private key at `pem`. +pub fn sign_payload(pem: &str, payload: &[u8]) -> Result { + let seed = seed_from_pkcs8_pem(pem)?; + let kp = KeyPair::from_seed(Seed::new(seed)); + let signature = kp.sk.sign(payload, None); + Ok(Signed { + signature: signature.to_vec(), + pubkey_b64: STANDARD.encode(*kp.pk), + }) +} + +/// Extract the 32-byte Ed25519 seed from a PKCS#8 PEM private key (RFC 8410). openssl +/// `genpkey -algorithm ed25519` emits the 48-byte DER `... 04 22 04 20 <32-byte seed>`, +/// so we locate the inner OCTET STRING. +fn seed_from_pkcs8_pem(pem: &str) -> Result<[u8; 32]> { + let b64: String = pem + .lines() + .filter(|l| !l.starts_with("-----")) + .collect::>() + .concat(); + let der = STANDARD + .decode(b64.trim()) + .context("private key PEM body is not valid base64")?; + + let marker = [0x04u8, 0x22, 0x04, 0x20]; + if let Some(pos) = der.windows(4).position(|w| w == marker) { + let start = pos + 4; + ensure!(start + 32 <= der.len(), "truncated ed25519 private key"); + let mut seed = [0u8; 32]; + seed.copy_from_slice(&der[start..start + 32]); + return Ok(seed); + } + anyhow::bail!("not a PKCS#8 Ed25519 private key (expected 04 22 04 20 marker)") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a valid PKCS#8 (RFC 8410) ed25519 PEM from a raw seed — the exact DER + /// `302e020100300506032b657004220420 ` that `openssl genpkey` emits. + fn pem_from_seed(seed: &[u8; 32]) -> String { + let mut der: Vec = vec![ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, + 0x04, 0x20, + ]; + der.extend_from_slice(seed); + let b64 = STANDARD.encode(&der); + format!("-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----\n") + } + + #[test] + fn sign_then_verify_roundtrip() { + let msg = b"payload bytes"; + let s = sign_payload(&pem_from_seed(&[7u8; 32]), msg).unwrap(); + // sign and verify are the two halves of the same wire contract, proven here together. + assert!(verify(&s.pubkey_b64, msg, &s.signature).is_ok()); + assert!(verify(&s.pubkey_b64, b"tampered payload!!!!", &s.signature).is_err()); + let mut bad = s.signature.clone(); + bad[0] ^= 1; + assert!(verify(&s.pubkey_b64, msg, &bad).is_err()); + assert!(verify("not-base64!!", msg, &s.signature).is_err()); + } + + #[test] + fn sha256_hex_is_lowercase_64() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } +} diff --git a/crates/metadata/Cargo.toml b/crates/metadata/Cargo.toml new file mode 100644 index 0000000..b30fc59 --- /dev/null +++ b/crates/metadata/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "metadata" +version = "0.1.0" +edition = "2021" +license.workspace = true +description = "Lock.Boot _stage1/_stage2 admission metadata: the wire types + validation, shared by the stage1 verifier and the deploy tool." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } + +[dev-dependencies] +ed25519-compact = { workspace = true } diff --git a/schema/stage2.schema.json b/crates/metadata/schema/stage2.schema.json similarity index 100% rename from schema/stage2.schema.json rename to crates/metadata/schema/stage2.schema.json diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs new file mode 100644 index 0000000..a022c42 --- /dev/null +++ b/crates/metadata/src/lib.rs @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! 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. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A full user-data document. stage1 reads `_stage2`; a deployment carries both. +#[derive(Debug, Serialize, Deserialize)] +pub struct UserData { + #[serde(rename = "_stage1", default, skip_serializing_if = "Option::is_none")] + pub stage1: Option, + #[serde(rename = "_stage2", default, skip_serializing_if = "Option::is_none")] + pub stage2: Option, +} + +/// One stage's config: shared inline `args` plus a per-architecture admission entry. +#[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")] + pub x86_64: Option, +} + +impl StageConfig { + /// The entry for the architecture this crate was compiled for (used at runtime). + #[must_use] + pub fn for_this_arch(&self) -> Option<&ArchConfig> { + #[cfg(target_arch = "aarch64")] + { + self.aarch64.as_ref() + } + #[cfg(target_arch = "x86_64")] + { + self.x86_64.as_ref() + } + #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] + { + None + } + } +} + +/// One URL or a fallback list, tried in order. Deserializes from a string or an array, +/// and serializes back to a bare string when singular. Trying mirrors is safe: the +/// payload is cryptographically pinned, so bytes from any URL must still verify. +#[derive(Debug, Clone)] +pub struct UrlList(pub Vec); + +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), + } + } +} + +/// 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. +#[derive(Debug, Serialize, Deserialize)] +pub struct ArchConfig { + pub url: UrlList, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ed25519: Option, + /// 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`. + #[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, +} + +/// 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)] +pub enum Profile { + /// `_stage1` — http:// only. + Stage0, + /// `_stage2` — http:// or https://. + Stage1, +} + +/// Resolved admission mode. `*_url` templates still carry a raw `{sha256}`; the consumer +/// substitutes it once the payload digest is known. +pub 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. `profile` chooses the transport policy (see [`Profile`]). + pub fn validate(&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) { + 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"), + }, + } + } +} + +#[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(Profile::Stage1), Ok(Verify::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()); + } + + #[test] + fn ed25519_mode_ok() { + let pk = pubkey_b64(); + assert!(matches!(ac("http://h/p", None, Some(&pk)).validate(Profile::Stage1), Ok(Verify::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()); + } + + #[test] + fn neither_mode_is_error() { + assert!(ac("http://h/p", None, None).validate(Profile::Stage1).is_err()); + } + + #[test] + fn bad_hex_is_error() { + assert!(ac("http://h/p", Some("zz"), None).validate(Profile::Stage1).is_err()); + let sixtyfour_nonhex = "z".repeat(64); + assert!(ac("http://h/p", Some(&sixtyfour_nonhex), None).validate(Profile::Stage1).is_err()); + } + + #[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()); + } + + #[test] + fn non_http_url_is_error() { + assert!(ac("ftp://h/p", Some(HASH64), None).validate(Profile::Stage1).is_err()); + } + + #[test] + fn args_url_requires_ed25519() { + let mut c = ac("http://h/p", Some(HASH64), None); + c.args_url = Some(UrlList(vec!["http://h/args".into()])); + assert!(c.validate(Profile::Stage1).is_err()); + } + + #[test] + fn args_sig_url_requires_args_url() { + let pk = pubkey_b64(); + let mut c = ac("http://h/p", None, Some(&pk)); + c.args_sig_url = Some(UrlList(vec!["http://h/args.sig".into()])); + assert!(c.validate(Profile::Stage1).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()]); + 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(Profile::Stage1).is_ok()); + c.url = UrlList(vec![]); + assert!(c.validate(Profile::Stage1).is_err()); + } +} diff --git a/crates/mkuki/Cargo.toml b/crates/mkuki/Cargo.toml index b1132b0..f4afa23 100644 --- a/crates/mkuki/Cargo.toml +++ b/crates/mkuki/Cargo.toml @@ -23,10 +23,8 @@ tar = "0.4" walkdir = "2" sha2 = "0.10" base64 = "0.22" -# Same ed25519 implementation stage0 verifies with (github.com/lockboot/stage0, -# crates/stage0/src/sig.rs), so a signature this tool emits is exactly what -# stage0's admission check expects. -ed25519-compact = "2.3" +# The ed25519 signer (wire-compatible with stage0/stage1), re-exported as `mkuki::sign`. +ed25519-sign = { path = "../ed25519-sign" } # Structured, multi-level logging. The library only *emits* events; the binary # installs the subscriber (see main.rs) so importers control their own logging. tracing = "0.1" diff --git a/crates/mkuki/src/lib.rs b/crates/mkuki/src/lib.rs index 9c6e01d..8e36c44 100644 --- a/crates/mkuki/src/lib.rs +++ b/crates/mkuki/src/lib.rs @@ -18,9 +18,12 @@ //! one in `main.rs`. pub mod cpio; -pub mod sign; pub mod uki; +// The ed25519 signer lives in a shared crate (used by the deploy tool too); re-export +// it as `sign` so existing `mkuki::sign::…` / `crate::sign::…` paths keep working. +pub use ed25519_sign as sign; + use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; diff --git a/crates/mkuki/src/sign.rs b/crates/mkuki/src/sign.rs deleted file mode 100644 index 5108fdd..0000000 --- a/crates/mkuki/src/sign.rs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! ed25519 signing + sha256, matching stage0's admission check -//! (github.com/lockboot/stage0, crates/stage0/src/sig.rs): the signature is a -//! detached 64-byte ed25519 over the raw payload bytes, the pinned key is the -//! base64 of the 32-byte public key. This MUST stay byte-compatible with what -//! stage0 verifies — the two are a cross-repo wire contract, not a shared crate. - -use anyhow::{ensure, Context, Result}; -use base64::engine::general_purpose::STANDARD; -use base64::Engine as _; -use ed25519_compact::{KeyPair, Seed}; -use sha2::{Digest, Sha256}; - -pub fn sha256_hex(data: &[u8]) -> String { - let digest = Sha256::digest(data); - hex(&digest) -} - -fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -} - -pub struct Signed { - /// 64-byte detached ed25519 signature over the payload. - pub signature: Vec, - /// base64 of the 32-byte public key — paste into `_stage1`'s `ed25519`. - pub pubkey_b64: String, -} - -/// Sign `payload` with the PKCS#8 (PEM) ed25519 private key at `pem`. -pub fn sign_payload(pem: &str, payload: &[u8]) -> Result { - let seed = seed_from_pkcs8_pem(pem)?; - let kp = KeyPair::from_seed(Seed::new(seed)); - let signature = kp.sk.sign(payload, None); - Ok(Signed { - signature: signature.to_vec(), - pubkey_b64: STANDARD.encode(*kp.pk), - }) -} - -/// Extract the 32-byte Ed25519 seed from a PKCS#8 PEM private key -/// (RFC 8410). openssl `genpkey -algorithm ed25519` emits the 48-byte DER: -/// `... 04 22 04 20 <32-byte seed>`, so we locate the inner OCTET STRING. -fn seed_from_pkcs8_pem(pem: &str) -> Result<[u8; 32]> { - let b64: String = pem - .lines() - .filter(|l| !l.starts_with("-----")) - .collect::>() - .concat(); - let der = STANDARD - .decode(b64.trim()) - .context("private key PEM body is not valid base64")?; - - // Find the `04 22 04 20` wrapper (CurvePrivateKey OCTET STRING containing a - // 32-byte OCTET STRING) and take the 32 bytes that follow it. - let marker = [0x04u8, 0x22, 0x04, 0x20]; - if let Some(pos) = der.windows(4).position(|w| w == marker) { - let start = pos + 4; - ensure!(start + 32 <= der.len(), "truncated ed25519 private key"); - let mut seed = [0u8; 32]; - seed.copy_from_slice(&der[start..start + 32]); - return Ok(seed); - } - anyhow::bail!("not a PKCS#8 Ed25519 private key (expected 04 22 04 20 marker)") -} diff --git a/crates/stage1/Cargo.toml b/crates/stage1/Cargo.toml index 33b88e6..d4d7efd 100644 --- a/crates/stage1/Cargo.toml +++ b/crates/stage1/Cargo.toml @@ -5,15 +5,15 @@ edition = "2021" license.workspace = true [dependencies] +metadata = { path = "../metadata" } +ed25519-sign = { path = "../ed25519-sign" } reqwest = { workspace = true } rustls = { workspace = true } rustls-rustcrypto = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } 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 120c26d..0fb5840 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -2,11 +2,11 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use vaportpm_attest::{Tpm, PcrOps}; +use metadata::{Profile, UrlList, UserData, Verify}; use reqwest::blocking::Client; use rustls::crypto::CryptoProvider; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; +use vaportpm_attest::{PcrOps, Tpm}; use vaportpm_attest as tpm; use std::fs; use std::io::{self, Write}; @@ -16,8 +16,6 @@ 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"; @@ -28,137 +26,6 @@ const TMP_DIR: &str = "/tmp"; // 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 { - _stage2: Stage2Config, -} - -#[derive(Debug, Serialize, Deserialize)] -struct Stage2Config { - #[serde(default, skip_serializing_if = "Option::is_none")] - args: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - aarch64: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - 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 { - 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() { let result = main_inner(); @@ -194,46 +61,20 @@ fn main_inner() -> Result<()> { }; return Ok(println!("{}", tpm::attest(&nonce)?)); } - // 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]")); - } - let arch = &args[2]; - if arch != "aarch64" && arch != "x86_64" { - return Err(anyhow!("Architecture must be either 'aarch64' or 'x86_64'")); - } - 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) + // Dev paths: fetch a user-data doc from a URL or file, then run stage2. if args.len() == 3 { - return stage2( - parse_json_to_config( - match args[1].as_str() { - "--url" => fetch_from_url(&args[2])?, - "--file" => read_from_file(&args[2])?, - _ => return Err(anyhow!("Invalid argument. Use --url or --file ")) + return stage2(parse_json_to_config(match args[1].as_str() { + "--url" => fetch_from_url(&args[2])?, + "--file" => read_from_file(&args[2])?, + _ => return Err(anyhow!("Invalid argument. Use --url or --file ")), })?); } Err(anyhow!( - "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. (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" + "Usage: stage1 [--url | --file | --attest [nonce]]\n\ + With no arguments (or as PID 1): fetch the user-data doc from the cloud metadata\n\ + service and run stage2. --url/--file fetch the doc from a URL or file instead.\n\ + --attest: print a TPM attestation (EK certs, PCRs, certified signing key).\n\ + (Deployment config is generated by the separate `deploy` tool, not this binary.)" )) } @@ -281,29 +122,6 @@ fn is_pid1() -> bool { std::process::id() == 1 } -/// Get the appropriate architecture config based on the target architecture -fn get_arch_config(stage2: &Stage2Config) -> Result<&ArchConfig> { - #[cfg(target_arch = "aarch64")] - let arch_config = stage2.aarch64.as_ref(); - - #[cfg(target_arch = "x86_64")] - let arch_config = stage2.x86_64.as_ref(); - - #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] - let arch_config: Option<&ArchConfig> = None; - - arch_config.ok_or_else(|| { - #[cfg(target_arch = "aarch64")] - return anyhow!("No aarch64 configuration found in _stage2"); - - #[cfg(target_arch = "x86_64")] - return anyhow!("No x86_64 configuration found in _stage2"); - - #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] - return anyhow!("Unsupported architecture"); - }) -} - fn poweroff() { if is_pid1() { unsafe { @@ -328,75 +146,6 @@ fn parse_json_to_config(data: Vec) -> Result { }) } -/// 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) - .context(format!("Failed to read config file: {}", path))?; - serde_json::from_str(&contents) - .context("Failed to parse config JSON")? - } else { - serde_json::json!({}) - }; - - if !config.is_object() { - return Err(anyhow!("Config file must contain a JSON object")); - } - - let config_obj = config.as_object_mut().unwrap(); - - // Get or create _stage2 object - let stage2_value = config_obj - .entry("_stage2") - .or_insert_with(|| serde_json::json!({})); - - if !stage2_value.is_object() { - return Err(anyhow!("_stage2 must be a JSON object")); - } - - // Add the architecture-specific config - let stage2_obj = stage2_value.as_object_mut().unwrap(); - stage2_obj.insert(arch.to_string(), serde_json::to_value(arch_config)?); - - println!("{}", serde_json::to_string_pretty(&config)?); - Ok(()) -} - -/// 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<()> { @@ -448,7 +197,7 @@ fn fetch_signed_args( }; let args_bytes = download_first(&args_urls)?; let signature = download_first(&args_sig_urls)?; - sig::verify(pubkey, &args_bytes, &signature) + ed25519_sign::verify(pubkey, &args_bytes, &signature) .map_err(|m| anyhow!("signed args verification failed: {m}"))?; let args: Vec = serde_json::from_slice(&args_bytes) .context("signed args must be a JSON array of strings")?; @@ -488,7 +237,7 @@ fn admit_from(url: &str, mode: &Verify) -> Result<(Vec, Option>) None => vec![format!("{url}.sig")], }; let signature = download_first(&sig_urls)?; - sig::verify(pubkey, &binary, &signature) + ed25519_sign::verify(pubkey, &binary, &signature) .map_err(|m| anyhow!("ed25519 verification failed: {m}"))?; ktseprintln!("verified: sha256:{hash} (ed25519 key:{pubkey})"); if let Some(au) = args_url { @@ -500,9 +249,16 @@ fn admit_from(url: &str, mode: &Verify) -> Result<(Vec, Option>) } fn stage2(parsed: ParsedData) -> Result<()> { - let arch_config = get_arch_config(&parsed.config._stage2)?; + 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() + .validate(Profile::Stage1) .map_err(|m| anyhow!("invalid _stage2 config: {m}"))?; let (binary_data, signed_args) = admit_payload(&arch_config.url.0, &mode)?; @@ -513,7 +269,7 @@ fn stage2(parsed: ParsedData) -> Result<()> { } // Signed remote args, when present, override inline args. - let inline_args = parsed.config._stage2.args.as_deref().unwrap_or(&[]); + 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)?; Ok(()) @@ -612,8 +368,7 @@ fn fetch_from_url(url: &str) -> Result> { } fn read_from_file(path: &str) -> Result> { - let data = fs::read(path) - .context(format!("Failed to read file: {}", path))?; + let data = fs::read(path).context(format!("Failed to read file: {}", path))?; log_hash(path, data.as_slice()); Ok(data) } @@ -638,157 +393,28 @@ 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(()) } fn execute_binary(data: &[u8], args: &[String], json_config: &[u8]) -> Result<()> { let tmp_path = format!("{}/stage2.exe", TMP_DIR); - fs::write(&tmp_path, data) - .context(format!("Failed to write binary to {}", tmp_path))?; + fs::write(&tmp_path, data).context(format!("Failed to write binary to {}", tmp_path))?; // Make the binary executable let mut perms = fs::metadata(&tmp_path) .context("Failed to get file metadata")? .permissions(); perms.set_mode(0o755); - fs::set_permissions(&tmp_path, perms) - .context("Failed to set executable permissions")?; + fs::set_permissions(&tmp_path, perms).context("Failed to set executable permissions")?; let json_path = format!("{}/stage2-config.json", TMP_DIR); - fs::write(&json_path, json_config) - .context(format!("Failed to write config to {}", json_path))?; + fs::write(&json_path, json_config).context(format!("Failed to write config to {}", json_path))?; ktseprintln!("{}: {:?}\n", tmp_path, args); - let err = Command::new(&tmp_path) - .args(args) - .exec(); + let err = Command::new(&tmp_path).args(args).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 deleted file mode 100644 index 2c84be7..0000000 --- a/crates/stage1/src/sig.rs +++ /dev/null @@ -1,54 +0,0 @@ -// 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()); - } -} From a83db8c8eb6f005f1851176453446fc11205c94b Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:09:54 +0000 Subject: [PATCH 2/2] stage1: one fail-closed path on panic; stop copying the payload Two hardening fixes from the boot-path security review: - Install a panic hook that routes any panic through the same poweroff() as a returned error. As PID 1 an unhandled panic would abort into a kernel panic and skip the log-drain wait; now every failure (error or panic) converges on one shutdown path so its logs reach the serial console before power-off. - download_binary returns reqwest's owned Bytes instead of .to_vec(), and download_first/admit_from/admit_payload thread Bytes through. This drops a full-length copy of the stage2 payload (and the signature / signed-args blobs). Bytes derefs to [u8], so admission and PCR 14 measurement see identical bytes and are unchanged. Verified: full chain boots and powers off cleanly in both sha256 and ed25519 modes. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/stage1/Cargo.toml | 1 + crates/stage1/src/main.rs | 24 +++++++++++++++++------- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abb9658..aeb4270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1722,6 +1722,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", + "bytes", "ed25519-sign", "hex", "libc", diff --git a/Cargo.toml b/Cargo.toml index f168a89..af2b919 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" +bytes = "1" sha2 = "0.10" anyhow = "1.0" hex = "0.4" diff --git a/crates/stage1/Cargo.toml b/crates/stage1/Cargo.toml index d4d7efd..3fedb99 100644 --- a/crates/stage1/Cargo.toml +++ b/crates/stage1/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true metadata = { path = "../metadata" } ed25519-sign = { path = "../ed25519-sign" } reqwest = { workspace = true } +bytes = { workspace = true } rustls = { workspace = true } rustls-rustcrypto = { workspace = true } serde_json = { workspace = true } diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index 0fb5840..e12f395 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; +use bytes::Bytes; use metadata::{Profile, UrlList, UserData, Verify}; use reqwest::blocking::Client; use rustls::crypto::CryptoProvider; @@ -27,6 +28,15 @@ const TMP_DIR: &str = "/tmp"; const PCR_BINARY: u8 = 14; fn main() { + // Single failure path: any error OR panic converges on `poweroff()`. As PID 1 an + // unhandled panic aborts into a kernel panic and skips the log-drain wait; route it + // through the same shutdown so it fails closed and its logs reach the serial console. + std::panic::set_hook(Box::new(|info| { + eprintln!("stage1: PANIC: {info}"); + let _ = io::stderr().flush(); + poweroff(); + })); + let result = main_inner(); // Flush output before exiting (especially important when running as PID 1) @@ -168,7 +178,7 @@ fn substitute(urls: &[String], hash: &str) -> Vec { } /// Download the first URL that responds (fallback across mirrors for resiliency). -fn download_first(urls: &[String]) -> Result> { +fn download_first(urls: &[String]) -> Result { let mut last: Option = None; for url in urls { match download_binary(url) { @@ -207,7 +217,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<(Vec, Option>)> { +fn admit_payload(urls: &[String], mode: &Verify) -> Result<(Bytes, Option>)> { let mut last: Option = None; for url in urls { match admit_from(url, mode) { @@ -222,7 +232,7 @@ fn admit_payload(urls: &[String], mode: &Verify) -> Result<(Vec, Option Result<(Vec, Option>)> { +fn admit_from(url: &str, mode: &Verify) -> Result<(Bytes, Option>)> { let binary = download_binary(url)?; let hash = hex::encode(sha256!(&binary)); let mut signed_args = None; @@ -373,8 +383,9 @@ fn read_from_file(path: &str) -> Result> { Ok(data) } -fn download_binary(url: &str) -> Result> { +fn download_binary(url: &str) -> Result { let client = http_client()?; + // reqwest already owns the body as `Bytes`; hand it back as-is (no extra copy). let binary_data = client .get(url) .send() @@ -382,9 +393,8 @@ fn download_binary(url: &str) -> Result> { .error_for_status() .context("Server returned an error status")? .bytes() - .context("Failed to read binary data")? - .to_vec(); - log_hash(url, binary_data.as_slice()); + .context("Failed to read binary data")?; + log_hash(url, &binary_data); Ok(binary_data) }