Skip to main content

homecore_plugins/
verify.rs

1//! Plugin signature & integrity verification (ADR-162, P4).
2//!
3//! ADR-161/B5 honestly relabelled the manifest's `wasm_module_hash` /
4//! `wasm_module_sig` / `publisher_key` fields as "(P4 — not yet enforced)":
5//! they were parsed and round-tripped but **never checked** before a plugin
6//! ran. This module makes that claim TRUE — it is the real verification gate
7//! the plugin load path runs before instantiating any `.wasm` module.
8//!
9//! ## What is verified, in order
10//!
11//! 1. **Module hash** — SHA-256 of the actual `.wasm` bytes must equal the
12//!    manifest's `wasm_module_hash` (`sha256:<hex>`). A tampered module
13//!    (one byte changed) fails here.
14//! 2. **Ed25519 signature** — `wasm_module_sig` (`ed25519:<base64>`, 64-byte
15//!    raw signature) must verify over the **32-byte SHA-256 digest** under
16//!    the `publisher_key` (`ed25519:<base64>`, 32-byte raw verifying key).
17//! 3. **Trust policy** — the `publisher_key` must be on the configured
18//!    allowlist, unless [`PluginPolicy::AllowUnsigned`] is in force (a loud
19//!    dev escape hatch).
20//!
21//! The crypto mirrors the in-repo Ed25519 pattern from
22//! `cog-ha-matter::witness_signing` (same `ed25519-dalek` 2.x API, same
23//! deterministic-test-key convention). SHA-256 matches the `sha256:` prefix
24//! the manifest doc already declared for `wasm_module_hash`, and the
25//! `cog-ha-matter` cog manifest's `binary_sha256` hex convention.
26//!
27//! ## Secure default
28//!
29//! [`PluginPolicy::trusted`] (the production constructor) **rejects**:
30//!   * an unsigned module (no hash / sig / key),
31//!   * a signature from a key not on the allowlist,
32//!   * any hash or signature mismatch.
33//!
34//! Only [`PluginPolicy::AllowUnsigned`] loosens this, and every load it
35//! waves through emits a `warn`-level log line so it cannot pass silently.
36
37use base64::Engine as _;
38use ed25519_dalek::{Signature, Verifier, VerifyingKey};
39use sha2::{Digest, Sha256};
40
41use crate::error::PluginError;
42use crate::manifest::PluginManifest;
43
44/// Trust policy governing which plugins may load.
45///
46/// The production path uses [`PluginPolicy::trusted`] with an explicit
47/// allowlist of publisher verifying keys. [`PluginPolicy::AllowUnsigned`]
48/// is the dev escape hatch — it loads anything (even unsigned modules) but
49/// logs a loud warning per load.
50#[derive(Debug, Clone)]
51pub enum PluginPolicy {
52    /// Secure default: a plugin loads only if its module hash matches, its
53    /// Ed25519 signature verifies, AND its publisher key is in this
54    /// allowlist. Each entry is the 32-byte raw Ed25519 verifying key.
55    Trusted { allowlist: Vec<[u8; 32]> },
56    /// Dev-only: skip signature/allowlist enforcement. Hash is still
57    /// checked when a `wasm_module_hash` is present (cheap integrity), but
58    /// unsigned / unknown-publisher modules are allowed. Every load logs a
59    /// loud `warn`.
60    AllowUnsigned,
61}
62
63impl PluginPolicy {
64    /// Construct the secure (production) policy from a list of trusted
65    /// publisher keys, each encoded as `ed25519:<base64>` (the same form
66    /// the manifest `publisher_key` uses).
67    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    /// Secure policy that trusts no publisher at all — every signed or
76    /// unsigned module is rejected. Useful as a strict default.
77    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
95/// Verify a `.wasm` module's integrity and signature against its manifest,
96/// under the given trust `policy`. Returns `Ok(())` only if the module may
97/// be instantiated.
98///
99/// On [`PluginPolicy::AllowUnsigned`] this still checks any present hash,
100/// but waves through missing/untrusted signatures with a loud `warn`.
101pub 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        // No integrity material at all.
112        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    // (1) Hash check — always enforced when a hash is declared.
128    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    // (2) Signature check + (3) allowlist.
146    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            // Hash present but signature/key incomplete.
176            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
193/// SHA-256 of `bytes` as a 32-byte digest.
194fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
195    let mut hasher = Sha256::new();
196    hasher.update(bytes);
197    hasher.finalize().into()
198}
199
200/// Parse a `sha256:<hex>` manifest hash into a 32-byte digest.
201fn 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
218/// Decode an `ed25519:<base64>` 32-byte verifying key.
219fn 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
238/// Decode an `ed25519:<base64>` 64-byte signature.
239fn 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
257/// Encode a SHA-256 digest as the manifest `sha256:<hex>` form. Exposed so
258/// tooling (and tests) can produce a manifest hash for real `.wasm` bytes.
259pub fn encode_sha256(wasm_bytes: &[u8]) -> String {
260    format!("sha256:{}", hex::encode(sha256_digest(wasm_bytes)))
261}
262
263/// Encode an Ed25519 verifying key as the manifest `ed25519:<base64>` form.
264pub fn encode_verifying_key(key: &VerifyingKey) -> String {
265    format!(
266        "ed25519:{}",
267        base64::engine::general_purpose::STANDARD.encode(key.to_bytes())
268    )
269}
270
271/// Encode an Ed25519 signature as the manifest `ed25519:<base64>` form.
272pub 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    /// Deterministic publisher key (mirrors witness_signing's fixed-bytes
285    /// seed convention — DO NOT use in production).
286    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    /// Sign `wasm_bytes` with `key` and produce a manifest carrying the real
295    /// hash + signature + publisher key.
296    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        // Flip a byte: hash no longer matches.
338        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        // Signed correctly by the attacker, but the attacker is not trusted.
347        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        // Manifest claims the trusted publisher_key but the signature was
357        // produced by the attacker (a forged sig under a trusted identity).
358        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        // ...but AllowUnsigned loads it (with a warn).
394        verify_module(&manifest, wasm, &PluginPolicy::AllowUnsigned)
395            .expect("AllowUnsigned should load an unsigned module");
396    }
397}