Skip to main content

batuta/agent/
signing.rs

1//! Ed25519 manifest signing and verification.
2//!
3//! Computes BLAKE3 hash of manifest TOML content, signs with
4//! pacha Ed25519 key. Verification ensures manifest integrity
5//! before agent execution (Jidoka: stop on tampered manifest).
6//!
7//! Phase 3: Feature-gated under `native` (requires pacha + blake3).
8
9/// Manifest signature containing Ed25519 signature and BLAKE3 hash.
10#[derive(Debug, Clone)]
11pub struct ManifestSignature {
12    /// BLAKE3 hash of the manifest content (hex).
13    pub content_hash: String,
14    /// Ed25519 signature over the content hash (hex).
15    pub signature_hex: String,
16    /// Signer identity (optional label).
17    pub signer: Option<String>,
18}
19
20/// Sign a manifest TOML string with Ed25519.
21///
22/// Computes BLAKE3 hash of the content, then signs the hash
23/// with the provided pacha signing key.
24#[cfg(feature = "native")]
25pub fn sign_manifest(
26    manifest_content: &str,
27    signing_key: &pacha::signing::SigningKey,
28    signer: Option<&str>,
29) -> ManifestSignature {
30    let content_hash = blake3::hash(manifest_content.as_bytes());
31    let hash_hex = content_hash.to_hex().to_string();
32
33    let signature = signing_key.sign(hash_hex.as_bytes());
34
35    ManifestSignature {
36        content_hash: hash_hex,
37        signature_hex: signature.to_hex(),
38        signer: signer.map(String::from),
39    }
40}
41
42/// Verify a manifest signature against content and public key.
43///
44/// Returns Ok(()) if valid, or an error describing the failure.
45#[cfg(feature = "native")]
46pub fn verify_manifest(
47    manifest_content: &str,
48    signature: &ManifestSignature,
49    verifying_key: &pacha::signing::VerifyingKey,
50) -> Result<(), ManifestVerifyError> {
51    // Recompute content hash
52    let content_hash = blake3::hash(manifest_content.as_bytes());
53    let hash_hex = content_hash.to_hex().to_string();
54
55    // Check hash matches
56    if hash_hex != signature.content_hash {
57        return Err(ManifestVerifyError::HashMismatch {
58            expected: signature.content_hash.clone(),
59            actual: hash_hex,
60        });
61    }
62
63    // Decode signature from hex
64    let sig = pacha::signing::Signature::from_hex(&signature.signature_hex)
65        .map_err(|e| ManifestVerifyError::InvalidSignature(format!("{e}")))?;
66
67    // Verify Ed25519 signature over the hash
68    verifying_key
69        .verify(hash_hex.as_bytes(), &sig)
70        .map_err(|e| ManifestVerifyError::SignatureFailed(format!("{e}")))
71}
72
73/// Errors from manifest signature verification.
74#[derive(Debug, Clone)]
75pub enum ManifestVerifyError {
76    /// Content hash doesn't match (manifest was modified).
77    HashMismatch {
78        /// Hash from the signature file.
79        expected: String,
80        /// Hash of the actual content.
81        actual: String,
82    },
83    /// Signature bytes are malformed.
84    InvalidSignature(String),
85    /// Ed25519 signature doesn't match the content+key.
86    SignatureFailed(String),
87}
88
89impl std::fmt::Display for ManifestVerifyError {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::HashMismatch { expected, actual } => {
93                write!(
94                    f,
95                    "content hash mismatch: expected \
96                     {expected}, got {actual}"
97                )
98            }
99            Self::InvalidSignature(msg) => {
100                write!(f, "invalid signature: {msg}")
101            }
102            Self::SignatureFailed(msg) => {
103                write!(f, "signature verification failed: {msg}")
104            }
105        }
106    }
107}
108
109/// Serialize a manifest signature to TOML sidecar format.
110pub fn signature_to_toml(sig: &ManifestSignature) -> String {
111    use std::fmt::Write;
112    let mut out = String::new();
113    out.push_str("[signature]\n");
114    let _ = writeln!(out, "content_hash = \"{}\"", sig.content_hash);
115    let _ = writeln!(out, "signature = \"{}\"", sig.signature_hex);
116    if let Some(ref signer) = sig.signer {
117        let _ = writeln!(out, "signer = \"{signer}\"");
118    }
119    out
120}
121
122/// Parse a manifest signature from TOML sidecar content.
123pub fn signature_from_toml(toml_str: &str) -> Result<ManifestSignature, String> {
124    let table: toml::Value = toml::from_str(toml_str).map_err(|e| format!("TOML parse: {e}"))?;
125
126    let sig = table.get("signature").ok_or("missing [signature] section")?;
127
128    let content_hash =
129        sig.get("content_hash").and_then(|v| v.as_str()).ok_or("missing content_hash")?.to_string();
130
131    let signature_hex =
132        sig.get("signature").and_then(|v| v.as_str()).ok_or("missing signature")?.to_string();
133
134    let signer = sig.get("signer").and_then(|v| v.as_str()).map(String::from);
135
136    Ok(ManifestSignature { content_hash, signature_hex, signer })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    const TEST_MANIFEST: &str = r#"
144name = "test-agent"
145version = "0.1.0"
146
147[model]
148max_tokens = 4096
149
150[resources]
151max_iterations = 20
152"#;
153
154    #[test]
155    fn test_content_hash_deterministic() {
156        let h1 = blake3::hash(TEST_MANIFEST.as_bytes());
157        let h2 = blake3::hash(TEST_MANIFEST.as_bytes());
158        assert_eq!(h1, h2);
159    }
160
161    #[test]
162    fn test_content_hash_changes_on_modification() {
163        let h1 = blake3::hash(TEST_MANIFEST.as_bytes());
164        let modified = TEST_MANIFEST.replace("20", "30");
165        let h2 = blake3::hash(modified.as_bytes());
166        assert_ne!(h1, h2);
167    }
168
169    #[cfg(feature = "native")]
170    #[test]
171    fn test_sign_and_verify_roundtrip() {
172        let key = pacha::signing::SigningKey::generate();
173        let vk = key.verifying_key();
174
175        let sig = sign_manifest(TEST_MANIFEST, &key, Some("test"));
176        assert!(!sig.content_hash.is_empty());
177        assert!(!sig.signature_hex.is_empty());
178        assert_eq!(sig.signer, Some("test".into()));
179
180        let result = verify_manifest(TEST_MANIFEST, &sig, &vk);
181        assert!(result.is_ok(), "verification failed: {result:?}");
182    }
183
184    #[cfg(feature = "native")]
185    #[test]
186    fn test_verify_fails_on_tampered_content() {
187        let key = pacha::signing::SigningKey::generate();
188        let vk = key.verifying_key();
189
190        let sig = sign_manifest(TEST_MANIFEST, &key, None);
191        let tampered = TEST_MANIFEST.replace("20", "999");
192
193        let result = verify_manifest(&tampered, &sig, &vk);
194        assert!(result.is_err());
195        match result.unwrap_err() {
196            ManifestVerifyError::HashMismatch { .. } => {}
197            other => {
198                panic!("expected HashMismatch, got: {other}")
199            }
200        }
201    }
202
203    #[cfg(feature = "native")]
204    #[test]
205    fn test_verify_fails_with_wrong_key() {
206        let key1 = pacha::signing::SigningKey::generate();
207        let key2 = pacha::signing::SigningKey::generate();
208        let vk2 = key2.verifying_key();
209
210        let sig = sign_manifest(TEST_MANIFEST, &key1, None);
211
212        let result = verify_manifest(TEST_MANIFEST, &sig, &vk2);
213        assert!(result.is_err());
214        match result.unwrap_err() {
215            ManifestVerifyError::SignatureFailed(_) => {}
216            other => {
217                panic!("expected SignatureFailed, got: {other}")
218            }
219        }
220    }
221
222    #[test]
223    fn test_signature_toml_roundtrip() {
224        let sig = ManifestSignature {
225            content_hash: "abc123".into(),
226            signature_hex: "def456".into(),
227            signer: Some("alice".into()),
228        };
229
230        let toml_str = signature_to_toml(&sig);
231        assert!(toml_str.contains("abc123"));
232        assert!(toml_str.contains("def456"));
233
234        let parsed = signature_from_toml(&toml_str).expect("parse");
235        assert_eq!(parsed.content_hash, "abc123");
236        assert_eq!(parsed.signature_hex, "def456");
237        assert_eq!(parsed.signer, Some("alice".into()));
238    }
239
240    #[test]
241    fn test_signature_toml_no_signer() {
242        let sig = ManifestSignature {
243            content_hash: "hash".into(),
244            signature_hex: "sig".into(),
245            signer: None,
246        };
247
248        let toml_str = signature_to_toml(&sig);
249        assert!(!toml_str.contains("signer"));
250
251        let parsed = signature_from_toml(&toml_str).expect("parse");
252        assert!(parsed.signer.is_none());
253    }
254
255    #[test]
256    fn test_verify_error_display() {
257        let err = ManifestVerifyError::HashMismatch { expected: "a".into(), actual: "b".into() };
258        assert!(format!("{err}").contains("mismatch"));
259
260        let err = ManifestVerifyError::InvalidSignature("bad".into());
261        assert!(format!("{err}").contains("bad"));
262
263        let err = ManifestVerifyError::SignatureFailed("nope".into());
264        assert!(format!("{err}").contains("nope"));
265    }
266
267    #[test]
268    fn test_signature_from_toml_malformed() {
269        let result = signature_from_toml("not valid toml {{");
270        assert!(result.is_err());
271        assert!(result.unwrap_err().contains("TOML parse"));
272    }
273
274    #[test]
275    fn test_signature_from_toml_missing_section() {
276        let result = signature_from_toml("[other]\nkey = 1\n");
277        assert!(result.is_err());
278        assert!(result.unwrap_err().contains("missing [signature]"));
279    }
280
281    #[test]
282    fn test_signature_from_toml_missing_content_hash() {
283        let toml = r#"
284[signature]
285signature = "abc"
286"#;
287        let result = signature_from_toml(toml);
288        assert!(result.is_err());
289        assert!(result.unwrap_err().contains("missing content_hash"));
290    }
291
292    #[test]
293    fn test_signature_from_toml_missing_signature() {
294        let toml = r#"
295[signature]
296content_hash = "abc"
297"#;
298        let result = signature_from_toml(toml);
299        assert!(result.is_err());
300        assert!(result.unwrap_err().contains("missing signature"));
301    }
302
303    #[cfg(feature = "native")]
304    #[test]
305    fn test_verify_invalid_signature_hex() {
306        let key = pacha::signing::SigningKey::generate();
307        let vk = key.verifying_key();
308
309        let sig = ManifestSignature {
310            content_hash: blake3::hash(TEST_MANIFEST.as_bytes()).to_hex().to_string(),
311            signature_hex: "not-valid-hex!!".into(),
312            signer: None,
313        };
314
315        let result = verify_manifest(TEST_MANIFEST, &sig, &vk);
316        assert!(result.is_err());
317        match result.unwrap_err() {
318            ManifestVerifyError::InvalidSignature(msg) => {
319                assert!(!msg.is_empty());
320            }
321            other => {
322                panic!("expected InvalidSignature, got: {other}")
323            }
324        }
325    }
326
327    #[test]
328    fn test_signature_to_toml_content() {
329        let sig = ManifestSignature {
330            content_hash: "deadbeef".into(),
331            signature_hex: "cafebabe".into(),
332            signer: Some("bob".into()),
333        };
334        let toml = signature_to_toml(&sig);
335        assert!(toml.starts_with("[signature]\n"));
336        assert!(toml.contains(r#"content_hash = "deadbeef""#));
337        assert!(toml.contains(r#"signature = "cafebabe""#));
338        assert!(toml.contains(r#"signer = "bob""#));
339    }
340}