Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ida98135cf868db0f5a46a64b8ac562366a6a6964
252 lines
7.9 KiB
Rust
252 lines
7.9 KiB
Rust
//! 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<SignatureStatus> {
|
|
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<VerifyingKey> {
|
|
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()
|
|
}
|
|
}
|