From ec4cc272af4479f23bcdea9178a336aae6fd2af9 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 11 Mar 2026 15:29:49 +0100 Subject: [PATCH] feat(pack): Ed25519 package signing -- generate-key, sign, verify subcommands --- crates/weft-pack/Cargo.toml | 4 + crates/weft-pack/src/main.rs | 209 ++++++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/crates/weft-pack/Cargo.toml b/crates/weft-pack/Cargo.toml index a8e1c76..4d71492 100644 --- a/crates/weft-pack/Cargo.toml +++ b/crates/weft-pack/Cargo.toml @@ -12,3 +12,7 @@ path = "src/main.rs" anyhow = "1.0" serde = { version = "1", features = ["derive"] } toml = "0.8" +ed25519-dalek = { version = "2", features = ["rand_core"] } +sha2 = "0.10" +rand = "0.8" +hex = "0.4" diff --git a/crates/weft-pack/src/main.rs b/crates/weft-pack/src/main.rs index 15cf265..0be724f 100644 --- a/crates/weft-pack/src/main.rs +++ b/crates/weft-pack/src/main.rs @@ -55,13 +55,48 @@ fn main() -> anyhow::Result<()> { Some("list") => { list_installed(); } + Some("generate-key") => { + let out = args.get(2).map(String::as_str).unwrap_or("."); + generate_key(Path::new(out))?; + } + Some("sign") => { + let dir = args + .get(2) + .context("usage: weft-pack sign --key ")?; + let key = args + .windows(2) + .find(|w| w[0] == "--key") + .map(|w| &w[1]) + .context("missing --key ")?; + sign_package(Path::new(dir), Path::new(key))?; + } + Some("verify") => { + let dir = args + .get(2) + .context("usage: weft-pack verify --key ")?; + let key = args + .windows(2) + .find(|w| w[0] == "--key") + .map(|w| &w[1]) + .context("missing --key ")?; + let ok = verify_package(Path::new(dir), Path::new(key))?; + if ok { + println!("OK"); + } else { + eprintln!("INVALID signature"); + std::process::exit(1); + } + } _ => { eprintln!("usage:"); - eprintln!(" weft-pack check validate a package directory"); - eprintln!(" weft-pack info print package metadata"); - eprintln!(" weft-pack install install package to app store"); - eprintln!(" weft-pack uninstall remove installed package"); - eprintln!(" weft-pack list list installed packages"); + eprintln!(" weft-pack check validate a package directory"); + eprintln!(" weft-pack info print package metadata"); + eprintln!(" weft-pack install install package to app store"); + eprintln!(" weft-pack uninstall remove installed package"); + eprintln!(" weft-pack list list installed packages"); + eprintln!(" weft-pack generate-key [] generate Ed25519 keypair"); + eprintln!(" weft-pack sign --key sign package with private key"); + eprintln!(" weft-pack verify --key verify package signature"); std::process::exit(1); } } @@ -303,6 +338,110 @@ fn copy_dir(src: &Path, dst: &Path) -> anyhow::Result<()> { Ok(()) } +fn collect_bundle_files( + base: &Path, + dir: &Path, + entries: &mut Vec<(String, [u8; 32])>, +) -> anyhow::Result<()> { + use sha2::{Digest, Sha256}; + for entry in std::fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + let rel = path + .strip_prefix(base) + .context("strip prefix")? + .to_string_lossy() + .into_owned(); + if rel == "signature.sig" { + continue; + } + if path.is_dir() { + collect_bundle_files(base, &path, entries)?; + } else { + let contents = + std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let hash: [u8; 32] = Sha256::digest(&contents).into(); + entries.push((rel, hash)); + } + } + Ok(()) +} + +fn canonical_bundle_hash(dir: &Path) -> anyhow::Result<[u8; 32]> { + use sha2::{Digest, Sha256}; + let mut entries: Vec<(String, [u8; 32])> = Vec::new(); + collect_bundle_files(dir, dir, &mut entries)?; + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut canonical = String::new(); + for (path, hash) in &entries { + canonical.push_str(&format!("{path}\t{}\n", hex::encode(hash))); + } + Ok(Sha256::digest(canonical.as_bytes()).into()) +} + +fn generate_key(output_dir: &Path) -> anyhow::Result<()> { + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + let key_path = output_dir.join("weft-sign.key"); + let pub_path = output_dir.join("weft-sign.pub"); + if key_path.exists() || pub_path.exists() { + anyhow::bail!( + "key files already exist in {}; remove them first", + output_dir.display() + ); + } + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + std::fs::create_dir_all(output_dir) + .with_context(|| format!("create {}", output_dir.display()))?; + std::fs::write(&key_path, hex::encode(signing_key.to_bytes())) + .with_context(|| format!("write {}", key_path.display()))?; + std::fs::write(&pub_path, hex::encode(verifying_key.to_bytes())) + .with_context(|| format!("write {}", pub_path.display()))?; + println!("private key: {}", key_path.display()); + println!("public key: {}", pub_path.display()); + Ok(()) +} + +fn sign_package(dir: &Path, key_file: &Path) -> anyhow::Result<()> { + use ed25519_dalek::{Signer, SigningKey}; + let key_hex = std::fs::read_to_string(key_file) + .with_context(|| format!("read {}", key_file.display()))?; + let key_bytes: [u8; 32] = hex::decode(key_hex.trim()) + .context("decode signing key: expected 64 hex chars")? + .try_into() + .map_err(|_| anyhow::anyhow!("signing key must be 32 bytes"))?; + let signing_key = SigningKey::from_bytes(&key_bytes); + let hash = canonical_bundle_hash(dir)?; + let signature = signing_key.sign(&hash); + let sig_path = dir.join("signature.sig"); + std::fs::write(&sig_path, hex::encode(signature.to_bytes())) + .with_context(|| format!("write {}", sig_path.display()))?; + println!("signed: {}", sig_path.display()); + Ok(()) +} + +fn verify_package(dir: &Path, pub_key_file: &Path) -> anyhow::Result { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let pub_hex = std::fs::read_to_string(pub_key_file) + .with_context(|| format!("read {}", pub_key_file.display()))?; + let pub_bytes: [u8; 32] = hex::decode(pub_hex.trim()) + .context("decode public key: expected 64 hex chars")? + .try_into() + .map_err(|_| anyhow::anyhow!("public key must be 32 bytes"))?; + let verifying_key = VerifyingKey::from_bytes(&pub_bytes).context("invalid public key bytes")?; + let sig_path = dir.join("signature.sig"); + let sig_hex = std::fs::read_to_string(&sig_path) + .with_context(|| format!("read {}", sig_path.display()))?; + let sig_bytes: [u8; 64] = hex::decode(sig_hex.trim()) + .context("decode signature: expected 128 hex chars")? + .try_into() + .map_err(|_| anyhow::anyhow!("signature must be 64 bytes"))?; + let signature = Signature::from_bytes(&sig_bytes); + let hash = canonical_bundle_hash(dir)?; + Ok(verifying_key.verify(&hash, &signature).is_ok()) +} + #[cfg(test)] mod tests { use super::*; @@ -539,6 +678,66 @@ entry = "ui/index.html" let _ = fs::remove_dir_all(&tmp); } + #[test] + fn sign_and_verify_roundtrip() { + use std::fs; + let id = format!("sign_verify_{}", std::process::id()); + let dir = std::env::temp_dir().join(&id); + let key_dir = std::env::temp_dir().join(format!("{id}_keys")); + let ui = dir.join("ui"); + let _ = fs::create_dir_all(&ui); + fs::write(dir.join("app.wasm"), b"\0asm\x01\0\0\0").unwrap(); + fs::write(ui.join("index.html"), b"").unwrap(); + fs::write( + dir.join("wapp.toml"), + "[package]\nid = \"com.example.signed\"\nname = \"S\"\nversion = \"1.0.0\"\n\n\ + [runtime]\nmodule = \"app.wasm\"\n\n[ui]\nentry = \"ui/index.html\"\n", + ) + .unwrap(); + + generate_key(&key_dir).unwrap(); + let key_file = key_dir.join("weft-sign.key"); + let pub_file = key_dir.join("weft-sign.pub"); + + sign_package(&dir, &key_file).unwrap(); + assert!(dir.join("signature.sig").exists()); + + let ok = verify_package(&dir, &pub_file).unwrap(); + assert!(ok, "signature should verify"); + + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&key_dir); + } + + #[test] + fn verify_rejects_tampered_bundle() { + use std::fs; + let id = format!("tamper_{}", std::process::id()); + let dir = std::env::temp_dir().join(&id); + let key_dir = std::env::temp_dir().join(format!("{id}_keys")); + let ui = dir.join("ui"); + let _ = fs::create_dir_all(&ui); + fs::write(dir.join("app.wasm"), b"\0asm\x01\0\0\0").unwrap(); + fs::write(ui.join("index.html"), b"").unwrap(); + fs::write( + dir.join("wapp.toml"), + "[package]\nid = \"com.example.tamper\"\nname = \"T\"\nversion = \"1.0.0\"\n\n\ + [runtime]\nmodule = \"app.wasm\"\n\n[ui]\nentry = \"ui/index.html\"\n", + ) + .unwrap(); + + generate_key(&key_dir).unwrap(); + sign_package(&dir, &key_dir.join("weft-sign.key")).unwrap(); + + fs::write(dir.join("app.wasm"), b"\0asm\x01\0\0\x01").unwrap(); + + let ok = verify_package(&dir, &key_dir.join("weft-sign.pub")).unwrap(); + assert!(!ok, "tampered bundle should not verify"); + + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&key_dir); + } + #[test] fn list_installed_roots_uses_weft_app_store_when_set() { let prior = std::env::var("WEFT_APP_STORE").ok();