pinakes-core: add plugin pipeline; impl signature verification & dependency resolution
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ida98135cf868db0f5a46a64b8ac562366a6a6964
This commit is contained in:
parent
8347a714d2
commit
4edda201e6
12 changed files with 2679 additions and 36 deletions
252
crates/pinakes-core/src/plugin/signature.rs
Normal file
252
crates/pinakes-core/src/plugin/signature.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//! 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue