use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
pub const RELEASE_MANIFEST_DOMAIN: &str = "wm-release-manifest/v1";
pub const KEY_LINEAGE_MESH_ERA: &str = "wm-record-attestation/v1-era (WM_MESH_KEY hex-decoded)";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReleaseArtifact {
pub name: String,
pub sha256_hex: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReleaseManifest {
pub domain: String,
pub package_version: String,
pub git_sha: String,
pub surface_hash: String,
pub artifacts: Vec<ReleaseArtifact>,
pub key_lineage: String,
pub public_key_hex: String,
pub signature_hex: String,
}
impl ReleaseManifest {
#[must_use]
pub fn new(
package_version: &str,
git_sha: &str,
surface_hash: &str,
artifacts: Vec<ReleaseArtifact>,
key_lineage: &str,
) -> Self {
let mut artifacts = artifacts;
artifacts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
domain: RELEASE_MANIFEST_DOMAIN.to_string(),
package_version: package_version.to_string(),
git_sha: git_sha.to_string(),
surface_hash: surface_hash.to_string(),
artifacts,
key_lineage: key_lineage.to_string(),
public_key_hex: String::new(),
signature_hex: String::new(),
}
}
}
#[must_use]
pub fn release_payload(m: &ReleaseManifest) -> String {
let mut out = format!(
"{}|{}|{}|{}|{}|{}",
RELEASE_MANIFEST_DOMAIN,
m.package_version,
m.git_sha,
m.surface_hash,
m.key_lineage,
m.artifacts.len()
);
for a in &m.artifacts {
out.push('|');
out.push_str(&a.name);
out.push(':');
out.push_str(&a.sha256_hex);
out.push(':');
out.push_str(&a.bytes.to_string());
}
out
}
#[must_use]
pub fn sign_release(m: &ReleaseManifest, secret_hex: &str) -> Option<ReleaseManifest> {
let secret = decode_key_hex(secret_hex.trim())?;
let signing = SigningKey::from_bytes(&secret);
let sig = signing.sign(release_payload(m).as_bytes());
Some(ReleaseManifest {
public_key_hex: hex_of(&signing.verifying_key().to_bytes()),
signature_hex: hex_of(&sig.to_bytes()),
..m.clone()
})
}
#[must_use]
pub fn verify_release(m: &ReleaseManifest) -> bool {
if m.domain != RELEASE_MANIFEST_DOMAIN {
return false;
}
if m.public_key_hex.is_empty() || m.signature_hex.is_empty() {
return false;
}
let payload = release_payload(m);
let (Some(pk_bytes), Some(sig_bytes)) = (
decode_key_hex(&m.public_key_hex),
decode_sig_hex(&m.signature_hex),
) else {
return false;
};
let Ok(pk) = VerifyingKey::from_bytes(&pk_bytes) else {
return false;
};
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
pk.verify(payload.as_bytes(), &sig).is_ok()
}
fn decode_key_hex(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
out[i] = (hex_val(chunk[0])? << 4) | hex_val(chunk[1])?;
}
Some(out)
}
fn decode_sig_hex(hex: &str) -> Option<[u8; 64]> {
if hex.len() != 128 {
return None;
}
let mut out = [0u8; 64];
for (i, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
out[i] = (hex_val(chunk[0])? << 4) | hex_val(chunk[1])?;
}
Some(out)
}
const fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn hex_of(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn test_manifest() -> ReleaseManifest {
ReleaseManifest::new(
"9.0.0",
"caf9a36",
&"ab".repeat(32),
vec![
ReleaseArtifact {
name: "wm-x86_64-linux".to_string(),
sha256_hex: "cd".repeat(32),
bytes: 22_500_000,
},
ReleaseArtifact {
name: "wm-aarch64-macos".to_string(),
sha256_hex: "ef".repeat(32),
bytes: 21_000_000,
},
],
KEY_LINEAGE_MESH_ERA,
)
}
#[test]
fn sign_and_verify_roundtrip() {
let signed = sign_release(&test_manifest(), TEST_KEY_HEX).expect("test key signs");
assert_eq!(signed.public_key_hex.len(), 64);
assert_eq!(signed.signature_hex.len(), 128);
assert!(verify_release(&signed));
}
#[test]
fn tampered_artifact_hash_fails() {
let signed = sign_release(&test_manifest(), TEST_KEY_HEX).expect("test key signs");
let mut tampered = signed;
tampered.artifacts[0].sha256_hex = "00".repeat(32);
assert!(!verify_release(&tampered));
}
#[test]
fn tampered_version_or_pin_fails() {
let signed = sign_release(&test_manifest(), TEST_KEY_HEX).expect("test key signs");
let mut tampered = signed.clone();
tampered.package_version = "9.0.1".to_string();
assert!(!verify_release(&tampered));
tampered = signed;
tampered.surface_hash = "ff".repeat(32);
assert!(!verify_release(&tampered));
}
#[test]
fn unsigned_and_wrong_domain_never_verify() {
assert!(!verify_release(&test_manifest()));
let mut signed = sign_release(&test_manifest(), TEST_KEY_HEX).expect("test key signs");
signed.domain = "wm-record-attestation/v1".to_string();
assert!(
!verify_release(&signed),
"a release signature must never verify as another protocol's payload"
);
}
#[test]
fn bad_key_material_returns_none() {
assert!(sign_release(&test_manifest(), "").is_none());
assert!(sign_release(&test_manifest(), "xyz").is_none());
assert!(sign_release(&test_manifest(), &"ab".repeat(31)).is_none());
}
#[test]
fn artifact_order_normalized_for_stable_payload() {
let forward = test_manifest();
let mut backward_artifacts = forward.artifacts.clone();
backward_artifacts.reverse();
let backward = ReleaseManifest::new(
"9.0.0",
"caf9a36",
&"ab".repeat(32),
backward_artifacts,
KEY_LINEAGE_MESH_ERA,
);
assert_eq!(release_payload(&forward), release_payload(&backward));
}
#[test]
fn payload_vector_is_byte_stable() {
let p1 = release_payload(&test_manifest());
let p2 = release_payload(&test_manifest());
assert_eq!(p1, p2);
assert!(p1.starts_with("wm-release-manifest/v1|9.0.0|caf9a36|"));
}
}