use crate::errors::PluginError;
use crate::manifest::PluginManifest;
#[cfg(test)]
use crate::manifest::ManifestSignature;
pub fn verify_hash_pin(manifest: &PluginManifest, payload: &[u8]) -> Result<(), PluginError> {
let Some(expected_hex) = manifest.hash.as_ref() else {
return Ok(());
};
let actual = blake3::hash(payload);
let actual_hex = actual.to_hex().to_string();
if !constant_time_eq(expected_hex, &actual_hex) {
return Err(PluginError::HashMismatch {
expected: expected_hex.clone(),
actual: actual_hex,
});
}
Ok(())
}
pub fn verify_signed_manifest(
manifest: &PluginManifest,
trust_root: &TrustRoot,
) -> Result<(), PluginError> {
let Some(sig) = manifest.signature.as_ref() else {
return Ok(());
};
if sig.algorithm != "ed25519" {
return Err(PluginError::SignatureInvalid(format!(
"unsupported algorithm `{}`",
sig.algorithm
)));
}
if !trust_root.contains(&sig.key_id) {
return Err(PluginError::SignatureInvalid(format!(
"key `{}` not in trust root",
sig.key_id
)));
}
let public_key_bytes = trust_root.public_key(&sig.key_id).ok_or_else(|| {
PluginError::SignatureInvalid(format!(
"trust root for key `{}` has no public key bytes",
sig.key_id
))
})?;
let signing_payload = canonical_payload(manifest)?;
verify_ed25519(public_key_bytes, &signing_payload, &sig.value)
}
const MANIFEST_SIG_DOMAIN_V1: &[u8] = b"uni-plugin-manifest-sig:v1\0";
fn canonical_payload(manifest: &PluginManifest) -> Result<Vec<u8>, PluginError> {
let mut unsigned = manifest.clone();
unsigned.signature = None;
let value = serde_json::to_value(&unsigned).map_err(|e| {
PluginError::SignatureInvalid(format!("manifest canonicalization failed: {e}"))
})?;
let json = serde_json::to_vec(&value).map_err(|e| {
PluginError::SignatureInvalid(format!("manifest canonicalization failed: {e}"))
})?;
let mut bytes = Vec::with_capacity(MANIFEST_SIG_DOMAIN_V1.len() + json.len());
bytes.extend_from_slice(MANIFEST_SIG_DOMAIN_V1);
bytes.extend_from_slice(&json);
Ok(bytes)
}
fn verify_ed25519(
public_key_bytes: &[u8; 32],
payload: &[u8],
signature_b64: &str,
) -> Result<(), PluginError> {
use base64::Engine;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let key = VerifyingKey::from_bytes(public_key_bytes)
.map_err(|e| PluginError::SignatureInvalid(format!("malformed ed25519 public key: {e}")))?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(signature_b64.as_bytes())
.map_err(|e| PluginError::SignatureInvalid(format!("signature base64: {e}")))?;
let sig = Signature::from_slice(&sig_bytes)
.map_err(|e| PluginError::SignatureInvalid(format!("signature parse: {e}")))?;
key.verify(payload, &sig)
.map_err(|e| PluginError::SignatureInvalid(format!("ed25519 verify failed: {e}")))?;
Ok(())
}
#[derive(Debug, Default)]
pub struct TrustRoot {
allowed_keys: std::collections::BTreeMap<String, Option<[u8; 32]>>,
}
impl TrustRoot {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn allow(&mut self, key_id: impl Into<String>) {
self.allowed_keys.insert(key_id.into(), None);
}
pub fn allow_with_key(&mut self, key_id: impl Into<String>, public_key: [u8; 32]) {
self.allowed_keys.insert(key_id.into(), Some(public_key));
}
#[must_use]
pub fn contains(&self, key_id: &str) -> bool {
self.allowed_keys.contains_key(key_id)
}
#[must_use]
pub fn public_key(&self, key_id: &str) -> Option<&[u8; 32]> {
self.allowed_keys.get(key_id).and_then(|k| k.as_ref())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SignaturePolicy {
#[default]
Disabled,
WarnIfUnsigned,
RequireSigned,
}
pub fn verify_manifest_with_policy(
manifest: &PluginManifest,
trust_root: &TrustRoot,
policy: SignaturePolicy,
) -> Result<(), PluginError> {
match policy {
SignaturePolicy::Disabled => Ok(()),
SignaturePolicy::WarnIfUnsigned => {
if manifest.signature.is_none() {
tracing::warn!(
plugin_id = %manifest.id.as_str(),
"plugin manifest has no signature; accepted under WarnIfUnsigned policy",
);
}
verify_signed_manifest(manifest, trust_root)
}
SignaturePolicy::RequireSigned => {
if manifest.signature.is_none() {
return Err(PluginError::SignatureInvalid(format!(
"plugin `{}` has no manifest signature; RequireSigned policy rejects it",
manifest.id.as_str()
)));
}
verify_signed_manifest(manifest, trust_root)
}
}
}
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (ai, bi) in a.bytes().zip(b.bytes()) {
diff |= ai ^ bi;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::AbiRange;
use crate::plugin::PluginId;
use crate::{Determinism, Scope, SideEffects};
use semver::Version;
fn empty_manifest() -> PluginManifest {
PluginManifest {
id: PluginId::new("test"),
version: Version::new(0, 1, 0),
abi: AbiRange::parse("^1").unwrap(),
depends_on: vec![],
capabilities: crate::CapabilitySet::new(),
determinism: Determinism::Pure,
side_effects: SideEffects::ReadOnly,
scope: Scope::Instance,
hash: None,
signature: None,
provides: crate::ProvidedSurfaces::default(),
docs: String::new(),
metadata: std::collections::BTreeMap::new(),
}
}
#[test]
fn hash_pin_passes_when_unpinned() {
let m = empty_manifest();
assert!(verify_hash_pin(&m, b"anything").is_ok());
}
#[test]
fn hash_pin_passes_with_correct_hash() {
let mut m = empty_manifest();
let payload = b"hello world";
m.hash = Some(blake3::hash(payload).to_hex().to_string());
assert!(verify_hash_pin(&m, payload).is_ok());
}
#[test]
fn hash_pin_fails_with_wrong_hash() {
let mut m = empty_manifest();
m.hash = Some(blake3::hash(b"a").to_hex().to_string());
match verify_hash_pin(&m, b"b") {
Err(PluginError::HashMismatch { expected, actual }) => {
assert!(!expected.is_empty());
assert!(!actual.is_empty());
assert_ne!(expected, actual);
}
other => panic!("expected HashMismatch, got {other:?}"),
}
}
#[test]
fn signature_verification_rejects_unknown_key_id() {
let mut m = empty_manifest();
m.signature = Some(ManifestSignature {
algorithm: "ed25519".to_owned(),
key_id: "ops@example.com".to_owned(),
value: "base64...".to_owned(),
});
let tr = TrustRoot::new();
assert!(verify_signed_manifest(&m, &tr).is_err());
}
#[test]
fn verify_signed_manifest_real_ed25519_round_trip() {
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
let seed: [u8; 32] = [
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
0x1c, 0xae, 0x7f, 0x60,
];
let signing_key = SigningKey::from_bytes(&seed);
let public_key_bytes: [u8; 32] = signing_key.verifying_key().to_bytes();
let mut m = empty_manifest();
m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
let payload = canonical_payload(&m).expect("canonicalize");
let sig = signing_key.sign(&payload);
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
m.signature = Some(ManifestSignature {
algorithm: "ed25519".to_owned(),
key_id: "ops@example.com".to_owned(),
value: sig_b64,
});
let mut tr = TrustRoot::new();
tr.allow_with_key("ops@example.com", public_key_bytes);
verify_signed_manifest(&m, &tr).expect("real Ed25519 verify must succeed");
m.hash = Some(blake3::hash(b"different payload").to_hex().to_string());
assert!(
verify_signed_manifest(&m, &tr).is_err(),
"tampered manifest must fail verification"
);
}
#[test]
fn verify_rejects_capability_substitution() {
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
let seed: [u8; 32] = [
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
0x1c, 0xae, 0x7f, 0x60,
];
let signing_key = SigningKey::from_bytes(&seed);
let public_key_bytes: [u8; 32] = signing_key.verifying_key().to_bytes();
let mut m = empty_manifest();
m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
let payload = canonical_payload(&m).expect("canonicalize");
let sig_b64 =
base64::engine::general_purpose::STANDARD.encode(signing_key.sign(&payload).to_bytes());
m.signature = Some(ManifestSignature {
algorithm: "ed25519".to_owned(),
key_id: "ops@example.com".to_owned(),
value: sig_b64,
});
let mut tr = TrustRoot::new();
tr.allow_with_key("ops@example.com", public_key_bytes);
verify_signed_manifest(&m, &tr).expect("baseline signed manifest must verify");
m.capabilities.insert(crate::Capability::ProcedureWrites);
m.side_effects = SideEffects::Writes;
assert!(
verify_signed_manifest(&m, &tr).is_err(),
"capability substitution under a constant hash must fail verification"
);
}
#[test]
fn verify_fails_closed_without_public_key_bytes() {
let mut m = empty_manifest();
m.signature = Some(ManifestSignature {
algorithm: "ed25519".to_owned(),
key_id: "ops@example.com".to_owned(),
value: "AAAA".to_owned(),
});
let mut tr = TrustRoot::new();
tr.allow("ops@example.com"); match verify_signed_manifest(&m, &tr) {
Err(PluginError::SignatureInvalid(msg)) => {
assert!(msg.contains("no public key bytes"), "msg: {msg}");
}
other => panic!("expected fail-closed SignatureInvalid, got {other:?}"),
}
}
#[test]
fn signature_with_unknown_algorithm_is_rejected() {
let mut m = empty_manifest();
m.signature = Some(ManifestSignature {
algorithm: "rsa".to_owned(),
key_id: "any".to_owned(),
value: String::new(),
});
let mut tr = TrustRoot::new();
tr.allow("any");
assert!(verify_signed_manifest(&m, &tr).is_err());
}
#[test]
fn unsigned_manifest_passes_signature_verifier() {
let m = empty_manifest();
let tr = TrustRoot::new();
assert!(verify_signed_manifest(&m, &tr).is_ok());
}
#[test]
fn policy_disabled_skips_verification() {
let mut m = empty_manifest();
m.signature = Some(ManifestSignature {
algorithm: "rsa".to_owned(),
key_id: "unknown".to_owned(),
value: String::new(),
});
let tr = TrustRoot::new();
assert!(verify_manifest_with_policy(&m, &tr, SignaturePolicy::Disabled).is_ok());
}
#[test]
fn policy_require_signed_rejects_unsigned_manifest() {
let m = empty_manifest();
let tr = TrustRoot::new();
let err = verify_manifest_with_policy(&m, &tr, SignaturePolicy::RequireSigned)
.expect_err("RequireSigned must reject unsigned manifest");
match err {
PluginError::SignatureInvalid(msg) => {
assert!(msg.contains("no manifest signature"), "msg: {msg}");
}
other => panic!("expected SignatureInvalid, got {other:?}"),
}
}
#[test]
fn policy_warn_if_unsigned_passes_unsigned_manifest() {
let m = empty_manifest();
let tr = TrustRoot::new();
assert!(verify_manifest_with_policy(&m, &tr, SignaturePolicy::WarnIfUnsigned).is_ok());
}
#[test]
fn constant_time_eq_basic() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "ab"));
}
#[test]
fn ed25519_sign_and_verify_round_trip_manually() {
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
let seed: [u8; 32] = [
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
0x1c, 0xae, 0x7f, 0x60,
];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let public_key_bytes: [u8; 32] = verifying_key.to_bytes();
let mut m = empty_manifest();
m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
let payload = canonical_payload(&m).expect("canonicalize");
let sig = signing_key.sign(&payload);
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
let key = ed25519_dalek::VerifyingKey::from_bytes(&public_key_bytes).unwrap();
let decoded = base64::engine::general_purpose::STANDARD
.decode(sig_b64.as_bytes())
.unwrap();
let parsed_sig = ed25519_dalek::Signature::from_slice(&decoded).unwrap();
use ed25519_dalek::Verifier;
assert!(key.verify(&payload, &parsed_sig).is_ok());
let mut tampered = payload.clone();
tampered[0] ^= 0xff;
assert!(key.verify(&tampered, &parsed_sig).is_err());
let mut tr = TrustRoot::new();
tr.allow_with_key("ops@example.com", public_key_bytes);
assert_eq!(tr.public_key("ops@example.com"), Some(&public_key_bytes));
}
}