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/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 7ce8b09..7cc96bd 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 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..23f4cf3 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. + ## 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..256ee32 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` (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]; + 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..a9c8d15 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. + #[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))