//! `release-tool` — offline publisher CLI for signed release channels. //! //! Generates a release-channel Ed25519 keypair and signs release manifests //! for a channel's `manifest.json`. This binary is intentionally its own //! crate, depending only on [`release_manifest`] and `ed25519-dalek` — it //! never links against the `huskies` server crate, so the running gateway //! has no code path that can read a channel's private signing key. Run this //! tool offline (or in a separate publish pipeline) and copy only the //! resulting public key hex into the gateway's `projects.toml`. //! //! Usage: //! ```text //! release-tool keygen //! release-tool sign --key --artifact --version --channel --out [--timestamp ] //! ``` use ed25519_dalek::{Signer, SigningKey}; use rand::Rng; use release_manifest::{ReleaseManifest, SignedManifest}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; fn main() { let args: Vec = std::env::args().collect(); let result = match args.get(1).map(String::as_str) { Some("keygen") => run_keygen(&args[2..]), Some("sign") => run_sign(&args[2..]), _ => Err( "usage: release-tool keygen | release-tool sign --key \ --artifact --version --channel --out [--timestamp ]" .to_string(), ), }; if let Err(e) = result { eprintln!("error: {e}"); std::process::exit(1); } } // ── keygen ─────────────────────────────────────────────────────────────────── fn run_keygen(args: &[String]) -> Result<(), String> { let key_path = args.first().ok_or("keygen requires a key-out-path")?; let signing_key = generate_signing_key(); write_seed_file(Path::new(key_path), &signing_key)?; let pubkey_hex = hex_encode(signing_key.verifying_key().as_bytes()); println!("Wrote private key seed to {key_path}"); println!("Pinned release public key (paste into projects.toml as `pubkey`):"); println!("{pubkey_hex}"); Ok(()) } fn generate_signing_key() -> SigningKey { let mut seed = [0u8; 32]; rand::rng().fill_bytes(&mut seed); SigningKey::from_bytes(&seed) } fn write_seed_file(path: &Path, signing_key: &SigningKey) -> Result<(), String> { if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent) .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; } #[cfg(unix)] { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path) .map_err(|e| format!("cannot create {}: {e}", path.display()))?; file.write_all(&signing_key.to_bytes()) .map_err(|e| format!("cannot write {}: {e}", path.display())) } #[cfg(not(unix))] { std::fs::write(path, signing_key.to_bytes()) .map_err(|e| format!("cannot write {}: {e}", path.display())) } } fn load_seed_file(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; let seed: [u8; 32] = bytes .try_into() .map_err(|_| format!("{} must contain exactly 32 bytes", path.display()))?; Ok(SigningKey::from_bytes(&seed)) } // ── sign ───────────────────────────────────────────────────────────────────── /// Parsed `sign` subcommand arguments. struct SignArgs { key: PathBuf, artifact: PathBuf, version: String, channel: String, out: PathBuf, timestamp: Option, } fn parse_sign_args(args: &[String]) -> Result { let mut key = None; let mut artifact = None; let mut version = None; let mut channel = None; let mut out = None; let mut timestamp = None; let mut i = 0; while i < args.len() { let flag = args[i].as_str(); let value = args .get(i + 1) .ok_or_else(|| format!("missing value for {flag}"))?; match flag { "--key" => key = Some(PathBuf::from(value)), "--artifact" => artifact = Some(PathBuf::from(value)), "--version" => version = Some(value.clone()), "--channel" => channel = Some(value.clone()), "--out" => out = Some(PathBuf::from(value)), "--timestamp" => { timestamp = Some( value .parse::() .map_err(|_| format!("--timestamp must be an integer, got `{value}`"))?, ) } other => return Err(format!("unknown flag `{other}`")), } i += 2; } Ok(SignArgs { key: key.ok_or("--key is required")?, artifact: artifact.ok_or("--artifact is required")?, version: version.ok_or("--version is required")?, channel: channel.ok_or("--channel is required")?, out: out.ok_or("--out is required")?, timestamp, }) } fn run_sign(args: &[String]) -> Result<(), String> { let parsed = parse_sign_args(args)?; let signing_key = load_seed_file(&parsed.key)?; let artifact_bytes = std::fs::read(&parsed.artifact) .map_err(|e| format!("cannot read {}: {e}", parsed.artifact.display()))?; let artifact_name = parsed .artifact .file_name() .and_then(|n| n.to_str()) .ok_or("--artifact path has no filename")? .to_string(); let timestamp = match parsed.timestamp { Some(t) => t, None => std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| format!("system clock before epoch: {e}"))? .as_secs() as i64, }; let signed = sign_manifest( &signing_key, artifact_name, &artifact_bytes, parsed.version, parsed.channel, timestamp, ); let json = serde_json::to_string_pretty(&signed).map_err(|e| format!("serialise manifest: {e}"))?; std::fs::write(&parsed.out, json) .map_err(|e| format!("cannot write {}: {e}", parsed.out.display()))?; println!("Signed manifest written to {}", parsed.out.display()); Ok(()) } /// Build and sign a [`SignedManifest`] for the given artifact bytes. /// /// Pure aside from the signature computation — split out from `run_sign` so /// tests can exercise it without touching the filesystem. fn sign_manifest( signing_key: &SigningKey, artifact: String, artifact_bytes: &[u8], version: String, channel: String, timestamp: i64, ) -> SignedManifest { let mut hasher = Sha256::new(); hasher.update(artifact_bytes); let sha256 = hex_encode(&hasher.finalize()); let manifest = ReleaseManifest { artifact, sha256, version, channel, timestamp, }; let signature = hex_encode(&signing_key.sign(&manifest.canonical_bytes()).to_bytes()); SignedManifest { manifest, signature, } } // ── helpers ────────────────────────────────────────────────────────────────── fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn keygen_then_sign_produces_verifiable_signature() { let tmp = tempfile::tempdir().unwrap(); let key_path = tmp.path().join("channel.key"); let signing_key = generate_signing_key(); write_seed_file(&key_path, &signing_key).unwrap(); let loaded = load_seed_file(&key_path).unwrap(); assert_eq!(loaded.verifying_key(), signing_key.verifying_key()); let signed = sign_manifest( &loaded, "huskies-linux-arm64".to_string(), b"fake binary contents", "abc1234".to_string(), "stable".to_string(), 1_700_000_000, ); // Verify with ed25519-dalek directly, mirroring how the gateway verifies. use ed25519_dalek::Verifier; let sig_bytes: [u8; 64] = hex_bytes(&signed.signature).try_into().unwrap(); let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); assert!( signing_key .verifying_key() .verify(&signed.manifest.canonical_bytes(), &sig) .is_ok(), "signature produced by sign_manifest must verify against the signing key's pubkey" ); } #[test] fn sign_manifest_hashes_artifact_bytes() { let signing_key = generate_signing_key(); let signed = sign_manifest( &signing_key, "art".to_string(), b"hello world", "v1".to_string(), "stable".to_string(), 1, ); let mut hasher = Sha256::new(); hasher.update(b"hello world"); let expected = hex_encode(&hasher.finalize()); assert_eq!(signed.manifest.sha256, expected); } #[test] fn parse_sign_args_rejects_missing_required_flag() { let args: Vec = vec!["--key".into(), "k".into()]; assert!(parse_sign_args(&args).is_err()); } #[test] fn parse_sign_args_accepts_all_flags() { let args: Vec = vec![ "--key".into(), "k".into(), "--artifact".into(), "a".into(), "--version".into(), "v1".into(), "--channel".into(), "stable".into(), "--out".into(), "o".into(), "--timestamp".into(), "42".into(), ]; let parsed = parse_sign_args(&args).unwrap(); assert_eq!(parsed.timestamp, Some(42)); assert_eq!(parsed.channel, "stable"); } fn hex_bytes(s: &str) -> Vec { (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) .collect() } }