Skip to main content

kojacoord_plugin_system/
integrity.rs

1//! Plugin integrity verification.
2//!
3//! Native plugins (`.dll`/`.so`/`.dylib`) execute with full process privileges,
4//! so loading an untrusted binary is equivalent to arbitrary code execution.
5//! This module gates loading behind a SHA-256 allowlist: the operator records
6//! the hashes of plugins they trust, and any binary whose hash is not on the
7//! list is refused.
8//!
9//! When `require_verification` is enabled but no hashes are configured, loading
10//! fails closed. When verification is not required and the allowlist is empty,
11//! loading proceeds with a prominent security warning so the risk is never
12//! silent.
13
14use anyhow::{bail, Context, Result};
15use sha2::{Digest, Sha256};
16use std::collections::HashSet;
17use std::path::Path;
18
19/// Verifies plugin binaries against a configured set of trusted SHA-256 hashes.
20#[derive(Debug, Clone, Default)]
21pub struct PluginVerifier {
22    trusted_hashes: HashSet<String>,
23    require_verification: bool,
24}
25
26impl PluginVerifier {
27    /// Create a permissive verifier with no allowlist (loads with warnings).
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Create a strict verifier seeded with trusted hex-encoded SHA-256 hashes.
33    /// Verification is required, so unknown binaries are refused.
34    pub fn with_trusted_hashes<I, S>(hashes: I) -> Self
35    where
36        I: IntoIterator<Item = S>,
37        S: AsRef<str>,
38    {
39        Self {
40            trusted_hashes: hashes
41                .into_iter()
42                .map(|h| h.as_ref().trim().to_ascii_lowercase())
43                .filter(|h| !h.is_empty())
44                .collect(),
45            require_verification: true,
46        }
47    }
48
49    /// Require verification: when true, a binary must be on the allowlist.
50    pub fn set_require_verification(&mut self, require: bool) {
51        self.require_verification = require;
52    }
53
54    /// Add a single trusted hex-encoded SHA-256 hash.
55    pub fn add_trusted_hash(&mut self, hash: &str) {
56        self.trusted_hashes.insert(hash.trim().to_ascii_lowercase());
57    }
58
59    /// Compute the hex-encoded SHA-256 digest of a file.
60    pub fn file_sha256(path: &Path) -> Result<String> {
61        // Prevent path traversal attacks by rejecting paths containing '..'.
62        if path
63            .components()
64            .any(|c| c == std::path::Component::ParentDir)
65        {
66            bail!("Invalid input: {}", path.display());
67        }
68        let bytes =
69            std::fs::read(path).with_context(|| format!("reading plugin {}", path.display()))?;
70        let mut hasher = Sha256::new();
71        hasher.update(&bytes);
72        Ok(hex::encode(hasher.finalize()))
73    }
74
75    /// Verify a plugin binary's integrity before it is loaded.
76    ///
77    /// Returns `Ok(())` if the binary is trusted (or verification is not
78    /// required and no allowlist is configured), and an error otherwise.
79    pub fn verify(&self, path: &Path) -> Result<()> {
80        let digest = Self::file_sha256(path)?;
81
82        if self.trusted_hashes.is_empty() {
83            if self.require_verification {
84                bail!(
85                    "plugin verification is required but no trusted hashes are configured; \
86                     refusing to load {} (sha256={})",
87                    path.display(),
88                    digest
89                );
90            }
91            log::warn!(
92                "SECURITY: loading UNVERIFIED native plugin {} (sha256={}). \
93                 No trusted-hash allowlist is configured and native plugins run with \
94                 full process privileges. Configure a plugin allowlist for production.",
95                path.display(),
96                digest
97            );
98            return Ok(());
99        }
100
101        if self.trusted_hashes.contains(&digest) {
102            log::info!("verified plugin {} (sha256={})", path.display(), digest);
103            Ok(())
104        } else {
105            bail!(
106                "plugin {} failed integrity verification: sha256={} is not in the trusted allowlist",
107                path.display(),
108                digest
109            )
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::io::Write;
118
119    fn temp_plugin(bytes: &[u8]) -> std::path::PathBuf {
120        let mut p = std::env::temp_dir();
121        p.push(format!(
122            "kojacoord_test_plugin_{}.bin",
123            uuid::Uuid::new_v4()
124        ));
125        let mut f = std::fs::File::create(&p).unwrap();
126        f.write_all(bytes).unwrap();
127        p
128    }
129
130    #[test]
131    fn permissive_allows_with_warning() {
132        let path = temp_plugin(b"hello plugin");
133        let v = PluginVerifier::new();
134        assert!(v.verify(&path).is_ok());
135        let _ = std::fs::remove_file(&path);
136    }
137
138    #[test]
139    fn strict_rejects_unknown_and_accepts_known() {
140        let path = temp_plugin(b"trusted bytes");
141        let digest = PluginVerifier::file_sha256(&path).unwrap();
142
143        let bad = PluginVerifier::with_trusted_hashes(["deadbeef"]);
144        assert!(bad.verify(&path).is_err());
145
146        let good = PluginVerifier::with_trusted_hashes([digest]);
147        assert!(good.verify(&path).is_ok());
148
149        let _ = std::fs::remove_file(&path);
150    }
151
152    #[test]
153    fn require_without_allowlist_fails_closed() {
154        let path = temp_plugin(b"x");
155        let mut v = PluginVerifier::new();
156        v.set_require_verification(true);
157        assert!(v.verify(&path).is_err());
158        let _ = std::fs::remove_file(&path);
159    }
160}