kojacoord_plugin_system/
integrity.rs1use anyhow::{bail, Context, Result};
15use sha2::{Digest, Sha256};
16use std::collections::HashSet;
17use std::path::Path;
18
19#[derive(Debug, Clone, Default)]
21pub struct PluginVerifier {
22 trusted_hashes: HashSet<String>,
23 require_verification: bool,
24}
25
26impl PluginVerifier {
27 pub fn new() -> Self {
29 Self::default()
30 }
31
32 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 pub fn set_require_verification(&mut self, require: bool) {
51 self.require_verification = require;
52 }
53
54 pub fn add_trusted_hash(&mut self, hash: &str) {
56 self.trusted_hashes.insert(hash.trim().to_ascii_lowercase());
57 }
58
59 pub fn file_sha256(path: &Path) -> Result<String> {
61 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 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}