1#[derive(Debug, Clone)]
11pub struct ManifestSignature {
12 pub content_hash: String,
14 pub signature_hex: String,
16 pub signer: Option<String>,
18}
19
20#[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#[cfg(feature = "native")]
46pub fn verify_manifest(
47 manifest_content: &str,
48 signature: &ManifestSignature,
49 verifying_key: &pacha::signing::VerifyingKey,
50) -> Result<(), ManifestVerifyError> {
51 let content_hash = blake3::hash(manifest_content.as_bytes());
53 let hash_hex = content_hash.to_hex().to_string();
54
55 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 let sig = pacha::signing::Signature::from_hex(&signature.signature_hex)
65 .map_err(|e| ManifestVerifyError::InvalidSignature(format!("{e}")))?;
66
67 verifying_key
69 .verify(hash_hex.as_bytes(), &sig)
70 .map_err(|e| ManifestVerifyError::SignatureFailed(format!("{e}")))
71}
72
73#[derive(Debug, Clone)]
75pub enum ManifestVerifyError {
76 HashMismatch {
78 expected: String,
80 actual: String,
82 },
83 InvalidSignature(String),
85 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
109pub 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
122pub 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}