//! Plugin signature verification using Ed25519 + BLAKE3 //! //! Each plugin directory may contain a `plugin.sig` file alongside its //! `plugin.toml`. The signature covers the BLAKE3 hash of the WASM binary //! referenced by the manifest. Verification uses Ed25519 public keys //! configured as trusted in the server's plugin settings. //! //! When `allow_unsigned` is false, plugins _must_ carry a valid signature //! from one of the trusted keys or they will be rejected at load time. use std::path::Path; use anyhow::{Result, anyhow}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; /// Outcome of a signature check on a plugin package. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SignatureStatus { /// Signature is present and valid against a trusted key. Valid, /// No signature file found. Unsigned, /// Signature file exists but does not match any trusted key. Invalid(String), } /// Verify the signature of a plugin's WASM binary. /// /// Reads `plugin.sig` from `plugin_dir`, computes the BLAKE3 hash of the /// WASM binary at `wasm_path`, and verifies the signature against each of /// the `trusted_keys`. The signature file is raw 64-byte Ed25519 signature /// over the 32-byte BLAKE3 digest. /// /// # Errors /// /// Returns an error only on I/O failures, never for cryptographic rejection, /// which is reported via [`SignatureStatus`] instead. pub fn verify_plugin_signature( plugin_dir: &Path, wasm_path: &Path, trusted_keys: &[VerifyingKey], ) -> Result { let sig_path = plugin_dir.join("plugin.sig"); if !sig_path.exists() { return Ok(SignatureStatus::Unsigned); } let sig_bytes = std::fs::read(&sig_path) .map_err(|e| anyhow!("failed to read plugin.sig: {e}"))?; let signature = Signature::from_slice(&sig_bytes).map_err(|e| { // Malformed signature file is an invalid signature, not an I/O error tracing::warn!(path = %sig_path.display(), "malformed plugin.sig: {e}"); anyhow!("malformed plugin.sig: {e}") }); let Ok(signature) = signature else { return Ok(SignatureStatus::Invalid( "malformed signature file".to_string(), )); }; // BLAKE3 hash of the WASM binary is the signed message let wasm_bytes = std::fs::read(wasm_path) .map_err(|e| anyhow!("failed to read WASM binary for verification: {e}"))?; let digest = blake3::hash(&wasm_bytes); let message = digest.as_bytes(); for key in trusted_keys { if key.verify(message, &signature).is_ok() { return Ok(SignatureStatus::Valid); } } Ok(SignatureStatus::Invalid( "signature did not match any trusted key".to_string(), )) } /// Parse a hex-encoded Ed25519 public key (64 hex characters = 32 bytes). /// /// # Errors /// /// Returns an error if the string is not valid hex or is the wrong length. pub fn parse_public_key(hex_str: &str) -> Result { let hex_str = hex_str.trim(); if hex_str.len() != 64 { return Err(anyhow!( "expected 64 hex characters for Ed25519 public key, got {}", hex_str.len() )); } let mut bytes = [0u8; 32]; for (i, byte) in bytes.iter_mut().enumerate() { *byte = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) .map_err(|e| anyhow!("invalid hex in public key: {e}"))?; } VerifyingKey::from_bytes(&bytes) .map_err(|e| anyhow!("invalid Ed25519 public key: {e}")) } #[cfg(test)] mod tests { use ed25519_dalek::{Signer, SigningKey}; use rand::RngExt; use super::*; fn make_keypair() -> (SigningKey, VerifyingKey) { let secret_bytes: [u8; 32] = rand::rng().random(); let signing = SigningKey::from_bytes(&secret_bytes); let verifying = signing.verifying_key(); (signing, verifying) } #[test] fn test_verify_unsigned_plugin() { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("plugin.wasm"); std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); let (_, vk) = make_keypair(); let status = verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); assert_eq!(status, SignatureStatus::Unsigned); } #[test] fn test_verify_valid_signature() { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("plugin.wasm"); let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code_here"; std::fs::write(&wasm_path, wasm_bytes).unwrap(); let (sk, vk) = make_keypair(); // Sign the BLAKE3 hash of the WASM binary let digest = blake3::hash(wasm_bytes); let signature = sk.sign(digest.as_bytes()); std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) .unwrap(); let status = verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); assert_eq!(status, SignatureStatus::Valid); } #[test] fn test_verify_wrong_key() { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("plugin.wasm"); let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code"; std::fs::write(&wasm_path, wasm_bytes).unwrap(); let (sk, _) = make_keypair(); let (_, wrong_vk) = make_keypair(); let digest = blake3::hash(wasm_bytes); let signature = sk.sign(digest.as_bytes()); std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) .unwrap(); let status = verify_plugin_signature(dir.path(), &wasm_path, &[wrong_vk]).unwrap(); assert!(matches!(status, SignatureStatus::Invalid(_))); } #[test] fn test_verify_tampered_wasm() { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("plugin.wasm"); let original = b"\0asm\x01\x00\x00\x00original"; std::fs::write(&wasm_path, original).unwrap(); let (sk, vk) = make_keypair(); let digest = blake3::hash(original); let signature = sk.sign(digest.as_bytes()); std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) .unwrap(); // Tamper with the WASM file after signing std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00tampered").unwrap(); let status = verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); assert!(matches!(status, SignatureStatus::Invalid(_))); } #[test] fn test_verify_malformed_sig_file() { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("plugin.wasm"); std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); // Write garbage to plugin.sig (wrong length) std::fs::write(dir.path().join("plugin.sig"), b"not a signature").unwrap(); let (_, vk) = make_keypair(); let status = verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); assert!(matches!(status, SignatureStatus::Invalid(_))); } #[test] fn test_verify_multiple_trusted_keys() { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("plugin.wasm"); let wasm_bytes = b"\0asm\x01\x00\x00\x00multi_key_test"; std::fs::write(&wasm_path, wasm_bytes).unwrap(); let (sk2, vk2) = make_keypair(); let (_, vk1) = make_keypair(); let (_, vk3) = make_keypair(); // Sign with key 2 let digest = blake3::hash(wasm_bytes); let signature = sk2.sign(digest.as_bytes()); std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) .unwrap(); // Verify against [vk1, vk2, vk3]; should find vk2 let status = verify_plugin_signature(dir.path(), &wasm_path, &[vk1, vk2, vk3]) .unwrap(); assert_eq!(status, SignatureStatus::Valid); } #[test] fn test_parse_public_key_valid() { let (_, vk) = make_keypair(); let hex = hex_encode(vk.as_bytes()); let parsed = parse_public_key(&hex).unwrap(); assert_eq!(parsed, vk); } #[test] fn test_parse_public_key_wrong_length() { assert!(parse_public_key("abcdef").is_err()); } #[test] fn test_parse_public_key_invalid_hex() { let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; assert!(parse_public_key(bad).is_err()); } fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } }