1use base64::Engine as _;
38use ed25519_dalek::{Signature, Verifier, VerifyingKey};
39use sha2::{Digest, Sha256};
40
41use crate::error::PluginError;
42use crate::manifest::PluginManifest;
43
44#[derive(Debug, Clone)]
51pub enum PluginPolicy {
52 Trusted { allowlist: Vec<[u8; 32]> },
56 AllowUnsigned,
61}
62
63impl PluginPolicy {
64 pub fn trusted(publisher_keys: &[&str]) -> Result<Self, PluginError> {
68 let mut allowlist = Vec::with_capacity(publisher_keys.len());
69 for k in publisher_keys {
70 allowlist.push(decode_verifying_key(k)?.to_bytes());
71 }
72 Ok(PluginPolicy::Trusted { allowlist })
73 }
74
75 pub fn deny_all() -> Self {
78 PluginPolicy::Trusted { allowlist: vec![] }
79 }
80
81 fn is_dev(&self) -> bool {
82 matches!(self, PluginPolicy::AllowUnsigned)
83 }
84
85 fn allows(&self, key: &VerifyingKey) -> bool {
86 match self {
87 PluginPolicy::AllowUnsigned => true,
88 PluginPolicy::Trusted { allowlist } => {
89 allowlist.iter().any(|k| k == &key.to_bytes())
90 }
91 }
92 }
93}
94
95pub fn verify_module(
102 manifest: &PluginManifest,
103 wasm_bytes: &[u8],
104 policy: &PluginPolicy,
105) -> Result<(), PluginError> {
106 let signed = manifest.wasm_module_hash.is_some()
107 || manifest.wasm_module_sig.is_some()
108 || manifest.publisher_key.is_some();
109
110 if !signed {
111 if policy.is_dev() {
113 eprintln!(
114 "[PLUGIN WARN] loading UNSIGNED plugin `{}` — no wasm_module_hash/sig/publisher_key. \
115 AllowUnsigned dev policy is active; this is INSECURE and must not be used in production.",
116 manifest.domain
117 );
118 return Ok(());
119 }
120 return Err(PluginError::SignatureRejected(format!(
121 "plugin `{}` is unsigned (no wasm_module_hash/sig/publisher_key) and the trust policy \
122 rejects unsigned modules; set PluginPolicy::AllowUnsigned to override in dev",
123 manifest.domain
124 )));
125 }
126
127 let digest = sha256_digest(wasm_bytes);
129 if let Some(declared) = &manifest.wasm_module_hash {
130 let expected = parse_sha256(declared)?;
131 if expected != digest {
132 return Err(PluginError::SignatureRejected(format!(
133 "plugin `{}` wasm hash mismatch: module does not match manifest wasm_module_hash \
134 (tampered or wrong binary)",
135 manifest.domain
136 )));
137 }
138 } else if !policy.is_dev() {
139 return Err(PluginError::SignatureRejected(format!(
140 "plugin `{}` carries a signature/publisher_key but no wasm_module_hash to bind it to",
141 manifest.domain
142 )));
143 }
144
145 match (&manifest.wasm_module_sig, &manifest.publisher_key) {
147 (Some(sig_str), Some(key_str)) => {
148 let key = decode_verifying_key(key_str)?;
149 let sig = decode_signature(sig_str)?;
150 key.verify(&digest, &sig).map_err(|_| {
151 PluginError::SignatureRejected(format!(
152 "plugin `{}` Ed25519 signature does not verify over the module hash under \
153 publisher_key",
154 manifest.domain
155 ))
156 })?;
157 if !policy.allows(&key) {
158 if policy.is_dev() {
159 eprintln!(
160 "[PLUGIN WARN] plugin `{}` is validly signed but its publisher_key is NOT on \
161 the trust allowlist; AllowUnsigned dev policy loads it anyway.",
162 manifest.domain
163 );
164 return Ok(());
165 }
166 return Err(PluginError::SignatureRejected(format!(
167 "plugin `{}` is validly signed but its publisher_key is not on the trust \
168 allowlist (untrusted publisher)",
169 manifest.domain
170 )));
171 }
172 Ok(())
173 }
174 _ => {
175 if policy.is_dev() {
177 eprintln!(
178 "[PLUGIN WARN] plugin `{}` has a hash but no complete Ed25519 signature; \
179 AllowUnsigned dev policy loads it anyway.",
180 manifest.domain
181 );
182 return Ok(());
183 }
184 Err(PluginError::SignatureRejected(format!(
185 "plugin `{}` is missing a complete wasm_module_sig + publisher_key pair; the trust \
186 policy requires a valid signature",
187 manifest.domain
188 )))
189 }
190 }
191}
192
193fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
195 let mut hasher = Sha256::new();
196 hasher.update(bytes);
197 hasher.finalize().into()
198}
199
200fn parse_sha256(s: &str) -> Result<[u8; 32], PluginError> {
202 let hex_part = s.strip_prefix("sha256:").ok_or_else(|| {
203 PluginError::InvalidManifest(format!(
204 "wasm_module_hash must be `sha256:<hex>`, got {s:?}"
205 ))
206 })?;
207 let raw = hex::decode(hex_part).map_err(|e| {
208 PluginError::InvalidManifest(format!("wasm_module_hash hex decode: {e}"))
209 })?;
210 raw.try_into().map_err(|v: Vec<u8>| {
211 PluginError::InvalidManifest(format!(
212 "wasm_module_hash must decode to 32 bytes, got {}",
213 v.len()
214 ))
215 })
216}
217
218fn decode_verifying_key(s: &str) -> Result<VerifyingKey, PluginError> {
220 let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
221 PluginError::InvalidManifest(format!(
222 "publisher_key must be `ed25519:<base64>`, got {s:?}"
223 ))
224 })?;
225 let raw = base64::engine::general_purpose::STANDARD
226 .decode(b64)
227 .map_err(|e| PluginError::InvalidManifest(format!("publisher_key base64: {e}")))?;
228 let bytes: [u8; 32] = raw.try_into().map_err(|v: Vec<u8>| {
229 PluginError::InvalidManifest(format!(
230 "publisher_key must decode to 32 bytes, got {}",
231 v.len()
232 ))
233 })?;
234 VerifyingKey::from_bytes(&bytes)
235 .map_err(|e| PluginError::InvalidManifest(format!("publisher_key not a valid Ed25519 point: {e}")))
236}
237
238fn decode_signature(s: &str) -> Result<Signature, PluginError> {
240 let b64 = s.strip_prefix("ed25519:").ok_or_else(|| {
241 PluginError::InvalidManifest(format!(
242 "wasm_module_sig must be `ed25519:<base64>`, got {s:?}"
243 ))
244 })?;
245 let raw = base64::engine::general_purpose::STANDARD
246 .decode(b64)
247 .map_err(|e| PluginError::InvalidManifest(format!("wasm_module_sig base64: {e}")))?;
248 let bytes: [u8; 64] = raw.try_into().map_err(|v: Vec<u8>| {
249 PluginError::InvalidManifest(format!(
250 "wasm_module_sig must decode to 64 bytes, got {}",
251 v.len()
252 ))
253 })?;
254 Ok(Signature::from_bytes(&bytes))
255}
256
257pub fn encode_sha256(wasm_bytes: &[u8]) -> String {
260 format!("sha256:{}", hex::encode(sha256_digest(wasm_bytes)))
261}
262
263pub fn encode_verifying_key(key: &VerifyingKey) -> String {
265 format!(
266 "ed25519:{}",
267 base64::engine::general_purpose::STANDARD.encode(key.to_bytes())
268 )
269}
270
271pub fn encode_signature(sig: &Signature) -> String {
273 format!(
274 "ed25519:{}",
275 base64::engine::general_purpose::STANDARD.encode(sig.to_bytes())
276 )
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use ed25519_dalek::{Signer, SigningKey};
283
284 fn publisher() -> SigningKey {
287 SigningKey::from_bytes(b"homecore-plugins-pub-test-seed--")
288 }
289
290 fn attacker() -> SigningKey {
291 SigningKey::from_bytes(b"homecore-plugins-attacker-seed--")
292 }
293
294 fn signed_manifest(wasm_bytes: &[u8], key: &SigningKey) -> PluginManifest {
297 let digest = sha256_digest(wasm_bytes);
298 let sig = key.sign(&digest);
299 PluginManifest {
300 domain: "demo".into(),
301 name: "Demo".into(),
302 version: "1.0.0".into(),
303 documentation: None,
304 iot_class: None,
305 config_flow: false,
306 integration_type: None,
307 dependencies: vec![],
308 requirements: vec![],
309 wasm_module: Some("demo.wasm".into()),
310 wasm_module_hash: Some(encode_sha256(wasm_bytes)),
311 wasm_module_sig: Some(encode_signature(&sig)),
312 publisher_key: Some(encode_verifying_key(&key.verifying_key())),
313 min_homecore_version: None,
314 host_imports_required: vec![],
315 homecore_permissions: vec![],
316 cog_id: None,
317 }
318 }
319
320 #[test]
321 fn valid_sig_from_trusted_key_passes() {
322 let wasm = b"\0asm\x01\0\0\0fake module bytes";
323 let key = publisher();
324 let manifest = signed_manifest(wasm, &key);
325 let policy =
326 PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
327 verify_module(&manifest, wasm, &policy).expect("trusted signed module should load");
328 }
329
330 #[test]
331 fn tampered_module_is_rejected() {
332 let wasm = b"\0asm\x01\0\0\0fake module bytes";
333 let key = publisher();
334 let manifest = signed_manifest(wasm, &key);
335 let policy =
336 PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap();
337 let tampered = b"\0asm\x01\0\0\0FAKE module bytes";
339 let err = verify_module(&manifest, tampered, &policy).unwrap_err();
340 assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
341 }
342
343 #[test]
344 fn valid_sig_from_untrusted_key_is_rejected() {
345 let wasm = b"\0asm\x01\0\0\0fake module bytes";
346 let manifest = signed_manifest(wasm, &attacker());
348 let policy =
349 PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
350 let err = verify_module(&manifest, wasm, &policy).unwrap_err();
351 assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
352 }
353
354 #[test]
355 fn forged_signature_is_rejected() {
356 let wasm = b"\0asm\x01\0\0\0fake module bytes";
359 let digest = sha256_digest(wasm);
360 let forged = attacker().sign(&digest);
361 let mut manifest = signed_manifest(wasm, &publisher());
362 manifest.wasm_module_sig = Some(encode_signature(&forged));
363 let policy =
364 PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap();
365 let err = verify_module(&manifest, wasm, &policy).unwrap_err();
366 assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
367 }
368
369 #[test]
370 fn unsigned_module_rejected_under_default_policy() {
371 let wasm = b"\0asm\x01\0\0\0unsigned";
372 let manifest = PluginManifest {
373 domain: "u".into(),
374 name: "U".into(),
375 version: "1".into(),
376 documentation: None,
377 iot_class: None,
378 config_flow: false,
379 integration_type: None,
380 dependencies: vec![],
381 requirements: vec![],
382 wasm_module: Some("u.wasm".into()),
383 wasm_module_hash: None,
384 wasm_module_sig: None,
385 publisher_key: None,
386 min_homecore_version: None,
387 host_imports_required: vec![],
388 homecore_permissions: vec![],
389 cog_id: None,
390 };
391 let err = verify_module(&manifest, wasm, &PluginPolicy::deny_all()).unwrap_err();
392 assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}");
393 verify_module(&manifest, wasm, &PluginPolicy::AllowUnsigned)
395 .expect("AllowUnsigned should load an unsigned module");
396 }
397}