From 72618edd580d4692fc09bc75d3ba93966d07b944 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:05:00 +0000 Subject: [PATCH 1/3] stage1: domain-separated signatures + dogfood the deploy signer Every ed25519 signature in the chain was over raw bytes with no role tag, so a signature minted for one of the six contexts (2 hops x {payload, args, manifest}) was structurally valid in another wherever those bytes were accepted. Bind each signature to its exact role by signing a fixed 64-byte preimage sha256(domain_tag) || sha256(message). ed25519-sign gains a Domain enum covering all six roles, with tags of the form lockboot.v1..; sign()/verify() take a Domain and operate on the preimage (callers still pass the raw message; the fn hashes it). pem_from_seed and pubkey_b64_from_seed are promoted to pub for keygen. Tests add domain-separation rejection and a golden known-answer vector that stage0's independent verifier pins against. The stage1 verifier and mkuki pass their roles (stage2.payload / .args / .manifest, stage1.uki). deploy gains keygen (random ed25519 key via /dev/urandom, no getrandom/libc) and a low-level sign --domain subcommand; create threads the right Domain into each signed artifact. The test Makefile stops shelling out to openssl and dogfoods lockboot-deploy for the release key and every signature, so the real signing and domain-separation code paths are exercised. make test-chain-x86_64 SIGN=1 / SIGN=1 MANIFEST=1 / SIGN=1 SIGN_ARGS=1 all pass against the domain-aware stage0 harness: both hops verify and all six domains round-trip across the repo boundary. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 - Makefile | 53 ++++++----- README.md | 6 +- crates/deploy/Cargo.toml | 3 - crates/deploy/src/main.rs | 115 +++++++++++++++++++---- crates/ed25519-sign/src/lib.rs | 166 ++++++++++++++++++++++++++------- crates/mkuki/src/main.rs | 2 +- crates/stage1/src/main.rs | 6 +- 8 files changed, 270 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 810f660..aeb4270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,7 +382,6 @@ name = "deploy" version = "0.1.0" dependencies = [ "anyhow", - "base64", "clap", "ed25519-sign", "metadata", diff --git a/Makefile b/Makefile index 7ce8b09..6691921 100644 --- a/Makefile +++ b/Makefile @@ -193,40 +193,45 @@ STAGE0_BOOT_DISK ?= $(STAGE0_DIR)/build/$*/boot.disk # stage0's DNS4 on the _stage1 hop (the _stage2 hop then needs guest DNS). SERVE_HOST ?= 10.0.2.1:8000 -# ed25519 release key for SIGN=1 (signed-mode admission). stage0 only ever sees the -# public half, pinned in the _stage1 doc; the private key signs the UKI. Generated -# in the build container (gitignored under build/keys). -build/keys/release.pem: docker-build-base +# The deploy CLI (lockboot-deploy) is the canonical signer + keygen; the tests dogfood it instead +# of shelling out to openssl, so the exact signing + domain-separation code paths are exercised. +# Built in the container; a musl static binary that runs there. cargo no-ops when up to date. +DEPLOY := target/x86_64-unknown-linux-musl/debug/lockboot-deploy +.PHONY: deploy-bin +deploy-bin: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) cargo build -p deploy + +# ed25519 release key for SIGN=1 (signed-mode admission). stage0 only ever sees the public half, +# pinned in the _stage1 doc; the private key signs the UKI. Generated in the build container +# (gitignored under build/keys) by `lockboot-deploy keygen`. +build/keys/release.pem: docker-build-base | deploy-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" - -# Detached ed25519 sigs over the UKI and stage2 (SIGN=1). ed25519 is deterministic, so -# `openssl pkeyutl -rawin` yields the exact bytes stage0/stage1 verify against the pinned -# pubkey. Served as .sig; each verifier fetches .sig in ed25519 mode. -build/%/linux.efi.sig: tools/build-uki/%/linux.efi build/keys/release.pem + $(DEPLOY) keygen --out build/keys/release.pem --pub build/keys/release.pub.b64" + +# Detached, domain-separated ed25519 sigs over the UKI and stage2 (SIGN=1): lockboot-deploy signs +# sha256(domain)||sha256(payload), which stage0/stage1 verify against the pinned pubkey. Served as +# .sig; each verifier fetches .sig in ed25519 mode. +build/%/linux.efi.sig: tools/build-uki/%/linux.efi build/keys/release.pem | deploy-bin $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ mkdir -p build/$* && \ - openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \ - -in tools/build-uki/$*/linux.efi -out build/$*/linux.efi.sig" + $(DEPLOY) sign --domain stage1.uki --key build/keys/release.pem \ + --in tools/build-uki/$*/linux.efi --out build/$*/linux.efi.sig" -build/%/stage2.sig: build/%/stage2 build/keys/release.pem +build/%/stage2.sig: build/%/stage2 build/keys/release.pem | deploy-bin $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ mkdir -p build/$* && \ - openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \ - -in build/$*/stage2 -out build/$*/stage2.sig" + $(DEPLOY) sign --domain stage2.payload --key build/keys/release.pem \ + --in build/$*/stage2 --out build/$*/stage2.sig" -# Signed remote args for SIGN_ARGS=1: a JSON array of strings, ed25519-signed like the -# payloads. stage1 fetches args.json + args.json.sig, verifies against the pinned key, -# and uses them as argv (overriding inline _stage2.args). -build/%/args.json.sig: build/keys/release.pem +# Signed remote args for SIGN_ARGS=1: a JSON array of strings, domain-signed like the payloads. +# stage1 fetches args.json + args.json.sig, verifies against the pinned key, and uses them as argv. +build/%/args.json.sig: build/keys/release.pem | deploy-bin $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ mkdir -p build/$* && \ printf '%s' '[\"--from\",\"signed-args\"]' > build/$*/args.json && \ - openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \ - -in build/$*/args.json -out build/$*/args.json.sig" + $(DEPLOY) sign --domain stage2.args --key build/keys/release.pem \ + --in build/$*/args.json --out build/$*/args.json.sig" # Guard the arch-less form with a helpful message instead of "no rule to make target". .PHONY: test-chain @@ -281,7 +286,7 @@ test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 \ S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \ printf '{ "_stage2": { "%s": { "payload": { "url": "%s/stage2", "sha256": "%s"%s } } } }\n' "$*" "$$H" "$$S2_SHA" "$$INLINE_ARGS" > "$$D/stage2.manifest.json"; \ $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c \ - "openssl pkeyutl -sign -inkey build/keys/release.pem -rawin -in $$D/stage2.manifest.json -out $$D/stage2.manifest.json.sig"; \ + "$(DEPLOY) sign --domain stage2.manifest --key build/keys/release.pem --in $$D/stage2.manifest.json --out $$D/stage2.manifest.json.sig"; \ S2="\"$*\": { \"manifest\": { \"url\": \"$$H/stage2.manifest.json\", \"ed25519\": \"$$PUB\" } }"; \ echo "user-data: _stage2 via signed manifest (pubkey $$PUB)"; \ else \ diff --git a/README.md b/README.md index 853386f..1bf6cd8 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ stage1 admits its stage2 payload from a `_stage2` block in the instance's user-d The optional `manifest.sha256` also pins the manifest's own bytes. Every hop is recorded in the merged entry's `resolved_manifests` array (each with the resolved hash + verifying key) — verifier-authoritative provenance the payload sees on stdin. You don't hand-write these docs: the [`deploy` tool](#deploy) below signs the payloads/manifests and generates the `user-data.json`. +**Domain separation.** Every ed25519 signature in the chain is over a fixed 64-byte preimage `sha256(domain_tag) || sha256(message)`, where the tag names the exact role. There are six roles across the two hops — `lockboot.v1.stage1.uki` / `.stage1.args` / `.stage1.manifest` (stage0 admits the UKI hop) and `lockboot.v1.stage2.payload` / `.stage2.args` / `.stage2.manifest` (stage1 admits the payload hop). Because the role is bound into what gets signed, a signature minted for one context is structurally invalid in every other: a signed-args blob can't be replayed as a payload signature, a `_stage1` manifest can't stand in for a `_stage2` one, and so on. `deploy` and `stage1` share the framing via the `ed25519-sign` crate; stage0's independent verifier is pinned to it byte-for-byte by a shared golden known-answer test. + **Fallback URLs.** Every URL field (`url`, `sig_url`, `args_url`, `args_sig_url`, `manifest.url`) accepts either a single string **or a list of strings** tried in order — for mirror resiliency. Because the payload is cryptographically pinned, any mirror that yields verifying bytes is accepted; a dead or wrong mirror is simply skipped. URLs may be `http://` or `https://`, and the `*_url` fields may contain a `{sha256}` placeholder (replaced with the payload's — or manifest's — hex digest, for content-addressed signatures): ```json @@ -112,11 +114,13 @@ lockboot-deploy modify ./deploy --add-base-url http://cdn3 # add / --remove-ba `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).) +The release key comes from `lockboot-deploy keygen --out release.pem --pub release.pub.b64` (a PKCS#8 ed25519 key; randomness is read from `/dev/urandom`, so it builds with no host C toolchain), and `lockboot-deploy sign --domain --key release.pem --in --out .sig` produces one domain-separated signature — the low-level primitive the test Makefile drives for each artifact. There is no `openssl` in the signing path. + ## Crates - **`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`. +- **`ed25519-sign`** — the domain-separated ed25519 sign/verify (`sha256(domain_tag) || sha256(message)`) + sha256 primitive (the cross-repo wire contract, with a golden known-answer test), 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. diff --git a/crates/deploy/Cargo.toml b/crates/deploy/Cargo.toml index e0e822f..df5c740 100644 --- a/crates/deploy/Cargo.toml +++ b/crates/deploy/Cargo.toml @@ -15,6 +15,3 @@ ed25519-sign = { path = "../ed25519-sign" } clap = { version = "4", features = ["derive"] } serde_json = { workspace = true } anyhow = { workspace = true } - -[dev-dependencies] -base64 = { workspace = true } diff --git a/crates/deploy/src/main.rs b/crates/deploy/src/main.rs index a67da18..f69350d 100644 --- a/crates/deploy/src/main.rs +++ b/crates/deploy/src/main.rs @@ -8,7 +8,7 @@ use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Parser, Subcommand}; -use ed25519_sign::{sha256_hex, sign_payload}; +use ed25519_sign::{pem_from_seed, pubkey_b64_from_seed, sha256_hex, sign, Domain}; use metadata::{ArchConfig, Entry, ManifestRef, Payload, Profile, StageConfig, UrlList, UserData}; use serde_json::{json, Map, Value}; use std::fs; @@ -23,6 +23,10 @@ struct Cli { #[derive(Subcommand)] enum Cmd { + /// Generate an ed25519 release key (PKCS#8 PEM) and print its base64 public key. + Keygen(KeygenArgs), + /// Sign one file for a signing domain (low-level; used by the build/test tooling). + Sign(SignArgs), /// 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. @@ -34,6 +38,33 @@ enum Cmd { Modify(ModifyArgs), } +#[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: one of stage1.uki / stage1.args / stage1.manifest / + /// stage2.payload / stage2.args / stage2.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, +} + #[derive(Args)] struct CreateArgs { #[arg(long, value_parser = ["x86_64", "aarch64"])] @@ -80,12 +111,64 @@ struct ModifyArgs { fn main() -> Result<()> { match Cli::parse().cmd { + Cmd::Keygen(a) => keygen(a), + Cmd::Sign(a) => sign_file(a), Cmd::Create(a) => create(a), Cmd::Validate { path } => validate(&path), Cmd::Modify(a) => modify(a), } } +/// Generate a random ed25519 key, write the PKCS#8 PEM (mode 0600), and print its base64 pubkey. +fn keygen(a: KeygenArgs) -> Result<()> { + let seed = random_seed()?; + let pubkey = pubkey_b64_from_seed(&seed); + write_private(&a.out, pem_from_seed(&seed).as_bytes())?; + if let Some(pub_path) = &a.pub_out { + fs::write(pub_path, &pubkey).with_context(|| format!("writing {}", pub_path.display()))?; + } + println!("wrote {} (ed25519 private key, mode 0600)", a.out.display()); + println!("pubkey: {pubkey}"); + Ok(()) +} + +/// 32 cryptographically-random bytes from the kernel CSPRNG via `/dev/urandom`. Uses only `std` +/// (no `getrandom`/`libc`/`rand`), so the tool builds with no C toolchain on the host. +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) +} + +/// Sign one file for `--domain` with `--key`, writing the detached signature to `--out`. +fn sign_file(a: SignArgs) -> Result<()> { + let domain: Domain = a.domain.parse().map_err(|e| anyhow!("--domain {}: {e}", a.domain))?; + let pem = fs::read_to_string(&a.key).with_context(|| format!("reading key {}", a.key.display()))?; + let bytes = fs::read(&a.input).with_context(|| format!("reading {}", a.input.display()))?; + let s = sign(&pem, domain, &bytes)?; + fs::write(&a.out, &s.signature).with_context(|| format!("writing {}", a.out.display()))?; + println!("signed {} [{}] -> {} (pubkey {})", a.input.display(), domain.tag(), a.out.display(), s.pubkey_b64); + Ok(()) +} + +/// 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 = 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())) +} + fn compose_urls(bases: &[String], arch: &str, filename: &str) -> UrlList { UrlList(bases.iter().map(|b| format!("{b}/{arch}/{filename}")).collect()) } @@ -99,6 +182,7 @@ fn build_payload( filename: &str, src: &Path, key_pem: Option<&str>, + domain: Domain, ) -> Result { let bytes = fs::read(src).with_context(|| format!("reading {}", src.display()))?; fs::write(arch_dir.join(filename), &bytes) @@ -106,7 +190,7 @@ fn build_payload( let url = compose_urls(bases, arch, filename); let (sha256, ed25519) = match key_pem { Some(pem) => { - let s = sign_payload(pem, &bytes)?; + let s = sign(pem, domain, &bytes)?; fs::write(arch_dir.join(format!("{filename}.sig")), &s.signature)?; (None, Some(s.pubkey_b64)) } @@ -132,12 +216,13 @@ fn wrap_manifest( filename: &str, payload: Payload, pem: &str, + domain: Domain, ) -> Result { let manifest = json!({ stage_key: { arch: { "payload": serde_json::to_value(&payload)? } } }); let bytes = serde_json::to_vec_pretty(&manifest)?; let name = format!("{filename}.manifest.json"); fs::write(arch_dir.join(&name), &bytes)?; - let s = sign_payload(pem, &bytes)?; + let s = sign(pem, domain, &bytes)?; fs::write(arch_dir.join(format!("{name}.sig")), &s.signature)?; Ok(ArchConfig { entry: Entry::Manifest(ManifestRef { @@ -173,8 +258,10 @@ fn create(a: CreateArgs) -> Result<()> { // In manifest mode the inner payload is sha256-pinned (the manifest signature covers it); // in direct mode the payload itself is signed with the key (or sha256-pinned when no key). let payload_key = if a.manifest { None } else { key_pem.as_deref() }; - let uki_payload = build_payload(&arch_dir, &a.arch, &bases, "linux.efi", &a.uki, payload_key)?; - let mut stage2_payload = build_payload(&arch_dir, &a.arch, &bases, "stage2", &a.stage2, payload_key)?; + let uki_payload = + build_payload(&arch_dir, &a.arch, &bases, "linux.efi", &a.uki, payload_key, Domain::Stage1Uki)?; + let mut stage2_payload = + build_payload(&arch_dir, &a.arch, &bases, "stage2", &a.stage2, payload_key, Domain::Stage2Payload)?; // Args ride inside the stage2 payload: inline, or served as a separately-signed blob // (ed25519 direct mode). In manifest mode args are always inline (bound by the manifest sig). @@ -185,7 +272,7 @@ fn create(a: CreateArgs) -> Result<()> { 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)?; + let s = sign(pem, Domain::Stage2Args, &blob)?; fs::write(arch_dir.join("args.json.sig"), &s.signature)?; stage2_payload.args_url = Some(compose_urls(&bases, &a.arch, "args.json")); } else { @@ -196,8 +283,8 @@ fn create(a: CreateArgs) -> Result<()> { let (uki_entry, stage2_entry) = if a.manifest { let pem = key_pem.as_deref().expect("clap requires --key with --manifest"); ( - wrap_manifest(&arch_dir, &a.arch, &bases, "_stage1", "linux.efi", uki_payload, pem)?, - wrap_manifest(&arch_dir, &a.arch, &bases, "_stage2", "stage2", stage2_payload, pem)?, + wrap_manifest(&arch_dir, &a.arch, &bases, "_stage1", "linux.efi", uki_payload, pem, Domain::Stage1Manifest)?, + wrap_manifest(&arch_dir, &a.arch, &bases, "_stage2", "stage2", stage2_payload, pem, Domain::Stage2Manifest)?, ) } else { (direct_entry(uki_payload), direct_entry(stage2_payload)) @@ -401,15 +488,9 @@ mod tests { let _ = fs::remove_dir_all(&dir); } - /// PKCS#8 PEM for an ed25519 key with a fixed 32-byte seed (matches ed25519-sign's format). + /// PKCS#8 PEM for an ed25519 key with a fixed 32-byte seed (via the shared signer). fn test_pem() -> String { - use base64::Engine as _; - let mut der = vec![ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, - ]; - der.extend_from_slice(&[7u8; 32]); - let b64 = base64::engine::general_purpose::STANDARD.encode(&der); - format!("-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----\n") + pem_from_seed(&[7u8; 32]) } #[test] @@ -445,7 +526,7 @@ mod tests { // payload carries the sha256 pin + the (bound) args. let mbytes = fs::read(out.join("x86_64/stage2.manifest.json")).unwrap(); let sig = fs::read(out.join("x86_64/stage2.manifest.json.sig")).unwrap(); - ed25519_sign::verify(&mref.ed25519, &mbytes, &sig).unwrap(); + ed25519_sign::verify(&mref.ed25519, Domain::Stage2Manifest, &mbytes, &sig).unwrap(); let frag: UserData = serde_json::from_slice(&mbytes).unwrap(); let Entry::Payload(p) = frag.stage2.unwrap().x86_64.unwrap().entry else { panic!("expected payload") }; assert!(p.sha256.is_some()); diff --git a/crates/ed25519-sign/src/lib.rs b/crates/ed25519-sign/src/lib.rs index 82df574..0521eff 100644 --- a/crates/ed25519-sign/src/lib.rs +++ b/crates/ed25519-sign/src/lib.rs @@ -1,10 +1,14 @@ // 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. +//! ed25519 signing + sha256 — the cross-repo **wire contract** shared by `mkuki`, the `deploy` +//! tool, and the stage0/stage1 admission checks. 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 (e.g. a `_stage2` manifest) is structurally invalid in any other +//! (a payload, a different hop, ...). The pinned key is the base64 of the 32-byte public key. +//! +//! A signature this produces must verify byte-for-byte against stage0's copy +//! (github.com/lockboot/stage0, crates/stage0/src/sig.rs) for the shared `stage1.*` domains — see +//! the golden known-answer test below. use anyhow::{ensure, Context, Result}; use base64::engine::general_purpose::STANDARD; @@ -12,17 +16,84 @@ 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> { +/// A signing context. The `tag()` string is the domain-separation namespace mixed into every +/// signature so a signature is only ever valid for its exact (hop, kind). Wire constants — do not +/// change without bumping the `v1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Domain { + /// `_stage1` payload (the UKI), admitted by stage0. + Stage1Uki, + /// `_stage1` LoadOptions (signed args), admitted by stage0. + Stage1Args, + /// `_stage1` signed manifest, resolved by stage0. + Stage1Manifest, + /// `_stage2` payload, admitted by stage1. + Stage2Payload, + /// `_stage2` args, admitted by stage1. + Stage2Args, + /// `_stage2` signed manifest, resolved by stage1. + Stage2Manifest, +} + +impl Domain { + /// The namespace string mixed into the signature preimage. + #[must_use] + pub 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", + Domain::Stage2Payload => "lockboot.v1.stage2.payload", + Domain::Stage2Args => "lockboot.v1.stage2.args", + Domain::Stage2Manifest => "lockboot.v1.stage2.manifest", + } + } +} + +impl core::str::FromStr for Domain { + type Err = &'static str; + /// Parse the short CLI form (`stage2.manifest`); the `lockboot.v1.` prefix is implicit. + fn from_str(s: &str) -> core::result::Result { + Ok(match s { + "stage1.uki" => Domain::Stage1Uki, + "stage1.args" => Domain::Stage1Args, + "stage1.manifest" => Domain::Stage1Manifest, + "stage2.payload" => Domain::Stage2Payload, + "stage2.args" => Domain::Stage2Args, + "stage2.manifest" => Domain::Stage2Manifest, + _ => return Err("unknown signing domain"), + }) + } +} + +/// The 64-byte signed preimage: `sha256(domain_tag) || sha256(message)`. Hash-then-sign, fixed +/// length regardless of `message` size. For a payload the second half equals the content digest +/// that admission computes on download and pins in sha256 mode. +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` for `domain` over `message` against the base64 +/// `pubkey_b64` (32-byte public key). Pass the raw `message` (e.g. downloaded bytes); this hashes +/// it. The verify counterpart of [`sign`] — the on-instance admission checks use this. +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")?; 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) + .verify(preimage(domain, message), &signature) .map_err(|_| "ed25519 signature verification failed") } @@ -36,17 +107,17 @@ pub fn sha256_hex(data: &[u8]) -> String { } pub struct Signed { - /// 64-byte detached ed25519 signature over the payload. + /// 64-byte detached ed25519 signature over the domain-separated preimage. 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 { +/// Sign `message` for `domain` with the PKCS#8 (PEM) ed25519 private key at `pem`. +pub fn sign(pem: &str, domain: Domain, message: &[u8]) -> Result { let seed = seed_from_pkcs8_pem(pem)?; let kp = KeyPair::from_seed(Seed::new(seed)); - let signature = kp.sk.sign(payload, None); + let signature = kp.sk.sign(preimage(domain, message), None); Ok(Signed { signature: signature.to_vec(), pubkey_b64: STANDARD.encode(*kp.pk), @@ -77,33 +148,64 @@ fn seed_from_pkcs8_pem(pem: &str) -> Result<[u8; 32]> { anyhow::bail!("not a PKCS#8 Ed25519 private key (expected 04 22 04 20 marker)") } +/// Build a valid PKCS#8 (RFC 8410) ed25519 PEM from a raw seed — the exact DER +/// `302e020100300506032b657004220420 ` that `openssl genpkey` emits, and the inverse of +/// [`seed_from_pkcs8_pem`]. Used by `deploy keygen` (with a random seed) and the tests. +#[must_use] +pub 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") +} + +/// The base64 32-byte public key for a raw seed — the value pinned in an `ed25519` metadata field. +#[must_use] +pub fn pubkey_b64_from_seed(seed: &[u8; 32]) -> String { + STANDARD.encode(*KeyPair::from_seed(Seed::new(*seed)).pk) +} + #[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 s = sign(&pem_from_seed(&[7u8; 32]), Domain::Stage2Payload, msg).unwrap(); + assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, msg, &s.signature).is_ok()); + assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, b"tampered!!!!!", &s.signature).is_err()); 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()); + assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, msg, &bad).is_err()); + assert!(verify("not-base64!!", Domain::Stage2Payload, msg, &s.signature).is_err()); + } + + #[test] + fn domain_separation_rejects_cross_use() { + // Same key, same bytes, different domain -> must not verify. + let msg = b"identical bytes"; + let s = sign(&pem_from_seed(&[7u8; 32]), Domain::Stage2Manifest, msg).unwrap(); + assert!(verify(&s.pubkey_b64, Domain::Stage2Manifest, msg, &s.signature).is_ok()); + assert!(verify(&s.pubkey_b64, Domain::Stage1Manifest, msg, &s.signature).is_err()); + assert!(verify(&s.pubkey_b64, Domain::Stage2Payload, msg, &s.signature).is_err()); + } + + /// Cross-repo wire-contract anchor: this exact (key, domain, message) -> signature must also + /// hold in stage0's verifier (github.com/lockboot/stage0). If either repo's framing drifts, + /// this fails. Values are base64 (no hex dep). + #[test] + fn golden_kat() { + let s = sign(&pem_from_seed(&[7u8; 32]), Domain::Stage1Manifest, b"lockboot-kat").unwrap(); + assert_eq!(s.pubkey_b64, "6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw="); + assert_eq!( + STANDARD.encode(&s.signature), + "nVZgjXp9d4zjnj9axtTQALlMADGqGKPTnR6RjMr8h8nI3wNpsBy0M4ZBjVfjlLKRZTN0pH3AAsGJqU0tJRTQDA==" + ); + assert!(verify(&s.pubkey_b64, Domain::Stage1Manifest, b"lockboot-kat", &s.signature).is_ok()); } #[test] diff --git a/crates/mkuki/src/main.rs b/crates/mkuki/src/main.rs index fd90fea..93bbe6b 100644 --- a/crates/mkuki/src/main.rs +++ b/crates/mkuki/src/main.rs @@ -136,7 +136,7 @@ fn main() -> Result<()> { if let Some(key_path) = &args.sign_key { let pem = std::fs::read_to_string(key_path) .with_context(|| format!("reading signing key {}", key_path.display()))?; - let s = sign::sign_payload(&pem, &image)?; + let s = sign::sign(&pem, sign::Domain::Stage1Uki, &image)?; let sig_path = sig_path(&args.out); std::fs::write(&sig_path, &s.signature) .with_context(|| format!("writing {}", sig_path.display()))?; diff --git a/crates/stage1/src/main.rs b/crates/stage1/src/main.rs index 5740ad0..d4da785 100644 --- a/crates/stage1/src/main.rs +++ b/crates/stage1/src/main.rs @@ -221,7 +221,7 @@ fn fetch_signed_args( }; let args_bytes = download_first(&args_urls)?; let signature = download_first(&args_sig_urls)?; - ed25519_sign::verify(pubkey, &args_bytes, &signature) + ed25519_sign::verify(pubkey, ed25519_sign::Domain::Stage2Args, &args_bytes, &signature) .map_err(|m| anyhow!("signed args verification failed: {m}"))?; let args: Vec = serde_json::from_slice(&args_bytes) .context("signed args must be a JSON array of strings")?; @@ -261,7 +261,7 @@ fn admit_from(url: &str, mode: &Admit) -> Result<(Bytes, Option>)> { None => vec![format!("{url}.sig")], }; let signature = download_first(&sig_urls)?; - ed25519_sign::verify(pubkey, &binary, &signature) + ed25519_sign::verify(pubkey, ed25519_sign::Domain::Stage2Payload, &binary, &signature) .map_err(|m| anyhow!("ed25519 verification failed: {m}"))?; ktseprintln!("verified: sha256:{hash} (ed25519 key:{pubkey})"); if let Some(au) = args_url { @@ -393,7 +393,7 @@ fn try_fetch_manifest(m: &ManifestRef, url: &str) -> Result<(Bytes, String)> { None => vec![format!("{url}.sig")], }; let signature = download_first(&sig_urls)?; - ed25519_sign::verify(&m.ed25519, &bytes, &signature) + ed25519_sign::verify(&m.ed25519, ed25519_sign::Domain::Stage2Manifest, &bytes, &signature) .map_err(|e| anyhow!("manifest verification failed: {e}"))?; ktseprintln!("manifest verified: sha256:{hash} (ed25519 key:{})", m.ed25519); Ok((bytes, hash)) From d385f446c2bff45ca5e2efad3417f24a97d66b8b Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:58:07 +0000 Subject: [PATCH 2/3] build: drop global RUSTFLAGS; rely on per-target .cargo/config.toml The Dockerfile baked a global ENV RUSTFLAGS applying the static-link flags to every target uniformly, including the host, where +crt-static makes proc-macro dylibs impossible to build. stage1 already has a self-sufficient .cargo/config.toml with per-target rustflags (musl static, gnu host rust-lld), so the env var only masked them. Remove it. The env was overriding the config rustflags, so with it gone cargo now applies the per-target rustflags. Paired with the shared workspace config dropping its duplicate musl rustflags (cargo concatenates rustflags across config files), the stage1 and stage2 binaries and the measured linux.efi UKI hash identically before and after, and the full chain still passes SIGN=1 / MANIFEST=1 / SIGN_ARGS=1. Co-Authored-By: Claude Opus 4.8 --- Dockerfile.build | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile.build b/Dockerfile.build index c8a2fb4..684f474 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -34,7 +34,9 @@ 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" +# Linker/static rustflags live in .cargo/config.toml, scoped per target. A global RUSTFLAGS here +# would apply to every target (forcing +crt-static onto the host, breaking proc-macros) and would +# override the per-target config, so it is intentionally not set. # User environment (project-specific cargo/rustup data) ENV HOME=/src From 64ad1e1fc4f1ae3cdfa56f5f12d121be5bf829b4 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:09:17 +0000 Subject: [PATCH 3/3] docs: trim tombstones and stale comments Release-prep hygiene over the domain-separation + rustflags changes, no logic: - Dockerfile.build: collapse the 3-line comment narrating the removed ENV RUSTFLAGS to a one-line statement of the rule. - Makefile: drop "instead of shelling out to openssl" from the deploy-bin note. - README: drop the trailing "There is no openssl in the signing path" sentence. - deploy: random_seed doc drops the removed-dep enumeration (getrandom/libc/rand), keeping the reason (no C toolchain on the host). - ed25519-sign: drop the "(no hex dep)" aside on the golden vector. Kept the openssl-genpkey PEM-format note: it documents a live interop property, not a removed dependency. deploy + ed25519-sign compile clean (comment-only). Co-Authored-By: Claude Opus 4.8 --- Dockerfile.build | 5 ++--- Makefile | 4 ++-- README.md | 2 +- crates/deploy/src/main.rs | 4 ++-- crates/ed25519-sign/src/lib.rs | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Dockerfile.build b/Dockerfile.build index 684f474..a9b6962 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -34,9 +34,8 @@ RUN apt-get -qq update && \ # Reproducible builds environment ENV SOURCE_DATE_EPOCH=0 ENV CARGO_INCREMENTAL=0 -# Linker/static rustflags live in .cargo/config.toml, scoped per target. A global RUSTFLAGS here -# would apply to every target (forcing +crt-static onto the host, breaking proc-macros) and would -# override the per-target config, so it is intentionally not set. +# 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 6691921..7cc96bd 100644 --- a/Makefile +++ b/Makefile @@ -193,8 +193,8 @@ STAGE0_BOOT_DISK ?= $(STAGE0_DIR)/build/$*/boot.disk # stage0's DNS4 on the _stage1 hop (the _stage2 hop then needs guest DNS). SERVE_HOST ?= 10.0.2.1:8000 -# The deploy CLI (lockboot-deploy) is the canonical signer + keygen; the tests dogfood it instead -# of shelling out to openssl, so the exact signing + domain-separation code paths are exercised. +# The deploy CLI (lockboot-deploy) is the canonical signer + keygen; the tests dogfood it so the +# exact signing + domain-separation code paths are exercised. # Built in the container; a musl static binary that runs there. cargo no-ops when up to date. DEPLOY := target/x86_64-unknown-linux-musl/debug/lockboot-deploy .PHONY: deploy-bin diff --git a/README.md b/README.md index 1bf6cd8..23f4cf3 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ lockboot-deploy modify ./deploy --add-base-url http://cdn3 # add / --remove-ba `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).) -The release key comes from `lockboot-deploy keygen --out release.pem --pub release.pub.b64` (a PKCS#8 ed25519 key; randomness is read from `/dev/urandom`, so it builds with no host C toolchain), and `lockboot-deploy sign --domain --key release.pem --in --out .sig` produces one domain-separated signature — the low-level primitive the test Makefile drives for each artifact. There is no `openssl` in the signing path. +The release key comes from `lockboot-deploy keygen --out release.pem --pub release.pub.b64` (a PKCS#8 ed25519 key; randomness is read from `/dev/urandom`, so it builds with no host C toolchain), and `lockboot-deploy sign --domain --key release.pem --in --out .sig` produces one domain-separated signature — the low-level primitive the test Makefile drives for each artifact. ## Crates diff --git a/crates/deploy/src/main.rs b/crates/deploy/src/main.rs index f69350d..256ee32 100644 --- a/crates/deploy/src/main.rs +++ b/crates/deploy/src/main.rs @@ -132,8 +132,8 @@ fn keygen(a: KeygenArgs) -> Result<()> { Ok(()) } -/// 32 cryptographically-random bytes from the kernel CSPRNG via `/dev/urandom`. Uses only `std` -/// (no `getrandom`/`libc`/`rand`), so the tool builds with no C toolchain on the host. +/// 32 cryptographically-random bytes from the kernel CSPRNG via `/dev/urandom` (std-only, so the +/// tool needs no C toolchain on the host). fn random_seed() -> Result<[u8; 32]> { use std::io::Read; let mut seed = [0u8; 32]; diff --git a/crates/ed25519-sign/src/lib.rs b/crates/ed25519-sign/src/lib.rs index 0521eff..a9c8d15 100644 --- a/crates/ed25519-sign/src/lib.rs +++ b/crates/ed25519-sign/src/lib.rs @@ -196,7 +196,7 @@ mod tests { /// Cross-repo wire-contract anchor: this exact (key, domain, message) -> signature must also /// hold in stage0's verifier (github.com/lockboot/stage0). If either repo's framing drifts, - /// this fails. Values are base64 (no hex dep). + /// this fails. Values are base64. #[test] fn golden_kat() { let s = sign(&pem_from_seed(&[7u8; 32]), Domain::Stage1Manifest, b"lockboot-kat").unwrap();