1use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ring::signature::{self, Ed25519KeyPair, KeyPair};
11
12use super::error::PluginError;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum SignatureMode {
17 Strict,
19 Permissive,
21 #[default]
23 Disabled,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum VerificationResult {
29 Valid { publisher_key: String },
31 Unsigned,
33 Untrusted,
35 Invalid { reason: String },
37}
38
39impl VerificationResult {
40 pub fn is_valid(&self) -> bool {
42 matches!(self, Self::Valid { .. })
43 }
44}
45
46fn b64u_encode(data: &[u8]) -> String {
49 URL_SAFE_NO_PAD.encode(data)
50}
51
52fn b64u_decode(s: &str) -> Result<Vec<u8>, PluginError> {
53 URL_SAFE_NO_PAD
54 .decode(s)
55 .map_err(|e| PluginError::SignatureInvalid(format!("base64url decode error: {e}")))
56}
57
58fn hex_decode(s: &str) -> Result<Vec<u8>, PluginError> {
61 let s = s.trim();
63 if !s.len().is_multiple_of(2) {
64 return Err(PluginError::SignatureInvalid(
65 "hex string must have even length".into(),
66 ));
67 }
68 (0..s.len())
69 .step_by(2)
70 .map(|i| {
71 u8::from_str_radix(&s[i..i + 2], 16)
72 .map_err(|e| PluginError::SignatureInvalid(format!("hex decode: {e}")))
73 })
74 .collect()
75}
76
77fn hex_encode(data: &[u8]) -> String {
78 data.iter().map(|b| format!("{b:02x}")).collect()
79}
80
81pub fn canonical_manifest_bytes(manifest_toml: &str) -> Vec<u8> {
89 let mut lines: Vec<&str> = Vec::new();
90 for line in manifest_toml.lines() {
91 let trimmed = line.trim();
92 if trimmed.starts_with("signature") && trimmed.contains('=') {
93 continue;
94 }
95 if trimmed.starts_with("publisher_key") && trimmed.contains('=') {
96 continue;
97 }
98 lines.push(line);
99 }
100 while lines.last().is_some_and(|l| l.trim().is_empty()) {
102 lines.pop();
103 }
104 let canonical = lines.join("\n");
105 canonical.into_bytes()
106}
107
108pub fn sign_manifest(manifest_toml: &str, pkcs8_der: &[u8]) -> Result<String, PluginError> {
113 let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8_der)
114 .map_err(|e| PluginError::SignatureInvalid(format!("invalid signing key: {e}")))?;
115 let canonical = canonical_manifest_bytes(manifest_toml);
116 let sig = key_pair.sign(&canonical);
117 Ok(b64u_encode(sig.as_ref()))
118}
119
120pub fn public_key_hex(pkcs8_der: &[u8]) -> Result<String, PluginError> {
122 let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8_der)
123 .map_err(|e| PluginError::SignatureInvalid(format!("invalid signing key: {e}")))?;
124 Ok(hex_encode(key_pair.public_key().as_ref()))
125}
126
127pub fn verify_manifest(
137 manifest_toml: &str,
138 signature_b64: &str,
139 publisher_key_hex: &str,
140 trusted_keys: &[String],
141) -> VerificationResult {
142 let normalized_key = publisher_key_hex.trim().to_lowercase();
144 let is_trusted = trusted_keys
145 .iter()
146 .any(|k| k.trim().to_lowercase() == normalized_key);
147
148 if !is_trusted {
149 return VerificationResult::Untrusted;
150 }
151
152 let pub_key_bytes = match hex_decode(publisher_key_hex) {
154 Ok(bytes) => bytes,
155 Err(e) => {
156 return VerificationResult::Invalid {
157 reason: format!("invalid publisher key: {e}"),
158 };
159 }
160 };
161
162 let sig_bytes = match b64u_decode(signature_b64) {
164 Ok(bytes) => bytes,
165 Err(e) => {
166 return VerificationResult::Invalid {
167 reason: format!("invalid signature encoding: {e}"),
168 };
169 }
170 };
171
172 let canonical = canonical_manifest_bytes(manifest_toml);
174
175 let peer_public_key = signature::UnparsedPublicKey::new(&signature::ED25519, &pub_key_bytes);
177 match peer_public_key.verify(&canonical, &sig_bytes) {
178 Ok(()) => VerificationResult::Valid {
179 publisher_key: normalized_key,
180 },
181 Err(_) => VerificationResult::Invalid {
182 reason: "Ed25519 signature verification failed".into(),
183 },
184 }
185}
186
187pub fn enforce_signature_policy(
192 plugin_name: &str,
193 manifest_toml: &str,
194 signature: Option<&str>,
195 publisher_key: Option<&str>,
196 trusted_keys: &[String],
197 mode: SignatureMode,
198) -> Result<VerificationResult, PluginError> {
199 if mode == SignatureMode::Disabled {
200 return Ok(VerificationResult::Unsigned);
201 }
202
203 match (signature, publisher_key) {
204 (None, _) | (_, None) => {
205 match mode {
207 SignatureMode::Strict => Err(PluginError::UnsignedPlugin(plugin_name.to_string())),
208 SignatureMode::Permissive => {
209 ::zeroclaw_log::record!(
210 WARN,
211 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
212 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
213 .with_attrs(::serde_json::json!({"plugin": plugin_name})),
214 "plugin is unsigned; loading in permissive mode"
215 );
216 Ok(VerificationResult::Unsigned)
217 }
218 SignatureMode::Disabled => Ok(VerificationResult::Unsigned),
219 }
220 }
221 (Some(sig), Some(pub_key)) => {
222 let result = verify_manifest(manifest_toml, sig, pub_key, trusted_keys);
223 match &result {
224 VerificationResult::Valid { publisher_key } => {
225 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"plugin": plugin_name, "publisher_key": publisher_key.as_str()})), "plugin signature verified");
226 Ok(result)
227 }
228 VerificationResult::Untrusted => match mode {
229 SignatureMode::Strict => Err(PluginError::UntrustedPublisher {
230 plugin: plugin_name.to_string(),
231 publisher_key: pub_key.to_string(),
232 }),
233 SignatureMode::Permissive => {
234 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"plugin": plugin_name, "publisher_key": pub_key})), "plugin publisher key not trusted; loading in permissive mode");
235 Ok(result)
236 }
237 SignatureMode::Disabled => Ok(result),
238 },
239 VerificationResult::Invalid { reason } => match mode {
240 SignatureMode::Strict => Err(PluginError::SignatureInvalid(format!(
241 "plugin '{}': {}",
242 plugin_name, reason
243 ))),
244 SignatureMode::Permissive => {
245 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"plugin": plugin_name, "reason": reason.as_str()})), "plugin signature invalid; loading in permissive mode");
246 Ok(result)
247 }
248 SignatureMode::Disabled => Ok(result),
249 },
250 VerificationResult::Unsigned => Ok(result),
251 }
252 }
253 }
254}
255
256pub fn generate_signing_key() -> Result<(Vec<u8>, String), PluginError> {
261 let rng = ring::rand::SystemRandom::new();
262 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
263 .map_err(|e| PluginError::SignatureInvalid(format!("keygen failed: {e}")))?;
264 let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
265 .map_err(|e| PluginError::SignatureInvalid(format!("parse pkcs8: {e}")))?;
266 let pub_hex = hex_encode(key_pair.public_key().as_ref());
267 Ok((pkcs8.as_ref().to_vec(), pub_hex))
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 const TEST_MANIFEST: &str = r#"
275name = "test-plugin"
276version = "0.1.0"
277description = "A test plugin"
278wasm_path = "plugin.wasm"
279capabilities = ["tool"]
280permissions = []
281"#;
282
283 fn generate_test_keypair() -> (Vec<u8>, String) {
284 generate_signing_key().expect("keygen should succeed")
285 }
286
287 #[test]
288 fn test_canonical_manifest_strips_signature_fields() {
289 let manifest_with_sig = r#"
290name = "test-plugin"
291version = "0.1.0"
292signature = "abc123"
293publisher_key = "deadbeef"
294wasm_path = "plugin.wasm"
295capabilities = ["tool"]
296"#;
297 let canonical = canonical_manifest_bytes(manifest_with_sig);
298 let canonical_str = String::from_utf8(canonical).unwrap();
299 assert!(!canonical_str.contains("signature"));
300 assert!(!canonical_str.contains("publisher_key"));
301 assert!(canonical_str.contains("name = \"test-plugin\""));
302 assert!(canonical_str.contains("wasm_path = \"plugin.wasm\""));
303 }
304
305 #[test]
306 fn test_canonical_manifest_without_signature_fields() {
307 let canonical = canonical_manifest_bytes(TEST_MANIFEST);
308 let canonical_str = String::from_utf8(canonical).unwrap();
309 assert!(canonical_str.contains("name = \"test-plugin\""));
310 }
311
312 #[test]
313 fn test_sign_and_verify_roundtrip() {
314 let (pkcs8, pub_hex) = generate_test_keypair();
315 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
316 let trusted_keys = vec![pub_hex.clone()];
317 let result = verify_manifest(TEST_MANIFEST, &sig, &pub_hex, &trusted_keys);
318 assert!(result.is_valid());
319 assert_eq!(
320 result,
321 VerificationResult::Valid {
322 publisher_key: pub_hex.to_lowercase()
323 }
324 );
325 }
326
327 #[test]
328 fn test_verify_rejects_tampered_manifest() {
329 let (pkcs8, pub_hex) = generate_test_keypair();
330 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
331 let tampered = TEST_MANIFEST.replace("0.1.0", "0.2.0");
332 let trusted_keys = vec![pub_hex.clone()];
333 let result = verify_manifest(&tampered, &sig, &pub_hex, &trusted_keys);
334 assert!(matches!(result, VerificationResult::Invalid { .. }));
335 }
336
337 #[test]
338 fn test_verify_rejects_wrong_key() {
339 let (pkcs8, _pub_hex) = generate_test_keypair();
340 let (_pkcs8_2, pub_hex_2) = generate_test_keypair();
341 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
342 let trusted_keys = vec![pub_hex_2.clone()];
343 let result = verify_manifest(TEST_MANIFEST, &sig, &pub_hex_2, &trusted_keys);
344 assert!(matches!(result, VerificationResult::Invalid { .. }));
345 }
346
347 #[test]
348 fn test_verify_untrusted_publisher() {
349 let (pkcs8, pub_hex) = generate_test_keypair();
350 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
351 let trusted_keys: Vec<String> = vec![]; let result = verify_manifest(TEST_MANIFEST, &sig, &pub_hex, &trusted_keys);
353 assert_eq!(result, VerificationResult::Untrusted);
354 }
355
356 #[test]
357 fn test_public_key_hex_matches_generate() {
358 let (pkcs8, pub_hex) = generate_test_keypair();
359 let derived_hex = public_key_hex(&pkcs8).unwrap();
360 assert_eq!(pub_hex, derived_hex);
361 }
362
363 #[test]
364 fn test_hex_roundtrip() {
365 let data = vec![0xDE, 0xAD, 0xBE, 0xEF];
366 let encoded = hex_encode(&data);
367 assert_eq!(encoded, "deadbeef");
368 let decoded = hex_decode(&encoded).unwrap();
369 assert_eq!(decoded, data);
370 }
371
372 #[test]
373 fn test_enforce_policy_disabled_mode() {
374 let result = enforce_signature_policy(
375 "test",
376 TEST_MANIFEST,
377 None,
378 None,
379 &[],
380 SignatureMode::Disabled,
381 )
382 .unwrap();
383 assert_eq!(result, VerificationResult::Unsigned);
384 }
385
386 #[test]
387 fn test_enforce_policy_strict_rejects_unsigned() {
388 let err = enforce_signature_policy(
389 "test",
390 TEST_MANIFEST,
391 None,
392 None,
393 &[],
394 SignatureMode::Strict,
395 )
396 .unwrap_err();
397 assert!(matches!(err, PluginError::UnsignedPlugin(_)));
398 }
399
400 #[test]
401 fn test_enforce_policy_permissive_allows_unsigned() {
402 let result = enforce_signature_policy(
403 "test",
404 TEST_MANIFEST,
405 None,
406 None,
407 &[],
408 SignatureMode::Permissive,
409 )
410 .unwrap();
411 assert_eq!(result, VerificationResult::Unsigned);
412 }
413
414 #[test]
415 fn test_enforce_policy_strict_rejects_untrusted() {
416 let (pkcs8, pub_hex) = generate_test_keypair();
417 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
418 let err = enforce_signature_policy(
419 "test",
420 TEST_MANIFEST,
421 Some(&sig),
422 Some(&pub_hex),
423 &[], SignatureMode::Strict,
425 )
426 .unwrap_err();
427 assert!(matches!(err, PluginError::UntrustedPublisher { .. }));
428 }
429
430 #[test]
431 fn test_enforce_policy_strict_accepts_valid_signature() {
432 let (pkcs8, pub_hex) = generate_test_keypair();
433 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
434 let trusted_keys = vec![pub_hex.clone()];
435 let result = enforce_signature_policy(
436 "test",
437 TEST_MANIFEST,
438 Some(&sig),
439 Some(&pub_hex),
440 &trusted_keys,
441 SignatureMode::Strict,
442 )
443 .unwrap();
444 assert!(result.is_valid());
445 }
446
447 #[test]
448 fn test_enforce_policy_strict_rejects_invalid_signature() {
449 let (pkcs8, pub_hex) = generate_test_keypair();
450 let _sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
451 let trusted_keys = vec![pub_hex.clone()];
452 let err = enforce_signature_policy(
453 "test",
454 TEST_MANIFEST,
455 Some("badsignature"),
456 Some(&pub_hex),
457 &trusted_keys,
458 SignatureMode::Strict,
459 )
460 .unwrap_err();
461 assert!(matches!(err, PluginError::SignatureInvalid(_)));
462 }
463
464 #[test]
465 fn test_signature_mode_default_is_disabled() {
466 assert_eq!(SignatureMode::default(), SignatureMode::Disabled);
467 }
468
469 #[test]
470 fn test_manifest_with_signature_fields_verifies() {
471 let (pkcs8, pub_hex) = generate_test_keypair();
472 let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
474
475 let manifest_with_sig = format!(
477 r#"
478name = "test-plugin"
479version = "0.1.0"
480description = "A test plugin"
481signature = "{sig}"
482publisher_key = "{pub_hex}"
483wasm_path = "plugin.wasm"
484capabilities = ["tool"]
485permissions = []
486"#
487 );
488
489 let trusted_keys = vec![pub_hex.clone()];
491 let result = verify_manifest(&manifest_with_sig, &sig, &pub_hex, &trusted_keys);
492 assert!(result.is_valid());
493 }
494}