huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store

This commit is contained in:
Huskies Agent
2026-07-16 14:03:31 +00:00
parent 1e0e581bd7
commit 77e0394195
23 changed files with 1643 additions and 7 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "release-tool"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "release-tool"
path = "src/main.rs"
[dependencies]
release-manifest = { path = "../release-manifest" }
ed25519-dalek = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+311
View File
@@ -0,0 +1,311 @@
//! `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 <key-out-path>
//! release-tool sign --key <path> --artifact <path> --version <str> --channel <str> --out <path> [--timestamp <unix-secs>]
//! ```
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<String> = 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 <key-out-path> | release-tool sign --key <path> \
--artifact <path> --version <str> --channel <str> --out <path> [--timestamp <unix-secs>]"
.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<SigningKey, String> {
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<i64>,
}
fn parse_sign_args(args: &[String]) -> Result<SignArgs, String> {
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::<i64>()
.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<String> = vec!["--key".into(), "k".into()];
assert!(parse_sign_args(&args).is_err());
}
#[test]
fn parse_sign_args_accepts_all_flags() {
let args: Vec<String> = 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<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
}