use std::path::{Path, PathBuf};
use base64::Engine;
use ring::signature::{ED25519, UnparsedPublicKey};
use sha2::{Digest, Sha256};
use super::{
DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginFailure,
DynamicPluginFailurePhase, DynamicPluginManifest, EvaluatedDynamicPluginHostPolicy,
read_bounded_regular_file,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DynamicPluginTrustFailure {
MissingArtifact,
MissingIntegrityDigest,
ArtifactRead {
path: PathBuf,
error: String,
},
IntegrityMismatch {
path: PathBuf,
expected: String,
actual: String,
},
MissingSignature,
MissingTrustedKeys,
SignatureRead {
path: PathBuf,
error: String,
},
SignatureVerification {
path: PathBuf,
parse_errors: Vec<String>,
},
}
impl DynamicPluginTrustFailure {
pub fn refusal_code(&self) -> &'static str {
match self {
Self::MissingArtifact
| Self::MissingIntegrityDigest
| Self::ArtifactRead { .. }
| Self::IntegrityMismatch { .. } => "integrity_failed",
Self::MissingSignature
| Self::MissingTrustedKeys
| Self::SignatureRead { .. }
| Self::SignatureVerification { .. } => "attestation_failed",
}
}
pub fn display(&self, plugin_id: &str) -> String {
match self {
Self::MissingArtifact => format!(
"dynamic plugin '{plugin_id}' is missing source.artifact required for integrity verification"
),
Self::MissingIntegrityDigest => format!(
"dynamic plugin '{plugin_id}' is missing integrity.sha256 required for host trust verification"
),
Self::ArtifactRead { path, error } => format!(
"dynamic plugin '{plugin_id}' artifact {} could not be read for trust verification: {error}",
path.display()
),
Self::IntegrityMismatch {
path,
expected,
actual,
} => format!(
"dynamic plugin '{plugin_id}' failed integrity verification for {}: expected {expected}, got {actual}",
path.display()
),
Self::MissingSignature => format!(
"dynamic plugin '{plugin_id}' requires integrity.signature under host policy; \
sign the artifact and configure trusted_public_keys, or set attestation to \
'integrity_only' or 'signature_if_present' under [plugins.policy.defaults]"
),
Self::MissingTrustedKeys => format!(
"dynamic plugin '{plugin_id}' requires signature verification, but no trusted_public_keys are configured in host policy"
),
Self::SignatureRead { path, error } => format!(
"dynamic plugin '{plugin_id}' signature {} could not be read: {error}",
path.display()
),
Self::SignatureVerification { path, parse_errors } => {
let suffix = if parse_errors.is_empty() {
String::new()
} else {
format!("; key parse errors: {}", parse_errors.join("; "))
};
format!(
"dynamic plugin '{plugin_id}' failed signature verification for {} against configured host policy keys{suffix}",
path.display()
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvaluatedDynamicPluginTrust {
pub integrity: DynamicPluginCheckState,
pub authenticity: DynamicPluginCheckState,
pub failure: Option<DynamicPluginTrustFailure>,
}
impl EvaluatedDynamicPluginTrust {
pub fn is_satisfied(&self) -> bool {
self.failure.is_none()
}
pub fn failure(&self) -> Option<&DynamicPluginTrustFailure> {
self.failure.as_ref()
}
pub fn refusal_code(&self) -> Option<&'static str> {
self.failure
.as_ref()
.map(DynamicPluginTrustFailure::refusal_code)
}
pub fn last_error(&self, plugin_id: &str) -> Option<DynamicPluginFailure> {
self.failure.as_ref().map(|failure| DynamicPluginFailure {
phase: DynamicPluginFailurePhase::Validation,
code: failure.refusal_code().into(),
message: failure.display(plugin_id),
})
}
}
pub fn evaluate_dynamic_plugin_trust(
manifest: &DynamicPluginManifest,
manifest_ref: &str,
policy: &EvaluatedDynamicPluginHostPolicy,
) -> EvaluatedDynamicPluginTrust {
if !policy.policy_satisfied {
return EvaluatedDynamicPluginTrust {
integrity: DynamicPluginCheckState::Unknown,
authenticity: DynamicPluginCheckState::Unknown,
failure: None,
};
}
let artifact_bytes = match verify_integrity(manifest, manifest_ref) {
Ok(artifact) => artifact,
Err(failure) => {
return EvaluatedDynamicPluginTrust {
integrity: DynamicPluginCheckState::Invalid,
authenticity: DynamicPluginCheckState::Unknown,
failure: Some(failure),
};
}
};
match verify_authenticity(manifest, manifest_ref, &artifact_bytes, policy) {
Ok(authenticity) => EvaluatedDynamicPluginTrust {
integrity: DynamicPluginCheckState::Valid,
authenticity,
failure: None,
},
Err(failure) => EvaluatedDynamicPluginTrust {
integrity: DynamicPluginCheckState::Valid,
authenticity: DynamicPluginCheckState::Invalid,
failure: Some(failure),
},
}
}
pub fn resolve_dynamic_plugin_artifact_path(manifest_ref: &str, reference: &str) -> PathBuf {
let path = PathBuf::from(reference);
if path.is_absolute() {
path
} else {
Path::new(manifest_ref)
.parent()
.map(|parent| parent.join(&path))
.unwrap_or(path)
}
}
fn verify_integrity(
manifest: &DynamicPluginManifest,
manifest_ref: &str,
) -> Result<Vec<u8>, DynamicPluginTrustFailure> {
let artifact = manifest
.source
.as_ref()
.and_then(|source| source.artifact.as_deref())
.ok_or(DynamicPluginTrustFailure::MissingArtifact)?;
let expected = manifest
.integrity
.as_ref()
.and_then(|integrity| integrity.sha256.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(DynamicPluginTrustFailure::MissingIntegrityDigest)?;
let artifact = resolve_dynamic_plugin_artifact_path(manifest_ref, artifact);
let bytes =
read_bounded_regular_file(&artifact, "dynamic plugin artifact").map_err(|error| {
DynamicPluginTrustFailure::ArtifactRead {
path: artifact.clone(),
error,
}
})?;
let actual = sha256_bytes(&bytes);
if actual != expected {
return Err(DynamicPluginTrustFailure::IntegrityMismatch {
path: artifact,
expected: expected.into(),
actual,
});
}
Ok(bytes)
}
fn verify_authenticity(
manifest: &DynamicPluginManifest,
manifest_ref: &str,
artifact_bytes: &[u8],
policy: &EvaluatedDynamicPluginHostPolicy,
) -> Result<DynamicPluginCheckState, DynamicPluginTrustFailure> {
let signature = manifest
.integrity
.as_ref()
.and_then(|integrity| integrity.signature.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty());
match (policy.attestation_mode, signature) {
(DynamicPluginAttestationMode::IntegrityOnly, _) => Ok(DynamicPluginCheckState::Unknown),
(DynamicPluginAttestationMode::SignatureIfPresent, None) => {
Ok(DynamicPluginCheckState::Unknown)
}
(DynamicPluginAttestationMode::SignatureRequired, None) => {
Err(DynamicPluginTrustFailure::MissingSignature)
}
(_, Some(signature)) => {
verify_signature(
manifest_ref,
artifact_bytes,
signature,
&policy.trusted_public_keys,
)?;
Ok(DynamicPluginCheckState::Valid)
}
}
}
fn verify_signature(
manifest_ref: &str,
artifact_bytes: &[u8],
signature_ref: &str,
trusted_keys: &[String],
) -> Result<(), DynamicPluginTrustFailure> {
if trusted_keys.is_empty() {
return Err(DynamicPluginTrustFailure::MissingTrustedKeys);
}
let signature_path = resolve_dynamic_plugin_artifact_path(manifest_ref, signature_ref);
let raw_signature = read_bounded_regular_file(&signature_path, "dynamic plugin signature")
.map_err(|error| DynamicPluginTrustFailure::SignatureRead {
path: signature_path.clone(),
error,
})?;
let signature = base64::engine::general_purpose::STANDARD
.decode(
String::from_utf8_lossy(&raw_signature)
.trim()
.strip_prefix("ed25519:")
.unwrap_or(String::from_utf8_lossy(&raw_signature).trim())
.trim(),
)
.map_err(|error| DynamicPluginTrustFailure::SignatureRead {
path: signature_path.clone(),
error: format!("invalid base64 signature: {error}"),
})?;
let mut parse_errors = Vec::new();
for key in trusted_keys {
match parse_ed25519_key(key) {
Ok(key) => {
if UnparsedPublicKey::new(&ED25519, key)
.verify(artifact_bytes, &signature)
.is_ok()
{
return Ok(());
}
}
Err(error) => parse_errors.push(error),
}
}
Err(DynamicPluginTrustFailure::SignatureVerification {
path: signature_path,
parse_errors,
})
}
fn parse_ed25519_key(value: &str) -> Result<Vec<u8>, String> {
let encoded = value
.trim()
.strip_prefix("ed25519:")
.ok_or_else(|| format!("unsupported trusted public key format '{value}'"))?;
base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.map_err(|error| format!("invalid ed25519 trusted public key '{value}': {error}"))
}
fn sha256_bytes(bytes: &[u8]) -> String {
let mut digest = Sha256::new();
digest.update(bytes);
format!(
"sha256:{}",
digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
)
}
#[cfg(test)]
#[path = "../../../tests/unit/plugin_dynamic_trust_tests.rs"]
mod tests;