Skip to main content

zeroclaw_plugins/
signature.rs

1//! Ed25519 plugin signature verification.
2//!
3//! Uses `ring` (already a dependency) for Ed25519 signing and verification.
4//! Plugin manifests may include a base64url-encoded Ed25519 signature over
5//! the canonical manifest bytes (TOML content without the `signature` field).
6//! Publisher public keys are stored in the config as hex-encoded strings.
7
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ring::signature::{self, Ed25519KeyPair, KeyPair};
11
12use super::error::PluginError;
13
14/// Signature mode controls how unsigned/unverified plugins are handled.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum SignatureMode {
17    /// Reject plugins that are unsigned or fail verification.
18    Strict,
19    /// Warn but allow plugins that are unsigned or fail verification.
20    Permissive,
21    /// Do not check signatures at all.
22    #[default]
23    Disabled,
24}
25
26/// Result of verifying a plugin's signature.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum VerificationResult {
29    /// Signature is valid and matches a trusted publisher key.
30    Valid { publisher_key: String },
31    /// Plugin has no signature field.
32    Unsigned,
33    /// Signature is present but does not match any trusted key.
34    Untrusted,
35    /// Signature is present but cryptographically invalid.
36    Invalid { reason: String },
37}
38
39impl VerificationResult {
40    /// Returns true if the signature is valid.
41    pub fn is_valid(&self) -> bool {
42        matches!(self, Self::Valid { .. })
43    }
44}
45
46// ── Base64url helpers (reused from verifiable_intent but kept local to avoid coupling) ──
47
48fn 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
58// ── Hex helpers ──
59
60fn hex_decode(s: &str) -> Result<Vec<u8>, PluginError> {
61    // Simple hex decoder
62    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
81// ── Canonical manifest bytes ──
82
83/// Compute the canonical bytes of a manifest for signing/verification.
84///
85/// This strips the `signature` and `publisher_key` fields from the TOML content
86/// and returns the remaining bytes. The stripping is line-based: any line
87/// starting with `signature` or `publisher_key` followed by `=` is removed.
88pub 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    // Remove trailing empty lines to normalize
101    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
108// ── Signing ──
109
110/// Sign manifest bytes with an Ed25519 private key (PKCS#8 DER).
111/// Returns the base64url-encoded signature.
112pub 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
120/// Get the hex-encoded public key from a PKCS#8 Ed25519 private key.
121pub 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
127// ── Verification ──
128
129/// Verify a plugin manifest signature against a set of trusted publisher keys.
130///
131/// # Arguments
132/// - `manifest_toml`: The raw TOML content of the manifest file.
133/// - `signature_b64`: The base64url-encoded Ed25519 signature from the manifest.
134/// - `publisher_key_hex`: The hex-encoded publisher public key from the manifest.
135/// - `trusted_keys`: Set of hex-encoded trusted publisher public keys from config.
136pub fn verify_manifest(
137    manifest_toml: &str,
138    signature_b64: &str,
139    publisher_key_hex: &str,
140    trusted_keys: &[String],
141) -> VerificationResult {
142    // Check if the publisher key is in the trusted set
143    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    // Decode the public key
153    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    // Decode the signature
163    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    // Compute canonical bytes
173    let canonical = canonical_manifest_bytes(manifest_toml);
174
175    // Verify
176    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
187/// Check a manifest's signature and enforce the configured signature mode.
188///
189/// Returns `Ok(VerificationResult)` on success (or warning in permissive mode),
190/// or `Err(PluginError)` if the plugin should be rejected.
191pub 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            // Plugin is unsigned
206            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
256// ── Key Generation ──
257
258/// Generate a new Ed25519 key pair for plugin signing.
259/// Returns `(pkcs8_der_bytes, public_key_hex)`.
260pub 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![]; // no trusted keys
352        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            &[], // no trusted keys
424            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        // Sign the manifest without signature fields
473        let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
474
475        // Now create a manifest that includes the signature fields
476        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        // Verification should still work because canonical bytes strip sig fields
490        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}