use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::signature::{self, Ed25519KeyPair, KeyPair};
use super::error::PluginError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignatureMode {
Strict,
Permissive,
Disabled,
}
impl Default for SignatureMode {
fn default() -> Self {
Self::Disabled
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerificationResult {
Valid { publisher_key: String },
Unsigned,
Untrusted,
Invalid { reason: String },
}
impl VerificationResult {
pub fn is_valid(&self) -> bool {
matches!(self, Self::Valid { .. })
}
}
fn b64u_encode(data: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(data)
}
fn b64u_decode(s: &str) -> Result<Vec<u8>, PluginError> {
URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| PluginError::SignatureInvalid(format!("base64url decode error: {e}")))
}
fn hex_decode(s: &str) -> Result<Vec<u8>, PluginError> {
let s = s.trim();
if s.len() % 2 != 0 {
return Err(PluginError::SignatureInvalid(
"hex string must have even length".into(),
));
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|e| PluginError::SignatureInvalid(format!("hex decode: {e}")))
})
.collect()
}
fn hex_encode(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn canonical_manifest_bytes(manifest_toml: &str) -> Vec<u8> {
let mut lines: Vec<&str> = Vec::new();
for line in manifest_toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with("signature") && trimmed.contains('=') {
continue;
}
if trimmed.starts_with("publisher_key") && trimmed.contains('=') {
continue;
}
lines.push(line);
}
while lines.last().map_or(false, |l| l.trim().is_empty()) {
lines.pop();
}
let canonical = lines.join("\n");
canonical.into_bytes()
}
pub fn sign_manifest(manifest_toml: &str, pkcs8_der: &[u8]) -> Result<String, PluginError> {
let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8_der)
.map_err(|e| PluginError::SignatureInvalid(format!("invalid signing key: {e}")))?;
let canonical = canonical_manifest_bytes(manifest_toml);
let sig = key_pair.sign(&canonical);
Ok(b64u_encode(sig.as_ref()))
}
pub fn public_key_hex(pkcs8_der: &[u8]) -> Result<String, PluginError> {
let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8_der)
.map_err(|e| PluginError::SignatureInvalid(format!("invalid signing key: {e}")))?;
Ok(hex_encode(key_pair.public_key().as_ref()))
}
pub fn verify_manifest(
manifest_toml: &str,
signature_b64: &str,
publisher_key_hex: &str,
trusted_keys: &[String],
) -> VerificationResult {
let normalized_key = publisher_key_hex.trim().to_lowercase();
let is_trusted = trusted_keys
.iter()
.any(|k| k.trim().to_lowercase() == normalized_key);
if !is_trusted {
return VerificationResult::Untrusted;
}
let pub_key_bytes = match hex_decode(publisher_key_hex) {
Ok(bytes) => bytes,
Err(e) => {
return VerificationResult::Invalid {
reason: format!("invalid publisher key: {e}"),
};
}
};
let sig_bytes = match b64u_decode(signature_b64) {
Ok(bytes) => bytes,
Err(e) => {
return VerificationResult::Invalid {
reason: format!("invalid signature encoding: {e}"),
};
}
};
let canonical = canonical_manifest_bytes(manifest_toml);
let peer_public_key = signature::UnparsedPublicKey::new(&signature::ED25519, &pub_key_bytes);
match peer_public_key.verify(&canonical, &sig_bytes) {
Ok(()) => VerificationResult::Valid {
publisher_key: normalized_key,
},
Err(_) => VerificationResult::Invalid {
reason: "Ed25519 signature verification failed".into(),
},
}
}
pub fn enforce_signature_policy(
plugin_name: &str,
manifest_toml: &str,
signature: Option<&str>,
publisher_key: Option<&str>,
trusted_keys: &[String],
mode: SignatureMode,
) -> Result<VerificationResult, PluginError> {
if mode == SignatureMode::Disabled {
return Ok(VerificationResult::Unsigned);
}
match (signature, publisher_key) {
(None, _) | (_, None) => {
match mode {
SignatureMode::Strict => Err(PluginError::UnsignedPlugin(plugin_name.to_string())),
SignatureMode::Permissive => {
tracing::warn!(
plugin = plugin_name,
"plugin is unsigned; loading in permissive mode"
);
Ok(VerificationResult::Unsigned)
}
SignatureMode::Disabled => Ok(VerificationResult::Unsigned),
}
}
(Some(sig), Some(pub_key)) => {
let result = verify_manifest(manifest_toml, sig, pub_key, trusted_keys);
match &result {
VerificationResult::Valid { publisher_key } => {
tracing::info!(
plugin = plugin_name,
publisher_key = publisher_key.as_str(),
"plugin signature verified"
);
Ok(result)
}
VerificationResult::Untrusted => match mode {
SignatureMode::Strict => Err(PluginError::UntrustedPublisher {
plugin: plugin_name.to_string(),
publisher_key: pub_key.to_string(),
}),
SignatureMode::Permissive => {
tracing::warn!(
plugin = plugin_name,
publisher_key = pub_key,
"plugin publisher key not trusted; loading in permissive mode"
);
Ok(result)
}
SignatureMode::Disabled => Ok(result),
},
VerificationResult::Invalid { reason } => match mode {
SignatureMode::Strict => Err(PluginError::SignatureInvalid(format!(
"plugin '{}': {}",
plugin_name, reason
))),
SignatureMode::Permissive => {
tracing::warn!(
plugin = plugin_name,
reason = reason.as_str(),
"plugin signature invalid; loading in permissive mode"
);
Ok(result)
}
SignatureMode::Disabled => Ok(result),
},
VerificationResult::Unsigned => Ok(result),
}
}
}
}
pub fn generate_signing_key() -> Result<(Vec<u8>, String), PluginError> {
let rng = ring::rand::SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
.map_err(|e| PluginError::SignatureInvalid(format!("keygen failed: {e}")))?;
let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
.map_err(|e| PluginError::SignatureInvalid(format!("parse pkcs8: {e}")))?;
let pub_hex = hex_encode(key_pair.public_key().as_ref());
Ok((pkcs8.as_ref().to_vec(), pub_hex))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_MANIFEST: &str = r#"
name = "test-plugin"
version = "0.1.0"
description = "A test plugin"
wasm_path = "plugin.wasm"
capabilities = ["tool"]
permissions = []
"#;
fn generate_test_keypair() -> (Vec<u8>, String) {
generate_signing_key().expect("keygen should succeed")
}
#[test]
fn test_canonical_manifest_strips_signature_fields() {
let manifest_with_sig = r#"
name = "test-plugin"
version = "0.1.0"
signature = "abc123"
publisher_key = "deadbeef"
wasm_path = "plugin.wasm"
capabilities = ["tool"]
"#;
let canonical = canonical_manifest_bytes(manifest_with_sig);
let canonical_str = String::from_utf8(canonical).unwrap();
assert!(!canonical_str.contains("signature"));
assert!(!canonical_str.contains("publisher_key"));
assert!(canonical_str.contains("name = \"test-plugin\""));
assert!(canonical_str.contains("wasm_path = \"plugin.wasm\""));
}
#[test]
fn test_canonical_manifest_without_signature_fields() {
let canonical = canonical_manifest_bytes(TEST_MANIFEST);
let canonical_str = String::from_utf8(canonical).unwrap();
assert!(canonical_str.contains("name = \"test-plugin\""));
}
#[test]
fn test_sign_and_verify_roundtrip() {
let (pkcs8, pub_hex) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let trusted_keys = vec![pub_hex.clone()];
let result = verify_manifest(TEST_MANIFEST, &sig, &pub_hex, &trusted_keys);
assert!(result.is_valid());
assert_eq!(
result,
VerificationResult::Valid {
publisher_key: pub_hex.to_lowercase()
}
);
}
#[test]
fn test_verify_rejects_tampered_manifest() {
let (pkcs8, pub_hex) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let tampered = TEST_MANIFEST.replace("0.1.0", "0.2.0");
let trusted_keys = vec![pub_hex.clone()];
let result = verify_manifest(&tampered, &sig, &pub_hex, &trusted_keys);
assert!(matches!(result, VerificationResult::Invalid { .. }));
}
#[test]
fn test_verify_rejects_wrong_key() {
let (pkcs8, _pub_hex) = generate_test_keypair();
let (_pkcs8_2, pub_hex_2) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let trusted_keys = vec![pub_hex_2.clone()];
let result = verify_manifest(TEST_MANIFEST, &sig, &pub_hex_2, &trusted_keys);
assert!(matches!(result, VerificationResult::Invalid { .. }));
}
#[test]
fn test_verify_untrusted_publisher() {
let (pkcs8, pub_hex) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let trusted_keys: Vec<String> = vec![]; let result = verify_manifest(TEST_MANIFEST, &sig, &pub_hex, &trusted_keys);
assert_eq!(result, VerificationResult::Untrusted);
}
#[test]
fn test_public_key_hex_matches_generate() {
let (pkcs8, pub_hex) = generate_test_keypair();
let derived_hex = public_key_hex(&pkcs8).unwrap();
assert_eq!(pub_hex, derived_hex);
}
#[test]
fn test_hex_roundtrip() {
let data = vec![0xDE, 0xAD, 0xBE, 0xEF];
let encoded = hex_encode(&data);
assert_eq!(encoded, "deadbeef");
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn test_enforce_policy_disabled_mode() {
let result = enforce_signature_policy(
"test",
TEST_MANIFEST,
None,
None,
&[],
SignatureMode::Disabled,
)
.unwrap();
assert_eq!(result, VerificationResult::Unsigned);
}
#[test]
fn test_enforce_policy_strict_rejects_unsigned() {
let err = enforce_signature_policy(
"test",
TEST_MANIFEST,
None,
None,
&[],
SignatureMode::Strict,
)
.unwrap_err();
assert!(matches!(err, PluginError::UnsignedPlugin(_)));
}
#[test]
fn test_enforce_policy_permissive_allows_unsigned() {
let result = enforce_signature_policy(
"test",
TEST_MANIFEST,
None,
None,
&[],
SignatureMode::Permissive,
)
.unwrap();
assert_eq!(result, VerificationResult::Unsigned);
}
#[test]
fn test_enforce_policy_strict_rejects_untrusted() {
let (pkcs8, pub_hex) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let err = enforce_signature_policy(
"test",
TEST_MANIFEST,
Some(&sig),
Some(&pub_hex),
&[], SignatureMode::Strict,
)
.unwrap_err();
assert!(matches!(err, PluginError::UntrustedPublisher { .. }));
}
#[test]
fn test_enforce_policy_strict_accepts_valid_signature() {
let (pkcs8, pub_hex) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let trusted_keys = vec![pub_hex.clone()];
let result = enforce_signature_policy(
"test",
TEST_MANIFEST,
Some(&sig),
Some(&pub_hex),
&trusted_keys,
SignatureMode::Strict,
)
.unwrap();
assert!(result.is_valid());
}
#[test]
fn test_enforce_policy_strict_rejects_invalid_signature() {
let (pkcs8, pub_hex) = generate_test_keypair();
let _sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let trusted_keys = vec![pub_hex.clone()];
let err = enforce_signature_policy(
"test",
TEST_MANIFEST,
Some("badsignature"),
Some(&pub_hex),
&trusted_keys,
SignatureMode::Strict,
)
.unwrap_err();
assert!(matches!(err, PluginError::SignatureInvalid(_)));
}
#[test]
fn test_signature_mode_default_is_disabled() {
assert_eq!(SignatureMode::default(), SignatureMode::Disabled);
}
#[test]
fn test_manifest_with_signature_fields_verifies() {
let (pkcs8, pub_hex) = generate_test_keypair();
let sig = sign_manifest(TEST_MANIFEST, &pkcs8).unwrap();
let manifest_with_sig = format!(
r#"
name = "test-plugin"
version = "0.1.0"
description = "A test plugin"
signature = "{sig}"
publisher_key = "{pub_hex}"
wasm_path = "plugin.wasm"
capabilities = ["tool"]
permissions = []
"#
);
let trusted_keys = vec![pub_hex.clone()];
let result = verify_manifest(&manifest_with_sig, &sig, &pub_hex, &trusted_keys);
assert!(result.is_valid());
}
}