1use 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
20pub enum SignatureAlgorithm {
21 #[serde(rename = "RSA_PKCS1_2048_SHA256")]
23 RsaPkcs1_2048Sha256,
24 #[serde(rename = "RSA_PKCS1_3072_SHA256")]
26 RsaPkcs1_3072Sha256,
27 #[serde(rename = "RSA_PKCS1_4096_SHA256")]
29 RsaPkcs1_4096SHA256,
30 #[serde(rename = "ED25519")]
32 Ed25519,
33}
34
35impl SignatureAlgorithm {
36 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
47pub const SIGNATURE_FILE: &str = "plugin.sig";
49
50pub const SIGNATURE_FORMAT_VERSION: u32 = 2;
57
58const ARTIFACT_PAYLOAD_DOMAIN: &[u8] = b"mockforge-plugin-signature-v2\0";
60
61pub const TRUST_STORE_ENV: &str = "MOCKFORGE_PLUGIN_TRUSTED_KEYS_FILE";
63
64pub const DEFAULT_TRUST_STORE_PATH: &str = "~/.mockforge/trusted-keys.json";
66
67fn legacy_format_version() -> u32 {
68 1
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PluginSignature {
74 #[serde(default = "legacy_format_version")]
76 pub format_version: u32,
77 pub algorithm: SignatureAlgorithm,
79 pub key_id: String,
81 pub signature: String,
83 pub signed_content_hash: String,
85}
86
87impl PluginSignature {
88 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 pub fn signature_bytes(&self) -> Result<Vec<u8>, hex::FromHexError> {
106 hex::decode(&self.signature)
107 }
108
109 pub fn content_hash_bytes(&self) -> Result<Vec<u8>, hex::FromHexError> {
111 hex::decode(&self.signed_content_hash)
112 }
113}
114
115pub 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
121pub struct SignatureVerifier<'a> {
123 config: &'a PluginLoaderConfig,
124}
125
126impl<'a> SignatureVerifier<'a> {
127 pub fn new(config: &'a PluginLoaderConfig) -> Self {
129 Self { config }
130 }
131
132 pub fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
143 self.verify_plugin_signature_at(plugin_dir, Utc::now())
144 }
145
146 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 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 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}