pinakes/crates/pinakes-core/src/plugin/signature.rs
NotAShelf 4edda201e6
pinakes-core: add plugin pipeline; impl signature verification & dependency resolution
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ida98135cf868db0f5a46a64b8ac562366a6a6964
2026-03-08 15:16:58 +03:00

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()
}
}