huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "release-manifest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Shared release-manifest type for the signed release-channel pull pipeline.
|
||||
//!
|
||||
//! The publisher tool (`crates/release-tool`) builds a [`ReleaseManifest`],
|
||||
//! serializes it to canonical bytes, signs those bytes with the channel's
|
||||
//! Ed25519 private key, and publishes the resulting [`SignedManifest`] as
|
||||
//! `manifest.json` on the release channel. The gateway (`huskies-server`)
|
||||
//! fetches that file, re-serializes the embedded manifest with
|
||||
//! [`ReleaseManifest::canonical_bytes`], and verifies the signature against
|
||||
//! its pinned public key before trusting anything in it.
|
||||
//!
|
||||
//! Keeping the type in its own dependency-light crate lets both sides agree
|
||||
//! on the exact byte representation to sign/verify without the server crate
|
||||
//! ever linking signing code, and without the publisher tool depending on
|
||||
//! the full `huskies` server crate.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The signed payload describing one published release artifact.
|
||||
///
|
||||
/// Field order is significant: [`ReleaseManifest::canonical_bytes`] relies on
|
||||
/// `serde_json`'s struct serialization preserving declaration order, so the
|
||||
/// signer and verifier always agree on the exact bytes being signed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReleaseManifest {
|
||||
/// Filename of the artifact within the channel (e.g. `huskies-linux-arm64`).
|
||||
pub artifact: String,
|
||||
/// Lowercase hex sha256 digest of the artifact's bytes.
|
||||
pub sha256: String,
|
||||
/// Version identifier — the short git commit hash the artifact was built from.
|
||||
pub version: String,
|
||||
/// Release channel name this manifest was signed for (e.g. `stable`).
|
||||
pub channel: String,
|
||||
/// Unix timestamp (seconds) the manifest was signed at.
|
||||
///
|
||||
/// Used for rollback/replay detection: a pull refuses any manifest whose
|
||||
/// timestamp is not strictly newer than the currently installed one.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl ReleaseManifest {
|
||||
/// Serialize this manifest deterministically for signing and verification.
|
||||
///
|
||||
/// Both the publisher and the gateway construct this independently from
|
||||
/// their own in-memory `ReleaseManifest` value — the manifest.json file's
|
||||
/// exact on-disk byte layout is never itself the signed payload.
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
serde_json::to_vec(self).expect("ReleaseManifest serialization cannot fail")
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`ReleaseManifest`] plus its Ed25519 signature (lowercase hex), as
|
||||
/// published to a release channel's `manifest.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedManifest {
|
||||
/// The manifest describing the published artifact.
|
||||
pub manifest: ReleaseManifest,
|
||||
/// Hex-encoded Ed25519 signature over `manifest.canonical_bytes()`.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> ReleaseManifest {
|
||||
ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256: "a".repeat(64),
|
||||
version: "abc1234".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 1_700_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_is_deterministic() {
|
||||
let m = sample();
|
||||
assert_eq!(m.canonical_bytes(), m.canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_changes_with_any_field() {
|
||||
let m1 = sample();
|
||||
let mut m2 = sample();
|
||||
m2.timestamp += 1;
|
||||
assert_ne!(m1.canonical_bytes(), m2.canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_manifest_roundtrips_through_json() {
|
||||
let signed = SignedManifest {
|
||||
manifest: sample(),
|
||||
signature: "deadbeef".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&signed).unwrap();
|
||||
let parsed: SignedManifest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.manifest, signed.manifest);
|
||||
assert_eq!(parsed.signature, signed.signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_missing_field_fails_to_parse() {
|
||||
let bad = serde_json::json!({
|
||||
"artifact": "huskies-linux-arm64",
|
||||
"sha256": "a".repeat(64),
|
||||
"version": "abc1234",
|
||||
"channel": "stable"
|
||||
// timestamp missing
|
||||
});
|
||||
let result: Result<ReleaseManifest, _> = serde_json::from_value(bad);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"manifest missing a field must fail to parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_manifest_missing_signature_fails_to_parse() {
|
||||
let bad = serde_json::json!({ "manifest": sample() });
|
||||
let result: Result<SignedManifest, _> = serde_json::from_value(bad);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"signed manifest missing signature must fail to parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user