Skip to main content

mockforge_plugin_loader/
signature.rs

1//! Plugin signature verification
2//!
3//! This module provides cryptographic signature verification for plugins.
4//! Supports RSA and Ed25519 signatures using the ring cryptography library.
5
6use crate::{LoaderResult, PluginLoaderConfig, PluginLoaderError};
7use chrono::{DateTime, Utc};
8use ring::signature;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::Path;
12
13mod artifact;
14mod trust_store;
15pub use artifact::compute_artifact_payload;
16pub use trust_store::{resolve_trust_store_path, TrustStoreFile, TrustedKeyEntry};
17
18/// Signature algorithm types
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
20pub enum SignatureAlgorithm {
21    /// RSA PKCS#1 v1.5 with SHA-256 (2048-bit key)
22    #[serde(rename = "RSA_PKCS1_2048_SHA256")]
23    RsaPkcs1_2048Sha256,
24    /// RSA PKCS#1 v1.5 with SHA-256 (3072-bit key)
25    #[serde(rename = "RSA_PKCS1_3072_SHA256")]
26    RsaPkcs1_3072Sha256,
27    /// RSA PKCS#1 v1.5 with SHA-256 (4096-bit key)
28    #[serde(rename = "RSA_PKCS1_4096_SHA256")]
29    RsaPkcs1_4096SHA256,
30    /// Ed25519 signature scheme
31    #[serde(rename = "ED25519")]
32    Ed25519,
33}
34
35impl SignatureAlgorithm {
36    /// Convert to ring's verification algorithm
37    fn as_ring_algorithm(&self) -> &'static dyn signature::VerificationAlgorithm {
38        match self {
39            SignatureAlgorithm::RsaPkcs1_2048Sha256 => &signature::RSA_PKCS1_2048_8192_SHA256,
40            SignatureAlgorithm::RsaPkcs1_3072Sha256 => &signature::RSA_PKCS1_2048_8192_SHA256,
41            SignatureAlgorithm::RsaPkcs1_4096SHA256 => &signature::RSA_PKCS1_2048_8192_SHA256,
42            SignatureAlgorithm::Ed25519 => &signature::ED25519,
43        }
44    }
45}
46
47/// Name of the detached signature file at the root of a plugin directory.
48pub const SIGNATURE_FILE: &str = "plugin.sig";
49
50/// Signature format version produced and accepted by this crate.
51///
52/// Version 2 signs a digest of every file in the plugin directory
53/// (see [`compute_artifact_payload`]). Version 1 signed only
54/// `plugin.toml`, so a swapped WASM binary still verified; it is
55/// rejected.
56pub const SIGNATURE_FORMAT_VERSION: u32 = 2;
57
58/// Domain separator for the v2 signed payload.
59const ARTIFACT_PAYLOAD_DOMAIN: &[u8] = b"mockforge-plugin-signature-v2\0";
60
61/// Environment variable naming a trust store file (see [`TrustStoreFile`]).
62pub const TRUST_STORE_ENV: &str = "MOCKFORGE_PLUGIN_TRUSTED_KEYS_FILE";
63
64/// Default trust store location used by the CLI.
65pub const DEFAULT_TRUST_STORE_PATH: &str = "~/.mockforge/trusted-keys.json";
66
67fn legacy_format_version() -> u32 {
68    1
69}
70
71/// Plugin signature metadata
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PluginSignature {
74    /// Signature format version. Files without the field are v1.
75    #[serde(default = "legacy_format_version")]
76    pub format_version: u32,
77    /// Signature algorithm used
78    pub algorithm: SignatureAlgorithm,
79    /// Key ID that was used to sign
80    pub key_id: String,
81    /// Hex-encoded signature bytes
82    pub signature: String,
83    /// Hex-encoded SHA-256 of the signed artifact payload
84    pub signed_content_hash: String,
85}
86
87impl PluginSignature {
88    /// Create a new signature in the current format
89    pub fn new(
90        algorithm: SignatureAlgorithm,
91        key_id: String,
92        signature: Vec<u8>,
93        content_hash: Vec<u8>,
94    ) -> Self {
95        Self {
96            format_version: SIGNATURE_FORMAT_VERSION,
97            algorithm,
98            key_id,
99            signature: hex::encode(signature),
100            signed_content_hash: hex::encode(content_hash),
101        }
102    }
103
104    /// Get signature bytes
105    pub fn signature_bytes(&self) -> Result<Vec<u8>, hex::FromHexError> {
106        hex::decode(&self.signature)
107    }
108
109    /// Get content hash bytes
110    pub fn content_hash_bytes(&self) -> Result<Vec<u8>, hex::FromHexError> {
111        hex::decode(&self.signed_content_hash)
112    }
113}
114
115/// Whether a plugin directory carries a detached signature file.
116pub fn has_signature_file(plugin_dir: &Path) -> bool {
117    !matches!(fs::symlink_metadata(plugin_dir.join(SIGNATURE_FILE)),
118        Err(error) if error.kind() == std::io::ErrorKind::NotFound)
119}
120
121/// Plugin signature verifier
122pub struct SignatureVerifier<'a> {
123    config: &'a PluginLoaderConfig,
124}
125
126impl<'a> SignatureVerifier<'a> {
127    /// Create a new signature verifier
128    pub fn new(config: &'a PluginLoaderConfig) -> Self {
129        Self { config }
130    }
131
132    /// Verify a plugin's signature
133    ///
134    /// This function:
135    /// 1. Reads the signature file (`plugin.sig`)
136    /// 2. Rejects legacy, revoked, untrusted and expired keys
137    /// 3. Recomputes the artifact payload over every plugin file
138    /// 4. Verifies the signature over that payload with the trusted key
139    ///
140    /// A missing signature passes only when `allow_unsigned` is set. A
141    /// signature that is present but invalid always fails.
142    pub fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
143        self.verify_plugin_signature_at(plugin_dir, Utc::now())
144    }
145
146    /// [`Self::verify_plugin_signature`] with an explicit clock, for
147    /// deterministic expiry checks.
148    pub fn verify_plugin_signature_at(
149        &self,
150        plugin_dir: &Path,
151        now: DateTime<Utc>,
152    ) -> LoaderResult<()> {
153        let sig_file = plugin_dir.join(SIGNATURE_FILE);
154        if !has_signature_file(plugin_dir) {
155            if self.config.allow_unsigned {
156                tracing::warn!("No signature file found, but unsigned plugins are allowed");
157                return Ok(());
158            }
159            return Err(PluginLoaderError::security("No signature file found (plugin.sig)"));
160        }
161
162        let sig_contents = fs::read_to_string(&sig_file).map_err(|e| {
163            PluginLoaderError::security(format!("Failed to read signature file: {}", e))
164        })?;
165
166        let signature: PluginSignature = serde_json::from_str(&sig_contents).map_err(|e| {
167            PluginLoaderError::security(format!("Failed to parse signature file: {}", e))
168        })?;
169
170        if signature.format_version != SIGNATURE_FORMAT_VERSION {
171            return Err(PluginLoaderError::security(format!(
172                "Unsupported plugin signature format v{} (expected v{}). Older signatures \
173                 covered only the manifest; re-sign the plugin.",
174                signature.format_version, SIGNATURE_FORMAT_VERSION
175            )));
176        }
177
178        tracing::debug!(
179            "Verifying signature with algorithm {:?} and key_id {}",
180            signature.algorithm,
181            signature.key_id
182        );
183
184        if self.config.revoked_keys.contains(&signature.key_id) {
185            return Err(PluginLoaderError::security(format!(
186                "Signature key '{}' has been revoked",
187                signature.key_id
188            )));
189        }
190
191        if !self.config.trusted_keys.contains(&signature.key_id) {
192            return Err(PluginLoaderError::security(format!(
193                "Signature key '{}' is not in trusted keys list",
194                signature.key_id
195            )));
196        }
197
198        if let Some(expires_at) = self.config.key_expires_at.get(&signature.key_id) {
199            if now >= *expires_at {
200                return Err(PluginLoaderError::security(format!(
201                    "Signature key '{}' expired at {}",
202                    signature.key_id,
203                    expires_at.to_rfc3339()
204                )));
205            }
206        }
207
208        let public_key_bytes = self.config.key_data.get(&signature.key_id).ok_or_else(|| {
209            PluginLoaderError::security(format!(
210                "Public key data not found for key_id '{}'",
211                signature.key_id
212            ))
213        })?;
214
215        let payload = compute_artifact_payload(plugin_dir)?;
216        let computed_hash = ring::digest::digest(&ring::digest::SHA256, &payload);
217
218        let signed_hash_bytes = signature.content_hash_bytes().map_err(|e| {
219            PluginLoaderError::security(format!("Failed to decode signed content hash: {}", e))
220        })?;
221
222        if computed_hash.as_ref() != signed_hash_bytes.as_slice() {
223            return Err(PluginLoaderError::security(
224                "Plugin contents do not match the signed hash. The plugin was modified or \
225                 the signature belongs to a different artifact.",
226            ));
227        }
228
229        let signature_bytes = signature.signature_bytes().map_err(|e| {
230            PluginLoaderError::security(format!("Failed to decode signature: {}", e))
231        })?;
232
233        let public_key = signature::UnparsedPublicKey::new(
234            signature.algorithm.as_ring_algorithm(),
235            public_key_bytes,
236        );
237
238        public_key.verify(&payload, &signature_bytes).map_err(|_| {
239            PluginLoaderError::security("Signature verification failed. Invalid signature.")
240        })?;
241
242        tracing::info!("Plugin signature verified successfully with key '{}'", signature.key_id);
243        Ok(())
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[cfg(unix)]
252    #[test]
253    fn dangling_signature_is_rejected_even_when_unsigned_plugins_are_allowed() {
254        let directory = tempfile::tempdir().unwrap();
255        std::os::unix::fs::symlink("missing-signature", directory.path().join(SIGNATURE_FILE))
256            .unwrap();
257        let config = PluginLoaderConfig {
258            allow_unsigned: true,
259            ..Default::default()
260        };
261        assert!(has_signature_file(directory.path()));
262        assert!(SignatureVerifier::new(&config)
263            .verify_plugin_signature(directory.path())
264            .is_err());
265    }
266
267    #[test]
268    fn test_signature_algorithm_serialization() {
269        let alg = SignatureAlgorithm::Ed25519;
270        let json = serde_json::to_string(&alg).unwrap();
271        assert_eq!(json, "\"ED25519\"");
272
273        let alg = SignatureAlgorithm::RsaPkcs1_2048Sha256;
274        let json = serde_json::to_string(&alg).unwrap();
275        assert_eq!(json, "\"RSA_PKCS1_2048_SHA256\"");
276    }
277
278    #[test]
279    fn test_plugin_signature_encoding() {
280        let sig = PluginSignature::new(
281            SignatureAlgorithm::Ed25519,
282            "test-key".to_string(),
283            vec![0x01, 0x02, 0x03],
284            vec![0xaa, 0xbb, 0xcc],
285        );
286
287        assert_eq!(sig.signature, "010203");
288        assert_eq!(sig.signed_content_hash, "aabbcc");
289        assert_eq!(sig.signature_bytes().unwrap(), vec![0x01, 0x02, 0x03]);
290        assert_eq!(sig.content_hash_bytes().unwrap(), vec![0xaa, 0xbb, 0xcc]);
291    }
292
293    #[test]
294    fn test_signature_verification_with_unsigned_allowed() {
295        let config = PluginLoaderConfig {
296            allow_unsigned: true,
297            ..Default::default()
298        };
299
300        let verifier = SignatureVerifier::new(&config);
301        let temp_dir = tempfile::tempdir().unwrap();
302
303        // No signature file, but unsigned is allowed
304        let result = verifier.verify_plugin_signature(temp_dir.path());
305        assert!(result.is_ok());
306    }
307
308    #[test]
309    fn test_signature_verification_missing_file() {
310        let config = PluginLoaderConfig {
311            allow_unsigned: false,
312            ..Default::default()
313        };
314
315        let verifier = SignatureVerifier::new(&config);
316        let temp_dir = tempfile::tempdir().unwrap();
317
318        // No signature file and unsigned not allowed
319        let result = verifier.verify_plugin_signature(temp_dir.path());
320        assert!(result.is_err());
321        assert!(matches!(result.unwrap_err(), PluginLoaderError::SecurityViolation { .. }));
322    }
323
324    #[test]
325    fn test_trust_store_file_applies_keys_expiry_and_revocation() {
326        let temp_dir = tempfile::tempdir().unwrap();
327        let path = temp_dir.path().join("trusted-keys.json");
328        fs::write(
329            &path,
330            r#"{"keys":[
331                {"key_id":"active","public_key":"00ff","expires_at":"2030-01-01T00:00:00Z"},
332                {"key_id":"gone","public_key":"aa","revoked":true}
333            ]}"#,
334        )
335        .unwrap();
336
337        let mut config = PluginLoaderConfig::default();
338        let trusted = TrustStoreFile::load(&path).unwrap().apply_to(&mut config).unwrap();
339
340        assert_eq!(trusted, 1);
341        assert!(config.trusted_keys.contains(&"active".to_string()));
342        assert_eq!(config.key_data.get("active"), Some(&vec![0x00, 0xff]));
343        assert!(config.key_expires_at.contains_key("active"));
344        assert!(!config.trusted_keys.contains(&"gone".to_string()));
345        assert!(config.revoked_keys.contains(&"gone".to_string()));
346    }
347
348    #[test]
349    fn test_trust_store_rejects_bad_hex() {
350        let store = TrustStoreFile {
351            keys: vec![TrustedKeyEntry {
352                key_id: "k".to_string(),
353                public_key: "not-hex".to_string(),
354                expires_at: None,
355                revoked: false,
356            }],
357        };
358        let mut config = PluginLoaderConfig::default();
359        assert!(store.apply_to(&mut config).is_err());
360    }
361
362    #[test]
363    fn test_artifact_payload_covers_nested_files_and_ignores_signature() {
364        let temp_dir = tempfile::tempdir().unwrap();
365        fs::write(temp_dir.path().join("plugin.yaml"), "id: x").unwrap();
366        let before = compute_artifact_payload(temp_dir.path()).unwrap();
367
368        fs::write(temp_dir.path().join(SIGNATURE_FILE), "{}").unwrap();
369        assert_eq!(compute_artifact_payload(temp_dir.path()).unwrap(), before);
370
371        fs::create_dir(temp_dir.path().join("lib")).unwrap();
372        fs::write(temp_dir.path().join("lib").join(SIGNATURE_FILE), "x").unwrap();
373        assert_ne!(compute_artifact_payload(temp_dir.path()).unwrap(), before);
374    }
375
376    #[cfg(unix)]
377    #[test]
378    fn test_artifact_payload_rejects_symlinks() {
379        let temp_dir = tempfile::tempdir().unwrap();
380        fs::write(temp_dir.path().join("plugin.yaml"), "id: x").unwrap();
381        std::os::unix::fs::symlink("/etc/hostname", temp_dir.path().join("link")).unwrap();
382        let err = compute_artifact_payload(temp_dir.path()).unwrap_err();
383        assert!(err.to_string().contains("symlink"));
384    }
385
386    #[test]
387    fn test_expiry_boundary_uses_supplied_clock() {
388        let temp_dir = tempfile::tempdir().unwrap();
389        fs::write(temp_dir.path().join("plugin.yaml"), "id: x").unwrap();
390        let (private_key, public_key) = crate::generate_ed25519_keypair().unwrap();
391        crate::sign_plugin_ed25519(temp_dir.path(), "k", &private_key).unwrap();
392
393        let expiry = Utc::now();
394        let mut config = PluginLoaderConfig {
395            trusted_keys: vec!["k".to_string()],
396            ..Default::default()
397        };
398        config.key_data.insert("k".to_string(), public_key);
399        config.key_expires_at.insert("k".to_string(), expiry);
400        let verifier = SignatureVerifier::new(&config);
401
402        let just_before = expiry - chrono::Duration::seconds(1);
403        assert!(verifier.verify_plugin_signature_at(temp_dir.path(), just_before).is_ok());
404        assert!(verifier.verify_plugin_signature_at(temp_dir.path(), expiry).is_err());
405    }
406}