use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{Result, VaultError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSignature {
pub signature: String,
pub public_key: String,
pub file_sha256: String,
pub signer: Option<String>,
pub signed_at: String,
pub version: u32,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SigningKeyPair {
pub secret_seed: String,
pub public_key: String,
pub identity: Option<String>,
pub created_at: String,
}
impl std::fmt::Debug for SigningKeyPair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SigningKeyPair")
.field("secret_seed", &"<redacted>")
.field("public_key", &self.public_key)
.field("identity", &self.identity)
.field("created_at", &self.created_at)
.finish()
}
}
pub struct ModelSigner;
const SIGNATURE_VERSION: u32 = 2;
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut padded = [0u8; BLOCK];
if key.len() > BLOCK {
padded[..32].copy_from_slice(&Sha256::digest(key));
} else {
padded[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= padded[i];
opad[i] ^= padded[i];
}
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(message);
let inner_hash = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_hash);
padded.fill(0);
ipad.fill(0);
opad.fill(0);
outer.finalize().into()
}
fn legacy_tag(seed: &[u8], file_hash: &[u8]) -> [u8; 32] {
let mut input = Vec::with_capacity(seed.len() + file_hash.len());
input.extend_from_slice(seed);
input.extend_from_slice(file_hash);
Sha256::digest(&input).into()
}
fn tags_equal(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
impl ModelSigner {
pub fn generate_keypair(identity: Option<&str>) -> Result<SigningKeyPair> {
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::aead::OsRng;
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let public = Sha256::digest(seed);
let kp = SigningKeyPair {
secret_seed: hex::encode(seed),
public_key: hex::encode(public),
identity: identity.map(String::from),
created_at: chrono::Utc::now().to_rfc3339(),
};
seed.fill(0);
Ok(kp)
}
pub fn save_keypair(keypair: &SigningKeyPair, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(keypair)?;
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
crate::permissions::set_create_mode(&mut options);
let mut file = options.open(path)?;
crate::permissions::restrict_file(path)?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
Ok(())
}
pub fn keypair_from_seed(seed_hex: &str, identity: Option<&str>) -> Result<SigningKeyPair> {
let seed_hex = seed_hex.trim();
let seed = hex::decode(seed_hex)
.map_err(|e| VaultError::InvalidInput(format!("Signing seed is not valid hex: {e}")))?;
if seed.len() != 32 {
return Err(VaultError::InvalidInput(format!(
"Signing seed must be 32 bytes ({} hex chars), got {}",
64,
seed.len()
)));
}
Ok(SigningKeyPair {
secret_seed: hex::encode(&seed),
public_key: hex::encode(Sha256::digest(&seed)),
identity: identity.map(String::from),
created_at: chrono::Utc::now().to_rfc3339(),
})
}
pub fn parse_keypair(data: &str, identity: Option<&str>) -> Result<SigningKeyPair> {
let trimmed = data.trim();
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed).map_err(Into::into);
}
Self::keypair_from_seed(trimmed, identity)
}
pub fn load_keypair(path: &Path) -> Result<SigningKeyPair> {
let data = fs::read_to_string(path)?;
let kp: SigningKeyPair = serde_json::from_str(&data)?;
Ok(kp)
}
pub fn sign(
keypair: &SigningKeyPair,
file_path: &Path,
metadata: HashMap<String, String>,
) -> Result<ModelSignature> {
let data = fs::read(file_path)?;
let file_hash = Sha256::digest(&data);
let file_sha256 = hex::encode(file_hash);
let seed_bytes = hex::decode(&keypair.secret_seed)
.map_err(|e| VaultError::CryptoError(format!("Invalid secret seed: {}", e)))?;
let signature_bytes = hmac_sha256(&seed_bytes, &file_hash);
Ok(ModelSignature {
signature: hex::encode(signature_bytes),
public_key: keypair.public_key.clone(),
file_sha256,
signer: keypair.identity.clone(),
signed_at: chrono::Utc::now().to_rfc3339(),
version: SIGNATURE_VERSION,
metadata,
})
}
pub fn verify(
signature: &ModelSignature,
file_path: &Path,
secret_seed: Option<&str>,
) -> Result<SignatureVerification> {
let data = fs::read(file_path)?;
let file_hash = Sha256::digest(&data);
let file_sha256 = hex::encode(file_hash);
if file_sha256 != signature.file_sha256 {
return Ok(SignatureVerification {
valid: false,
file_hash_match: false,
signature_match: false,
signature_checked: secret_seed.is_some(),
signer: signature.signer.clone(),
signed_at: signature.signed_at.clone(),
reason: Some("File SHA-256 does not match signed hash".to_string()),
});
}
let Some(seed_hex) = secret_seed else {
return Ok(SignatureVerification {
valid: false,
file_hash_match: true,
signature_match: false,
signature_checked: false,
signer: signature.signer.clone(),
signed_at: signature.signed_at.clone(),
reason: Some(
"No verification key was supplied, so authenticity was not checked. \
The file hash matched, but that hash is stored in the signature file \
itself and proves nothing about who produced it. Pass --key with the \
signing key (a file path or a KMS URI) to verify."
.to_string(),
),
});
};
let seed_bytes = hex::decode(seed_hex)
.map_err(|e| VaultError::CryptoError(format!("Invalid seed: {}", e)))?;
let expected = match signature.version {
0 | 1 => legacy_tag(&seed_bytes, &file_hash),
_ => hmac_sha256(&seed_bytes, &file_hash),
};
let signature_match = tags_equal(&hex::encode(expected), &signature.signature);
Ok(SignatureVerification {
valid: signature_match,
file_hash_match: true,
signature_match,
signature_checked: true,
signer: signature.signer.clone(),
signed_at: signature.signed_at.clone(),
reason: if signature_match {
None
} else {
Some("HMAC signature does not match".to_string())
},
})
}
pub fn save_signature(signature: &ModelSignature, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(signature)?;
fs::write(path, json)?;
Ok(())
}
pub fn load_signature(path: &Path) -> Result<ModelSignature> {
let data = fs::read_to_string(path)?;
let sig: ModelSignature = serde_json::from_str(&data)?;
Ok(sig)
}
}
#[derive(Debug, Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct SignatureVerification {
pub valid: bool,
pub file_hash_match: bool,
pub signature_match: bool,
pub signature_checked: bool,
pub signer: Option<String>,
pub signed_at: String,
pub reason: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_generate_keypair() {
let kp = ModelSigner::generate_keypair(Some("test-user")).unwrap();
assert_eq!(kp.secret_seed.len(), 64); assert_eq!(kp.public_key.len(), 64);
assert_eq!(kp.identity.as_deref(), Some("test-user"));
}
#[test]
fn test_sign_and_verify() {
let kp = ModelSigner::generate_keypair(Some("tester")).unwrap();
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"fake model data for testing").unwrap();
let sig = ModelSigner::sign(&kp, file.path(), HashMap::new()).unwrap();
assert!(!sig.signature.is_empty());
assert_eq!(sig.public_key, kp.public_key);
let result = ModelSigner::verify(&sig, file.path(), Some(&kp.secret_seed)).unwrap();
assert!(result.valid);
assert!(result.file_hash_match);
assert!(result.signature_match);
let result2 = ModelSigner::verify(&sig, file.path(), None).unwrap();
assert!(!result2.valid, "no key means no verification");
assert!(!result2.signature_checked);
assert!(result2.file_hash_match);
}
#[test]
fn test_forged_signature_without_key_is_not_reported_valid() {
let kp = ModelSigner::generate_keypair(Some("honest-signer")).unwrap();
let mut genuine = NamedTempFile::new().unwrap();
genuine.write_all(b"the real model").unwrap();
let real_sig = ModelSigner::sign(&kp, genuine.path(), HashMap::new()).unwrap();
let mut tampered = NamedTempFile::new().unwrap();
tampered.write_all(b"malicious payload").unwrap();
let tampered_hash = hex::encode(Sha256::digest(b"malicious payload"));
let forged = ModelSignature {
file_sha256: tampered_hash,
..real_sig.clone()
};
let unkeyed = ModelSigner::verify(&forged, tampered.path(), None).unwrap();
assert!(
!unkeyed.valid,
"a forged signature must never verify, least of all with no key"
);
assert!(
unkeyed.file_hash_match,
"the forged hash does match the file"
);
assert!(!unkeyed.signature_checked);
let keyed = ModelSigner::verify(&forged, tampered.path(), Some(&kp.secret_seed)).unwrap();
assert!(!keyed.valid);
assert!(!keyed.signature_match);
}
#[test]
fn test_wrong_key_does_not_verify() {
let signer = ModelSigner::generate_keypair(None).unwrap();
let attacker = ModelSigner::generate_keypair(None).unwrap();
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"model bytes").unwrap();
let sig = ModelSigner::sign(&signer, file.path(), HashMap::new()).unwrap();
let result = ModelSigner::verify(&sig, file.path(), Some(&attacker.secret_seed)).unwrap();
assert!(!result.valid);
assert!(result.signature_checked);
assert!(!result.signature_match);
}
#[test]
fn test_hmac_sha256_matches_rfc4231_vector() {
let key = [0x0bu8; 20];
let tag = hmac_sha256(&key, b"Hi There");
assert_eq!(
hex::encode(tag),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
);
}
#[test]
fn test_hmac_sha256_handles_oversized_keys() {
let key = [0xaau8; 131];
let tag = hmac_sha256(
&key,
b"Test Using Larger Than Block-Size Key - Hash Key First",
);
assert_eq!(
hex::encode(tag),
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
);
}
#[test]
fn test_version_1_signatures_still_verify() {
let kp = ModelSigner::generate_keypair(None).unwrap();
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"legacy model").unwrap();
let file_hash = Sha256::digest(b"legacy model");
let seed = hex::decode(&kp.secret_seed).unwrap();
let legacy = ModelSignature {
signature: hex::encode(legacy_tag(&seed, &file_hash)),
public_key: kp.public_key.clone(),
file_sha256: hex::encode(file_hash),
signer: None,
signed_at: "2026-01-01T00:00:00Z".to_string(),
version: 1,
metadata: HashMap::new(),
};
let result = ModelSigner::verify(&legacy, file.path(), Some(&kp.secret_seed)).unwrap();
assert!(result.valid, "existing v1 signatures must keep verifying");
let fresh = ModelSigner::sign(&kp, file.path(), HashMap::new()).unwrap();
assert_eq!(fresh.version, 2);
assert_ne!(
fresh.signature, legacy.signature,
"v2 must not reproduce the v1 tag"
);
}
#[test]
fn test_tag_comparison_rejects_length_and_content_mismatch() {
assert!(tags_equal("abcd", "abcd"));
assert!(!tags_equal("abcd", "abce"));
assert!(!tags_equal("abcd", "abc"));
assert!(!tags_equal("", "a"));
assert!(tags_equal("", ""));
}
#[test]
fn test_verify_tampered_file() {
let kp = ModelSigner::generate_keypair(None).unwrap();
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"original data").unwrap();
let sig = ModelSigner::sign(&kp, file.path(), HashMap::new()).unwrap();
file.as_file().set_len(0).unwrap();
file.write_all(b"tampered data").unwrap();
let result = ModelSigner::verify(&sig, file.path(), Some(&kp.secret_seed)).unwrap();
assert!(!result.valid);
assert!(!result.file_hash_match);
}
#[test]
fn test_save_load_keypair() {
let kp = ModelSigner::generate_keypair(Some("test")).unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.key");
ModelSigner::save_keypair(&kp, &path).unwrap();
let loaded = ModelSigner::load_keypair(&path).unwrap();
assert_eq!(loaded.secret_seed, kp.secret_seed);
assert_eq!(loaded.public_key, kp.public_key);
}
#[test]
fn test_save_load_signature() {
let kp = ModelSigner::generate_keypair(None).unwrap();
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"model bytes").unwrap();
let sig = ModelSigner::sign(&kp, file.path(), HashMap::new()).unwrap();
let dir = tempfile::tempdir().unwrap();
let sig_path = dir.path().join("model.sig");
ModelSigner::save_signature(&sig, &sig_path).unwrap();
let loaded = ModelSigner::load_signature(&sig_path).unwrap();
assert_eq!(loaded.signature, sig.signature);
assert_eq!(loaded.file_sha256, sig.file_sha256);
}
#[test]
fn test_keypair_from_seed_roundtrip() {
let original = ModelSigner::generate_keypair(Some("alice")).unwrap();
let rebuilt = ModelSigner::keypair_from_seed(&original.secret_seed, Some("alice")).unwrap();
assert_eq!(rebuilt.secret_seed, original.secret_seed);
assert_eq!(rebuilt.public_key, original.public_key);
}
#[test]
fn test_keypair_from_seed_rejects_bad_input() {
assert!(ModelSigner::keypair_from_seed("not-hex", None).is_err());
assert!(ModelSigner::keypair_from_seed(&"ab".repeat(16), None).is_err());
}
#[test]
fn test_parse_keypair_accepts_both_shapes() {
let kp = ModelSigner::generate_keypair(Some("bob")).unwrap();
let as_json = serde_json::to_string(&kp).unwrap();
let from_json = ModelSigner::parse_keypair(&as_json, None).unwrap();
assert_eq!(from_json.public_key, kp.public_key);
assert_eq!(from_json.identity.as_deref(), Some("bob"));
let from_seed = ModelSigner::parse_keypair(&kp.secret_seed, Some("bob")).unwrap();
assert_eq!(from_seed.public_key, kp.public_key);
assert_eq!(from_seed.identity.as_deref(), Some("bob"));
}
#[test]
fn test_seed_sourced_key_produces_verifiable_signature() {
let kp = ModelSigner::generate_keypair(None).unwrap();
let rebuilt = ModelSigner::keypair_from_seed(&kp.secret_seed, None).unwrap();
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"payload signed with a KMS-sourced key")
.unwrap();
let sig = ModelSigner::sign(&rebuilt, file.path(), HashMap::new()).unwrap();
let result = ModelSigner::verify(&sig, file.path(), Some(&kp.secret_seed)).unwrap();
assert!(
result.valid,
"seed-sourced key must verify against the original"
);
}
#[test]
fn test_keypair_debug_redacts_the_secret_seed() {
let kp = ModelSigner::generate_keypair(Some("ML Team")).unwrap();
let rendered = format!("{kp:?}");
assert!(
!rendered.contains(&kp.secret_seed),
"Debug output leaked the secret seed: {rendered}"
);
assert!(rendered.contains("<redacted>"), "got: {rendered}");
assert!(rendered.contains(&kp.public_key), "got: {rendered}");
assert!(rendered.contains("ML Team"), "got: {rendered}");
}
#[test]
fn test_saved_keypair_is_not_world_readable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("signing_key.json");
let kp = ModelSigner::generate_keypair(None).unwrap();
ModelSigner::save_keypair(&kp, &path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(on_disk.contains(&kp.secret_seed));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o077,
0,
"group/other bits set on the signing key: {:o}",
mode
);
}
#[cfg(windows)]
{
let out = std::process::Command::new("icacls")
.arg(&path)
.output()
.expect("icacls should be available on Windows");
let text = String::from_utf8_lossy(&out.stdout);
assert!(
!text.contains("(I)"),
"signing key still has inherited ACLs: {text}"
);
}
}
}