diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..671ea88 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,31 @@ +# Per-target rustc flags for stage0. Self-contained: CI checks this repo out ALONE (no workspace +# parent, CARGO_HOME redirected to an empty dir), so the flags a build needs must live here, not in +# the workspace-level config. Per target (not one flag set for all) because +crt-static must reach +# the musl/uefi targets but not the host, where it makes proc-macro dylibs impossible to build. +# +# The UEFI flags reproduce the measured binaries byte-for-byte (stage0.efi, ena.efi, test-payload); +# do not change them without re-pinning PCR4/PCR14 expectations. No `[build] target`: every stage0 +# build passes an explicit `--target`. + +[target.x86_64-unknown-linux-musl] +# Fully static musl for the host-side signer (stage0-sign); rust-lld, no system cc. +rustflags = ["-C", "linker=rust-lld", "-C", "target-feature=+crt-static", "-C", "link-arg=-static"] + +[target.aarch64-unknown-linux-musl] +rustflags = ["-C", "linker=rust-lld", "-C", "target-feature=+crt-static", "-C", "link-arg=-static"] + +[target.x86_64-unknown-uefi] +# The measured bootloader + payloads. Static, rust-lld. +rustflags = ["-C", "linker=rust-lld", "-C", "target-feature=+crt-static", "-C", "link-arg=-static"] + +[target.aarch64-unknown-uefi] +rustflags = ["-C", "linker=rust-lld", "-C", "target-feature=+crt-static", "-C", "link-arg=-static"] + +# Host target for proc-macros and build scripts: rust-lld only (no crt-static), so they link with no +# system cc. Kept out of the shared workspace config because the gnu-host repos link C and need cc. +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "linker=rust-lld"] + +[env] +# Reproducible builds. +SOURCE_DATE_EPOCH = "0" diff --git a/Dockerfile.build b/Dockerfile.build index c8a2fb4..a9b6962 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -34,7 +34,8 @@ RUN apt-get -qq update && \ # Reproducible builds environment ENV SOURCE_DATE_EPOCH=0 ENV CARGO_INCREMENTAL=0 -ENV RUSTFLAGS="-C linker=rust-lld -C target-feature=+crt-static -C link-arg=-static" +# rustflags are per-target in .cargo/config.toml; a global RUSTFLAGS would force +crt-static onto +# the host and break proc-macros. # User environment (project-specific cargo/rustup data) ENV HOME=/src diff --git a/Makefile b/Makefile index a4d8481..a38242d 100644 --- a/Makefile +++ b/Makefile @@ -88,14 +88,26 @@ DOCKER_RUN = docker run --rm \ build/keys/%: docker-build-base $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) ./tools/gen-keys.sh build/keys +# ---- stage0-sign: this repo's standalone host-side signer (ed25519 keygen + domain-separated +# signatures), so stage0 builds + tests without stage1's deploy tool. Built + run in the container. +# Built for the musl target (like stage1's deploy) so the signer is a fully static host binary. +# NB: named STAGE0_SIGN, not SIGN -- SIGN is the boot-test matrix's signed-mode flag (SIGN=1), +# and a command-line `SIGN=1` from the recursive $(MAKE) boot-% would otherwise clobber this path. +STAGE0_SIGN := crates/stage0-sign/target/x86_64-unknown-linux-musl/debug/stage0-sign +.PHONY: sign-bin sign-test +sign-bin: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo build --manifest-path crates/stage0-sign/Cargo.toml --target x86_64-unknown-linux-musl +# Run stage0-sign's tests (incl. the golden known-answer vector shared with stage1's signer). +sign-test: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo test --manifest-path crates/stage0-sign/Cargo.toml --target x86_64-unknown-linux-musl + # ---- ed25519 release key for "signed mode" payload admission ---- -# Vendor key; stage0 only ever sees the public half, pinned in the metadata doc. -build/keys/release.pem: docker-build-base +# Vendor key; stage0 only ever sees the public half, pinned in the metadata doc. Generated by +# stage0-sign keygen (the domain-separated signer the verifier admits against). +build/keys/release.pem: docker-build-base | sign-bin $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ mkdir -p build/keys && \ - openssl genpkey -algorithm ed25519 -out build/keys/release.pem && \ - openssl pkey -in build/keys/release.pem -pubout -outform DER \ - | tail -c 32 | base64 -w0 > build/keys/release.pub.b64" + $(STAGE0_SIGN) keygen --out build/keys/release.pem --pub build/keys/release.pub.b64" # ---- Build the ena.efi UEFI driver (embedded into stage0 for EC2/ENA netboot) ---- # A resident SimpleNetworkProtocol driver for the AWS ENA NIC, which Nitro's UEFI @@ -128,21 +140,21 @@ build-arm64 build-aarch64: build/aarch64/boot.disk # maps payload.lockboot.test -> 10.0.2.1. Override SERVE_HOST=10.0.2.1:8000 to skip DNS. SERVE_HOST ?= payload.lockboot.test:8000 PAYLOAD_URL ?= http://$(SERVE_HOST)/payload.efi -build/%/payload.efi: docker-build-base build/keys/release.pem +build/%/payload.efi: docker-build-base build/keys/release.pem | sign-bin $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ bash -c "mkdir -p build/$* && rustup target add $*-unknown-uefi && cargo build --release --manifest-path crates/stage0-test-payload/Cargo.toml --target $*-unknown-uefi && \ cp crates/stage0-test-payload/target/$*-unknown-uefi/release/stage0-test-payload.efi $@ && \ - openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $@ -out $@.sig" + $(STAGE0_SIGN) sign --domain stage1.uki --key build/keys/release.pem --in $@ --out $@.sig" # ---- Signed remote LoadOptions for SIGN_ARGS=1: a JSON array of strings, ed25519-signed like # the payload. stage0 fetches args.json + args.json.sig, verifies against the pinned key, and # uses them as the child's UEFI LoadOptions (overriding inline args). ---- -build/%/args.json.sig: build/keys/release.pem +build/%/args.json.sig: build/keys/release.pem | sign-bin $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ mkdir -p build/$* && \ printf '%s' '[\"--from\",\"signed-args\",\"--nosleep\"]' > build/$*/args.json && \ - openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \ - -in build/$*/args.json -out build/$*/args.json.sig" + $(STAGE0_SIGN) sign --domain stage1.args --key build/keys/release.pem \ + --in build/$*/args.json --out build/$*/args.json.sig" # ---- QEMU harness: the lean harness image bakes qemu-test.sh as its entrypoint # (and the EC2_MOCK_CACHE + iptables-ack env), so we just append CLI args. ---- @@ -186,7 +198,7 @@ boot-%: build/%/boot.disk build/%/payload.efi docker-build-harness \ SHA=$$(sha256sum "$$D/payload.efi" | cut -d' ' -f1); \ printf '{ "_stage1": { "%s": { "payload": { "url": %s, "sha256": "%s"%s } } } }\n' "$*" "$$URLVAL" "$$SHA" "$$INLINE_ARGS" > "$$D/stage1.manifest.json"; \ $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c \ - "openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $$D/stage1.manifest.json -out $$D/stage1.manifest.json.sig"; \ + "$(STAGE0_SIGN) sign --domain stage1.manifest --key build/keys/release.pem --in $$D/stage1.manifest.json --out $$D/stage1.manifest.json.sig"; \ printf '{\n "_stage1": { "%s": { "manifest": { "url": "%s/stage1.manifest.json", "ed25519": "%s" } } }\n}\n' "$*" "$$H" "$$PUB" > user-data.stage0.json; \ echo "user-data: _stage1 via signed manifest (pubkey $$PUB)"; \ else \ diff --git a/README.md b/README.md index c500640..5f0c0fe 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,14 @@ binary now) or a `manifest` (resolve a signed manifest first): { "_stage1": { "x86_64": { "manifest": { "url": "http://cdn.example.com/app.manifest.json", "ed25519": "" } } } } ``` +Signatures are **domain-separated**: every ed25519 signature is over a fixed 64-byte preimage +`sha256(domain_tag) || sha256(message)`, where the tag names the exact role — `lockboot.v1.stage1.uki` +for the payload, `lockboot.v1.stage1.args` for signed LoadOptions, `lockboot.v1.stage1.manifest` for a +manifest. A signature minted for one role is structurally invalid in another, so a signed-args blob can +never be replayed as a payload signature, nor a `_stage1` manifest as anything else. stage0 admits only +these three `stage1.*` roles; the signer is `crates/stage0-sign` in this repo, whose framing is pinned +byte-for-byte to stage1's `deploy` by a shared golden known-answer test. + The payload must be a UEFI PE. However the firmware `db` feels about it, stage0 admits it by your pin/signature and measures it into **PCR 14** (= its SHA-256). @@ -65,7 +73,9 @@ fallback), or `ARGS='[…]'` (inline LoadOptions). `stage0 → test-payload` and verifies the payload actually chain-loaded (proving fetch → admit → PCR-measure → chain-load), printing a per-mode PASS/FAIL summary. It needs nested KVM (local only) and takes ~2 minutes. Everything is regenerated and signed reproducibly from the Makefile — no -manual steps. +manual steps. The release key and every detached signature are produced by `stage0-sign` (this +repo's host-side signer: `make sign-bin`, or `make sign-test` for its golden vector), so the test +path dogfoods the same domain-separated signing a deployer would use. Under the test harness the payload runs with `--nosleep` in its LoadOptions (skipping its EC2-only ~60s serial-flush hold, which QEMU doesn't need) and powers the machine off at the end diff --git a/crates/stage0-sign/Cargo.lock b/crates/stage0-sign/Cargo.lock new file mode 100644 index 0000000..dcfdaba --- /dev/null +++ b/crates/stage0-sign/Cargo.lock @@ -0,0 +1,291 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519-compact" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "stage0-sign" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "ed25519-compact", + "sha2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/crates/stage0-sign/Cargo.toml b/crates/stage0-sign/Cargo.toml new file mode 100644 index 0000000..b85b966 --- /dev/null +++ b/crates/stage0-sign/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 +[package] +name = "stage0-sign" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Host-side signer for stage0 artifacts: ed25519 keygen + domain-separated signatures." + +# Standalone workspace: a host-side (std) CLI, kept out of the UEFI crates so stage0 stays +# standalone. The domain framing is duplicated from crates/stage0/src/sig.rs and pinned to stage1's +# signer byte-for-byte by the golden known-answer test in src/main.rs. +[workspace] + +[[bin]] +name = "stage0-sign" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +base64 = "0.22" +clap = { version = "4", features = ["derive"] } +# default-features = false drops the `random` feature (getrandom -> libc -> C toolchain); we supply +# the seed ourselves, and randomness for keygen comes from /dev/urandom via std. +ed25519-compact = { version = "2", default-features = false } +sha2 = "0.10" diff --git a/crates/stage0-sign/src/main.rs b/crates/stage0-sign/src/main.rs new file mode 100644 index 0000000..a5e32aa --- /dev/null +++ b/crates/stage0-sign/src/main.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `stage0-sign` — a small standalone host-side signer for stage0's release / test artifacts. +//! +//! Generates ed25519 keys and produces **domain-separated** detached signatures that stage0's +//! `sig.rs` admission check accepts: the signature is over `sha256(domain_tag) || sha256(message)`. +//! It lives in this repo (rather than reusing stage1's `deploy`) so stage0 builds and tests +//! standalone; the framing is duplicated from `crates/stage0/src/sig.rs` and pinned to stage1's +//! signer byte-for-byte by the golden known-answer test below. No `getrandom`/`libc`/`rand` — +//! randomness is `/dev/urandom` via `std`, so it builds with no C toolchain on the host. + +use anyhow::{anyhow, bail, Context, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use clap::{Args, Parser, Subcommand}; +use ed25519_compact::{KeyPair, Seed}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +/// A signing context — the `stage1.*` roles stage0 admits. `tag()` must match stage1's namespace. +#[derive(Clone, Copy)] +enum Domain { + Stage1Uki, + Stage1Args, + Stage1Manifest, +} + +impl Domain { + fn tag(self) -> &'static str { + match self { + Domain::Stage1Uki => "lockboot.v1.stage1.uki", + Domain::Stage1Args => "lockboot.v1.stage1.args", + Domain::Stage1Manifest => "lockboot.v1.stage1.manifest", + } + } +} + +impl std::str::FromStr for Domain { + type Err = &'static str; + fn from_str(s: &str) -> std::result::Result { + Ok(match s { + "stage1.uki" => Domain::Stage1Uki, + "stage1.args" => Domain::Stage1Args, + "stage1.manifest" => Domain::Stage1Manifest, + _ => return Err("unknown signing domain (want stage1.uki / stage1.args / stage1.manifest)"), + }) + } +} + +#[derive(Parser)] +#[command(name = "stage0-sign", version, about = "ed25519 keygen + domain-separated signatures for stage0 artifacts.")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Generate an ed25519 key (PKCS#8 PEM) and print its base64 public key. + Keygen(KeygenArgs), + /// Sign one file for a signing domain. + Sign(SignArgs), +} + +#[derive(Args)] +struct KeygenArgs { + /// Write the ed25519 PKCS#8 PEM private key here (created mode 0600). + #[arg(long)] + out: PathBuf, + /// Also write the base64 public key here (the value pinned in `ed25519` metadata fields). + #[arg(long = "pub")] + pub_out: Option, +} + +#[derive(Args)] +struct SignArgs { + /// Signing domain: stage1.uki / stage1.args / stage1.manifest. + #[arg(long)] + domain: String, + /// ed25519 PKCS#8 PEM private key. + #[arg(long)] + key: PathBuf, + /// File to sign. + #[arg(long = "in")] + input: PathBuf, + /// Write the detached signature here. + #[arg(long)] + out: PathBuf, +} + +fn main() -> Result<()> { + match Cli::parse().cmd { + Cmd::Keygen(a) => keygen(a), + Cmd::Sign(a) => sign_cmd(a), + } +} + +fn keygen(a: KeygenArgs) -> Result<()> { + let seed = random_seed()?; + let pubkey = STANDARD.encode(*KeyPair::from_seed(Seed::new(seed)).pk); + write_private(&a.out, pem_from_seed(&seed).as_bytes())?; + if let Some(p) = &a.pub_out { + std::fs::write(p, &pubkey).with_context(|| format!("writing {}", p.display()))?; + } + println!("wrote {} (ed25519 private key, mode 0600)", a.out.display()); + println!("pubkey: {pubkey}"); + Ok(()) +} + +fn sign_cmd(a: SignArgs) -> Result<()> { + let domain: Domain = a.domain.parse().map_err(|e| anyhow!("--domain {}: {e}", a.domain))?; + let pem = std::fs::read_to_string(&a.key).with_context(|| format!("reading key {}", a.key.display()))?; + let bytes = std::fs::read(&a.input).with_context(|| format!("reading {}", a.input.display()))?; + let (sig, pubkey) = sign_bytes(&pem, domain, &bytes)?; + std::fs::write(&a.out, &sig).with_context(|| format!("writing {}", a.out.display()))?; + println!("signed {} [{}] -> {} (pubkey {})", a.input.display(), domain.tag(), a.out.display(), pubkey); + Ok(()) +} + +/// The 64-byte signed preimage `sha256(domain_tag) || sha256(message)` (must match `sig.rs`). +fn preimage(domain: Domain, message: &[u8]) -> [u8; 64] { + let dom: [u8; 32] = Sha256::digest(domain.tag().as_bytes()).into(); + let msg: [u8; 32] = Sha256::digest(message).into(); + let mut out = [0u8; 64]; + out[..32].copy_from_slice(&dom); + out[32..].copy_from_slice(&msg); + out +} + +/// Sign `message` for `domain` with the PKCS#8 PEM `pem`; returns (signature, base64 pubkey). +fn sign_bytes(pem: &str, domain: Domain, message: &[u8]) -> Result<(Vec, String)> { + let seed = seed_from_pkcs8_pem(pem)?; + let kp = KeyPair::from_seed(Seed::new(seed)); + let sig = kp.sk.sign(preimage(domain, message), None); + Ok((sig.to_vec(), STANDARD.encode(*kp.pk))) +} + +/// Extract the 32-byte ed25519 seed from a PKCS#8 PEM (RFC 8410): `... 04 22 04 20 `. +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 pos = der + .windows(4) + .position(|w| w == [0x04u8, 0x22, 0x04, 0x20]) + .ok_or_else(|| anyhow!("not a PKCS#8 Ed25519 private key (expected 04 22 04 20 marker)"))?; + let start = pos + 4; + if start + 32 > der.len() { + bail!("truncated ed25519 private key"); + } + let mut seed = [0u8; 32]; + seed.copy_from_slice(&der[start..start + 32]); + Ok(seed) +} + +/// Build the PKCS#8 (RFC 8410) PEM `openssl genpkey` emits, inverse of `seed_from_pkcs8_pem`. +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); + format!("-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", STANDARD.encode(&der)) +} + +/// 32 CSPRNG bytes from the kernel via `/dev/urandom` (std only — no getrandom/libc/C toolchain). +fn random_seed() -> Result<[u8; 32]> { + use std::io::Read; + let mut seed = [0u8; 32]; + std::fs::File::open("/dev/urandom") + .context("open /dev/urandom")? + .read_exact(&mut seed) + .context("read /dev/urandom")?; + Ok(seed) +} + +/// Write `bytes` to `path`, creating it mode 0600 (private key material). +fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + f.write_all(bytes).with_context(|| format!("writing {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Cross-repo wire-contract anchor: this exact (key, domain, message) -> signature must equal + /// stage1's ed25519-sign golden vector, so stage0-sign's signatures verify in stage0 and + /// vice-versa for the shared `stage1.*` roles. Drift in either repo's framing fails CI. + #[test] + fn golden_kat() { + let (sig, pubkey) = + sign_bytes(&pem_from_seed(&[7u8; 32]), Domain::Stage1Manifest, b"lockboot-kat").unwrap(); + assert_eq!(pubkey, "6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw="); + assert_eq!( + STANDARD.encode(&sig), + "nVZgjXp9d4zjnj9axtTQALlMADGqGKPTnR6RjMr8h8nI3wNpsBy0M4ZBjVfjlLKRZTN0pH3AAsGJqU0tJRTQDA==" + ); + } +} diff --git a/crates/stage0/src/main.rs b/crates/stage0/src/main.rs index acc6ba2..9803885 100644 --- a/crates/stage0/src/main.rs +++ b/crates/stage0/src/main.rs @@ -303,7 +303,7 @@ fn try_fetch_manifest(m: &ManifestRef, url: &str) -> Result<(Vec, String), S None => alloc::vec![alloc::format!("{url}.sig")], }; let signature = download_first(&sig_urls)?; - sig::verify(&m.ed25519, &bytes, &signature).map_err(|e| { + sig::verify(&m.ed25519, sig::Domain::Stage1Manifest, &bytes, &signature).map_err(|e| { crate::slog!("stage0: manifest verification failed: {e}"); Status::SECURITY_VIOLATION })?; @@ -370,7 +370,7 @@ fn admit_from(url: &str, mode: &Admit) -> Result<(Vec, [u8; 32], Option alloc::vec![alloc::format!("{url}.sig")], }; let signature = download_first(&sig_urls)?; - sig::verify(pubkey, &binary, &signature).map_err(|m| { + sig::verify(pubkey, sig::Domain::Stage1Uki, &binary, &signature).map_err(|m| { crate::slog!("stage0: ed25519 verification failed: {m}"); Status::SECURITY_VIOLATION })?; @@ -400,7 +400,7 @@ fn fetch_signed_args( }; let args = download_first(&args_urls)?; let sig = download_first(&sig_urls)?; - sig::verify(pubkey, &args, &sig).map_err(|m| { + sig::verify(pubkey, sig::Domain::Stage1Args, &args, &sig).map_err(|m| { crate::slog!("stage0: signed args verification failed: {m}"); Status::SECURITY_VIOLATION })?; diff --git a/crates/stage0/src/sig.rs b/crates/stage0/src/sig.rs index b9a1de6..d02ee0a 100644 --- a/crates/stage0/src/sig.rs +++ b/crates/stage0/src/sig.rs @@ -2,24 +2,66 @@ //! ed25519 admission control for "signed mode" payloads. //! -//! In signed mode the metadata pins a long-term **release public key** (32-byte -//! ed25519, base64) instead of an exact SHA-256. The payload at the URL is -//! whatever the latest signed build is; stage0 fetches a detached signature -//! (`.sig`, 64 raw bytes) and verifies it against the pinned key before -//! loading. This lets a release roll forward without editing VM metadata. +//! In signed mode the metadata pins a long-term **release public key** (32-byte ed25519, base64) +//! instead of an exact SHA-256. The payload at the URL is whatever the latest signed build is; +//! stage0 fetches a detached signature (`.sig`, 64 raw bytes) and verifies it against the +//! pinned key before loading. This lets a release roll forward without editing VM metadata. //! -//! The signature is *admission control only*: it is not measured, and the key -//! is not measured. The attestation surface stays minimal: PCR 14 records the -//! SHA-256 of whatever binary actually ran, full stop. +//! Signatures are **domain-separated**: the ed25519 signature is over a fixed 64-byte preimage +//! `sha256(domain_tag) || sha256(message)`, so a signature minted for one role can never be reused +//! in another. This must match the signer byte-for-byte (github.com/lockboot/stage1, +//! crates/ed25519-sign + the `stage0-sign` CLI in this repo). stage0 only verifies the `stage1.*` +//! roles; the shared golden vector lives in `stage0-sign`'s tests. +//! +//! The signature is *admission control only*: it is not measured, and the key is not measured. +//! The attestation surface stays minimal: PCR 14 records the SHA-256 of whatever binary ran. use base64::engine::general_purpose::STANDARD; use base64::Engine as _; use ed25519_compact::{PublicKey, Signature}; +use sha2::{Digest, Sha256}; + +/// A signing context — the domain-separation namespace. stage0 admits only the `stage1.*` roles. +/// Wire constants; must match the signer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Domain { + /// `_stage1` payload (the UKI). + Stage1Uki, + /// `_stage1` LoadOptions (signed args). + Stage1Args, + /// `_stage1` signed manifest. + Stage1Manifest, +} + +impl Domain { + fn tag(self) -> &'static str { + match self { + Domain::Stage1Uki => "lockboot.v1.stage1.uki", + Domain::Stage1Args => "lockboot.v1.stage1.args", + Domain::Stage1Manifest => "lockboot.v1.stage1.manifest", + } + } +} + +/// The 64-byte signed preimage `sha256(domain_tag) || sha256(message)` (must match the signer). +fn preimage(domain: Domain, message: &[u8]) -> [u8; 64] { + let dom: [u8; 32] = Sha256::digest(domain.tag().as_bytes()).into(); + let msg: [u8; 32] = Sha256::digest(message).into(); + let mut out = [0u8; 64]; + out[..32].copy_from_slice(&dom); + out[32..].copy_from_slice(&msg); + out +} -/// Verify a detached ed25519 `signature` over `message` against the base64 -/// `pubkey_b64` pinned in the metadata. Verification is constant-work and needs -/// no allocator or RNG. -pub fn verify(pubkey_b64: &str, message: &[u8], signature: &[u8]) -> Result<(), &'static str> { +/// Verify a detached ed25519 `signature` for `domain` over `message` against the base64 +/// `pubkey_b64` pinned in the metadata. Pass the raw `message` (the downloaded bytes); this hashes +/// it. Constant-work, needs no RNG. +pub fn verify( + pubkey_b64: &str, + domain: Domain, + message: &[u8], + signature: &[u8], +) -> Result<(), &'static str> { let key_bytes = STANDARD .decode(pubkey_b64.trim()) .map_err(|_| "ed25519 pubkey is not valid base64")?; @@ -28,6 +70,6 @@ pub fn verify(pubkey_b64: &str, message: &[u8], signature: &[u8]) -> Result<(), let signature = Signature::from_slice(signature).map_err(|_| "ed25519 signature wrong length")?; public_key - .verify(message, &signature) + .verify(preimage(domain, message), &signature) .map_err(|_| "ed25519 signature verification failed") }