Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Dockerfile.build
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 29 additions & 24 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>.sig; each verifier fetches <url>.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
# <name>.sig; each verifier fetches <url>.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
Expand Down Expand Up @@ -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 \
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -112,11 +114,13 @@ lockboot-deploy modify ./deploy --add-base-url http://cdn3 # add / --remove-ba

`create` writes `deploy/<arch>/{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 <role> --key release.pem --in <file> --out <file>.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.
Expand Down
3 changes: 0 additions & 3 deletions crates/deploy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
115 changes: 98 additions & 17 deletions crates/deploy/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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<PathBuf>,
}

#[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"])]
Expand Down Expand Up @@ -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())
}
Expand All @@ -99,14 +182,15 @@ fn build_payload(
filename: &str,
src: &Path,
key_pem: Option<&str>,
domain: Domain,
) -> Result<Payload> {
let bytes = fs::read(src).with_context(|| format!("reading {}", src.display()))?;
fs::write(arch_dir.join(filename), &bytes)
.with_context(|| format!("writing {}/{filename}", arch_dir.display()))?;
let url = compose_urls(bases, arch, filename);
let (sha256, ed25519) = match key_pem {
Some(pem) => {
let s = sign_payload(pem, &bytes)?;
let s = sign(pem, domain, &bytes)?;
fs::write(arch_dir.join(format!("{filename}.sig")), &s.signature)?;
(None, Some(s.pubkey_b64))
}
Expand All @@ -132,12 +216,13 @@ fn wrap_manifest(
filename: &str,
payload: Payload,
pem: &str,
domain: Domain,
) -> Result<ArchConfig> {
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 {
Expand Down Expand Up @@ -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).
Expand All @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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());
Expand Down
Loading