127 lines
4.7 KiB
Rust
127 lines
4.7 KiB
Rust
//! 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"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|