From 0ca903b49789700be2aa2b9c5484f7aea49876d7 Mon Sep 17 00:00:00 2001 From: ruv Date: Fri, 12 Jun 2026 01:33:52 -0400 Subject: [PATCH] feat(homecore-plugins): enforce plugin signature + capability isolation (ADR-162 P4/P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-161 honestly relabelled the manifest's wasm_module_hash / wasm_module_sig / publisher_key as "(P4 — not yet enforced)" and the homecore_permissions claims as deferred P5 authority isolation. This makes both real and tested. P4 (signature/integrity verification, SECURITY): - New `verify` module: SHA-256 module-hash check + Ed25519 signature verification over the digest against publisher_key, with a PluginPolicy trust allowlist and an explicit AllowUnsigned dev escape hatch (loud warn). Secure default rejects unsigned / unknown-publisher / tampered modules. - Reuses the in-repo cog-ha-matter::witness_signing Ed25519 pattern; sha2 is a workspace dep, ed25519-dalek/hex/base64 already in the lock — no new external dep tree (only new edges in homecore-plugins). - WasmtimeRuntime::load_plugin verifies before instantiation; legacy load_wasm retained for trusted/test modules. P5 (authority/capability isolation, SECURITY): - New `permissions` module: PermissionSet distilled from homecore_permissions (state:write: or bare entity glob). hc_state_set now consults it and returns a typed -3 to the guest on an undeclared write (no host panic). Tests (fail on old code, which had no load_plugin/verify and an unchecked hc_state_set): tampered module rejected; valid sig from trusted key loads; valid sig from untrusted key rejected; unsigned rejected by default and loads only under AllowUnsigned; light.* plugin writes light.kitchen but is denied lock.front_door; no-permission plugin can write nothing. Real deterministic keypair signs real bytes. Manifest doc updated: P4/P5 now ENFORCED (was "not yet enforced"). homecore-plugins --features wasmtime: 32 passed (lib 23, integration 9), 0 failed. Co-Authored-By: claude-flow --- v2/Cargo.lock | 4 + v2/crates/homecore-plugins/Cargo.toml | 9 + v2/crates/homecore-plugins/src/error.rs | 12 + v2/crates/homecore-plugins/src/lib.rs | 16 +- v2/crates/homecore-plugins/src/manifest.rs | 24 +- v2/crates/homecore-plugins/src/permissions.rs | 168 ++++++++ v2/crates/homecore-plugins/src/verify.rs | 397 ++++++++++++++++++ .../homecore-plugins/src/wasmtime_runtime.rs | 77 +++- .../homecore-plugins/tests/integration.rs | 255 +++++++++++ 9 files changed, 947 insertions(+), 15 deletions(-) create mode 100644 v2/crates/homecore-plugins/src/permissions.rs create mode 100644 v2/crates/homecore-plugins/src/verify.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index c31100fd..2d8d9ecc 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -3554,9 +3554,13 @@ name = "homecore-plugins" version = "0.1.0-alpha.0" dependencies = [ "async-trait", + "base64 0.22.1", + "ed25519-dalek", + "hex", "homecore", "serde", "serde_json", + "sha2", "thiserror 1.0.69", "tokio", "uuid", diff --git a/v2/crates/homecore-plugins/Cargo.toml b/v2/crates/homecore-plugins/Cargo.toml index 3b7325fa..7c4b23a1 100644 --- a/v2/crates/homecore-plugins/Cargo.toml +++ b/v2/crates/homecore-plugins/Cargo.toml @@ -50,6 +50,15 @@ serde_json = "1" # UUIDs for config entry IDs in host_abi.rs. uuid = { version = "1", features = ["v4"] } +# ── ADR-162 P4: plugin signature + integrity verification ────────────────── +# Reuses the same in-repo crypto stack as cog-ha-matter (witness_signing.rs): +# Ed25519 over a SHA-256 module digest. All four are already in the workspace +# Cargo.lock (cog-ha-matter / bfld pull them in) — no new external dep tree. +ed25519-dalek = "2.1" +sha2 = { workspace = true } +hex = "0.4" +base64 = "0.22" + # Optional Wasmtime runtime (P2, default-off — 30 MB dep). # Bumped from 25.0.3 → 42 to remediate RUSTSEC-2026-0095 and RUSTSEC-2026-0096 # (Cranelift/Winch sandbox-escape CVEs, CVSS 9.0 — iter-11 security sprint HC-03/04). diff --git a/v2/crates/homecore-plugins/src/error.rs b/v2/crates/homecore-plugins/src/error.rs index 6fe4bb9a..468aa801 100644 --- a/v2/crates/homecore-plugins/src/error.rs +++ b/v2/crates/homecore-plugins/src/error.rs @@ -25,6 +25,18 @@ pub enum PluginError { #[error("plugin setup failed: {0}")] SetupFailed(String), + /// The plugin failed signature/integrity verification (ADR-162 P4): + /// hash mismatch, bad signature, untrusted publisher, or unsigned + /// module under a non-dev trust policy. + #[error("plugin signature rejected: {0}")] + SignatureRejected(String), + + /// A plugin attempted a host call (e.g. `hc_state_set`) on an entity + /// it did not declare in `homecore_permissions` (ADR-162 P5 authority + /// isolation). + #[error("plugin permission denied: {0}")] + PermissionDenied(String), + /// The plugin's `unload` hook returned an error. #[error("plugin unload failed: {0}")] UnloadFailed(String), diff --git a/v2/crates/homecore-plugins/src/lib.rs b/v2/crates/homecore-plugins/src/lib.rs index 296d4aa5..c64d7d9f 100644 --- a/v2/crates/homecore-plugins/src/lib.rs +++ b/v2/crates/homecore-plugins/src/lib.rs @@ -22,8 +22,16 @@ //! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc. //! (P2 — requires ADR-127 state machine API freeze first). //! - Config entry lifecycle + hot-load (P3). -//! - Cog registry distribution + Ed25519 signature verification (P4). -//! - Permission enforcement (P5). +//! +//! ## Now enforced (ADR-162) +//! +//! - **Ed25519 signature + SHA-256 integrity verification (P4)** — see +//! [`verify`]: the plugin load path hashes the real `.wasm` bytes, checks +//! the manifest `wasm_module_hash`, verifies `wasm_module_sig` against +//! `publisher_key`, and enforces a [`verify::PluginPolicy`] allowlist. +//! - **Permission / authority isolation (P5)** — see [`permissions`]: a +//! plugin's `hc_state_set` writes are gated against the entity domains/ +//! globs it declared in `homecore_permissions`. //! //! ## Feature flags //! @@ -35,9 +43,11 @@ pub mod error; pub mod host_abi; pub mod manifest; +pub mod permissions; pub mod plugin; pub mod registry; pub mod runtime; +pub mod verify; #[cfg(feature = "wasmtime")] pub mod wasmtime_runtime; @@ -45,9 +55,11 @@ pub mod wasmtime_runtime; pub use error::PluginError; pub use host_abi::{ConfigEntryJson, StateChangedEventJson}; pub use manifest::{IotClass, IntegrationType, PluginManifest}; +pub use permissions::PermissionSet; pub use plugin::{HomeCorePlugin, PluginId}; pub use registry::PluginRegistry; pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime}; +pub use verify::{verify_module, PluginPolicy}; #[cfg(feature = "wasmtime")] pub use wasmtime_runtime::{WasmPlugin, WasmtimeRuntime}; diff --git a/v2/crates/homecore-plugins/src/manifest.rs b/v2/crates/homecore-plugins/src/manifest.rs index 4480fc4d..106bc29b 100644 --- a/v2/crates/homecore-plugins/src/manifest.rs +++ b/v2/crates/homecore-plugins/src/manifest.rs @@ -85,24 +85,26 @@ pub struct PluginManifest { /// [HOMECORE] `sha256:` hash of the wasm binary. /// - /// **(P4 — not yet enforced, ADR-161/B5):** this field is parsed and - /// round-tripped but is NOT verified before execution. The hash/sig - /// gate lands in P4; until then the presence of this field implies no - /// integrity guarantee. + /// **(P4 — ENFORCED, ADR-162):** `verify::verify_module` computes the + /// SHA-256 of the real `.wasm` bytes on load and rejects the module if + /// it does not equal this hash (tamper detection). See [`crate::verify`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub wasm_module_hash: Option, /// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:`). /// - /// **(P4 — not yet enforced, ADR-161/B5):** parsed but never checked. - /// No signature verification happens before a plugin runs. + /// **(P4 — ENFORCED, ADR-162):** verified against `publisher_key` over + /// the SHA-256 module digest before instantiation. A bad/forged/absent + /// signature is rejected under the secure trust policy (the + /// `cog-ha-matter::witness_signing` Ed25519 pattern is reused). #[serde(default, skip_serializing_if = "Option::is_none")] pub wasm_module_sig: Option, /// [HOMECORE] Ed25519 public key of the plugin publisher. /// - /// **(P4 — not yet enforced, ADR-161/B5):** parsed but never used to - /// verify `wasm_module_sig`. + /// **(P4 — ENFORCED, ADR-162):** used to verify `wasm_module_sig`, and + /// checked against the host's [`crate::verify::PluginPolicy`] trust + /// allowlist — an unknown publisher is rejected by the secure default. #[serde(default, skip_serializing_if = "Option::is_none")] pub publisher_key: Option, @@ -115,6 +117,12 @@ pub struct PluginManifest { pub host_imports_required: Vec, /// [HOMECORE] Coarse-grained permission claims (glob patterns). + /// + /// **(P5 — ENFORCED, ADR-162):** `state:write:` (or a bare entity + /// glob like `light.*`) grants are parsed into a + /// [`crate::permissions::PermissionSet` ] and consulted by the + /// `hc_state_set` host import. A plugin can no longer write an entity it + /// did not declare; a plugin with no write grants can write nothing. #[serde(default)] pub homecore_permissions: Vec, diff --git a/v2/crates/homecore-plugins/src/permissions.rs b/v2/crates/homecore-plugins/src/permissions.rs new file mode 100644 index 00000000..67eef26e --- /dev/null +++ b/v2/crates/homecore-plugins/src/permissions.rs @@ -0,0 +1,168 @@ +//! Plugin authority / capability isolation (ADR-162, P5). +//! +//! Wasmtime already gives a plugin **memory** isolation — it cannot read +//! another plugin's linear memory. It does NOT, by itself, stop a plugin +//! from using a host import to write any entity it likes. Before this fix +//! `hc_state_set` happily let any plugin write `lock.front_door` or +//! `alarm_control_panel.*`, and the manifest's `homecore_permissions` +//! claims were parsed but **never consulted** (ADR-161 deferred P5). +//! +//! This module adds **authority isolation**: a plugin may only write +//! entities its manifest declared. The host import consults a +//! [`PermissionSet`] before applying any state write and returns a typed +//! error to the guest (it does **not** panic the host) on a violation. +//! +//! ## Permission grammar +//! +//! Each entry in `homecore_permissions` is one of: +//! +//! * a bare entity glob — `"light.*"`, `"light.kitchen"`, `"*"`; +//! * the explicit capability form `"state:write:"` (the form the +//! ADR-128 manifest doc shows), e.g. `"state:write:sensor.*"`. +//! +//! A glob supports a single trailing `*` (HA-style domain wildcards: +//! `light.*` matches every `light` entity) and a leading-or-bare `*` +//! (`*` = everything). Exact strings match exactly. A plugin with **no** +//! `state:write` entries can write **nothing** — the secure default. + +use crate::manifest::PluginManifest; + +/// The set of entity-write permissions a plugin holds, distilled from its +/// manifest `homecore_permissions` at load time. +#[derive(Debug, Clone, Default)] +pub struct PermissionSet { + /// Glob patterns the plugin may write (state:write authority). Empty = + /// the plugin may write nothing. + write_globs: Vec, +} + +impl PermissionSet { + /// Build a permission set from a manifest's `homecore_permissions`. + /// + /// Only `state:write` authority is modelled here (the host import this + /// gates is `hc_state_set`). A bare glob (`"light.*"`) is treated as a + /// write grant; the explicit `"state:write:"` form is also + /// accepted. Other capability strings (`state:read:*`, future verbs) + /// are ignored for write-gating purposes. + pub fn from_manifest(manifest: &PluginManifest) -> Self { + let mut write_globs = Vec::new(); + for claim in &manifest.homecore_permissions { + let claim = claim.trim(); + if let Some(glob) = claim.strip_prefix("state:write:") { + write_globs.push(glob.trim().to_string()); + } else if claim.starts_with("state:read:") { + // read authority — not relevant to write gating. + } else if !claim.is_empty() { + // Bare glob — treat as a write grant. + write_globs.push(claim.to_string()); + } + } + Self { write_globs } + } + + /// An all-allowing set (equivalent to a `"*"` grant). Used by the + /// legacy permission-free `WasmtimeRuntime::load_wasm` path so existing + /// callers/tests that do not supply a manifest keep working; the + /// permission-gated path uses [`Self::from_manifest`]. + pub fn allow_all() -> Self { + Self { + write_globs: vec!["*".to_string()], + } + } + + /// May this plugin write the given entity id (e.g. `"light.kitchen"`)? + pub fn may_write(&self, entity_id: &str) -> bool { + self.write_globs.iter().any(|g| glob_matches(g, entity_id)) + } + + /// Number of write-grant globs (0 = can write nothing). + pub fn write_grant_count(&self) -> usize { + self.write_globs.len() + } +} + +/// Match `entity_id` against a single glob pattern. +/// +/// Supported forms: +/// * `"*"` → matches anything. +/// * `"light.*"` → trailing wildcard: any id with the `light.` prefix. +/// * `"light.kitchen"` → exact match. +fn glob_matches(pattern: &str, entity_id: &str) -> bool { + if pattern == "*" { + return true; + } + if let Some(prefix) = pattern.strip_suffix('*') { + return entity_id.starts_with(prefix); + } + pattern == entity_id +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest_with(perms: &[&str]) -> PluginManifest { + PluginManifest { + domain: "p".into(), + name: "P".into(), + version: "1".into(), + documentation: None, + iot_class: None, + config_flow: false, + integration_type: None, + dependencies: vec![], + requirements: vec![], + wasm_module: None, + wasm_module_hash: None, + wasm_module_sig: None, + publisher_key: None, + min_homecore_version: None, + host_imports_required: vec![], + homecore_permissions: perms.iter().map(|s| s.to_string()).collect(), + cog_id: None, + } + } + + #[test] + fn domain_glob_allows_same_domain_only() { + let ps = PermissionSet::from_manifest(&manifest_with(&["light.*"])); + assert!(ps.may_write("light.kitchen")); + assert!(ps.may_write("light.bedroom")); + assert!(!ps.may_write("lock.front_door")); + assert!(!ps.may_write("alarm_control_panel.home")); + } + + #[test] + fn no_permissions_can_write_nothing() { + let ps = PermissionSet::from_manifest(&manifest_with(&[])); + assert_eq!(ps.write_grant_count(), 0); + assert!(!ps.may_write("light.kitchen")); + assert!(!ps.may_write("sensor.temp")); + } + + #[test] + fn explicit_state_write_form_is_honored() { + let ps = PermissionSet::from_manifest(&manifest_with(&["state:write:sensor.*"])); + assert!(ps.may_write("sensor.temp")); + assert!(!ps.may_write("light.kitchen")); + } + + #[test] + fn read_grants_do_not_confer_write() { + let ps = PermissionSet::from_manifest(&manifest_with(&["state:read:lock.*"])); + assert!(!ps.may_write("lock.front_door")); + } + + #[test] + fn exact_entity_grant_is_scoped() { + let ps = PermissionSet::from_manifest(&manifest_with(&["light.kitchen"])); + assert!(ps.may_write("light.kitchen")); + assert!(!ps.may_write("light.bedroom")); + } + + #[test] + fn wildcard_grants_everything() { + let ps = PermissionSet::from_manifest(&manifest_with(&["*"])); + assert!(ps.may_write("lock.front_door")); + } +} diff --git a/v2/crates/homecore-plugins/src/verify.rs b/v2/crates/homecore-plugins/src/verify.rs new file mode 100644 index 00000000..04b31ae4 --- /dev/null +++ b/v2/crates/homecore-plugins/src/verify.rs @@ -0,0 +1,397 @@ +//! Plugin signature & integrity verification (ADR-162, P4). +//! +//! ADR-161/B5 honestly relabelled the manifest's `wasm_module_hash` / +//! `wasm_module_sig` / `publisher_key` fields as "(P4 — not yet enforced)": +//! they were parsed and round-tripped but **never checked** before a plugin +//! ran. This module makes that claim TRUE — it is the real verification gate +//! the plugin load path runs before instantiating any `.wasm` module. +//! +//! ## What is verified, in order +//! +//! 1. **Module hash** — SHA-256 of the actual `.wasm` bytes must equal the +//! manifest's `wasm_module_hash` (`sha256:`). A tampered module +//! (one byte changed) fails here. +//! 2. **Ed25519 signature** — `wasm_module_sig` (`ed25519:`, 64-byte +//! raw signature) must verify over the **32-byte SHA-256 digest** under +//! the `publisher_key` (`ed25519:`, 32-byte raw verifying key). +//! 3. **Trust policy** — the `publisher_key` must be on the configured +//! allowlist, unless [`PluginPolicy::AllowUnsigned`] is in force (a loud +//! dev escape hatch). +//! +//! The crypto mirrors the in-repo Ed25519 pattern from +//! `cog-ha-matter::witness_signing` (same `ed25519-dalek` 2.x API, same +//! deterministic-test-key convention). SHA-256 matches the `sha256:` prefix +//! the manifest doc already declared for `wasm_module_hash`, and the +//! `cog-ha-matter` cog manifest's `binary_sha256` hex convention. +//! +//! ## Secure default +//! +//! [`PluginPolicy::trusted`] (the production constructor) **rejects**: +//! * an unsigned module (no hash / sig / key), +//! * a signature from a key not on the allowlist, +//! * any hash or signature mismatch. +//! +//! Only [`PluginPolicy::AllowUnsigned`] loosens this, and every load it +//! waves through emits a `warn`-level log line so it cannot pass silently. + +use base64::Engine as _; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use sha2::{Digest, Sha256}; + +use crate::error::PluginError; +use crate::manifest::PluginManifest; + +/// Trust policy governing which plugins may load. +/// +/// The production path uses [`PluginPolicy::trusted`] with an explicit +/// allowlist of publisher verifying keys. [`PluginPolicy::AllowUnsigned`] +/// is the dev escape hatch — it loads anything (even unsigned modules) but +/// logs a loud warning per load. +#[derive(Debug, Clone)] +pub enum PluginPolicy { + /// Secure default: a plugin loads only if its module hash matches, its + /// Ed25519 signature verifies, AND its publisher key is in this + /// allowlist. Each entry is the 32-byte raw Ed25519 verifying key. + Trusted { allowlist: Vec<[u8; 32]> }, + /// Dev-only: skip signature/allowlist enforcement. Hash is still + /// checked when a `wasm_module_hash` is present (cheap integrity), but + /// unsigned / unknown-publisher modules are allowed. Every load logs a + /// loud `warn`. + AllowUnsigned, +} + +impl PluginPolicy { + /// Construct the secure (production) policy from a list of trusted + /// publisher keys, each encoded as `ed25519:` (the same form + /// the manifest `publisher_key` uses). + pub fn trusted(publisher_keys: &[&str]) -> Result { + let mut allowlist = Vec::with_capacity(publisher_keys.len()); + for k in publisher_keys { + allowlist.push(decode_verifying_key(k)?.to_bytes()); + } + Ok(PluginPolicy::Trusted { allowlist }) + } + + /// Secure policy that trusts no publisher at all — every signed or + /// unsigned module is rejected. Useful as a strict default. + pub fn deny_all() -> Self { + PluginPolicy::Trusted { allowlist: vec![] } + } + + fn is_dev(&self) -> bool { + matches!(self, PluginPolicy::AllowUnsigned) + } + + fn allows(&self, key: &VerifyingKey) -> bool { + match self { + PluginPolicy::AllowUnsigned => true, + PluginPolicy::Trusted { allowlist } => { + allowlist.iter().any(|k| k == &key.to_bytes()) + } + } + } +} + +/// Verify a `.wasm` module's integrity and signature against its manifest, +/// under the given trust `policy`. Returns `Ok(())` only if the module may +/// be instantiated. +/// +/// On [`PluginPolicy::AllowUnsigned`] this still checks any present hash, +/// but waves through missing/untrusted signatures with a loud `warn`. +pub fn verify_module( + manifest: &PluginManifest, + wasm_bytes: &[u8], + policy: &PluginPolicy, +) -> Result<(), PluginError> { + let signed = manifest.wasm_module_hash.is_some() + || manifest.wasm_module_sig.is_some() + || manifest.publisher_key.is_some(); + + if !signed { + // No integrity material at all. + if policy.is_dev() { + eprintln!( + "[PLUGIN WARN] loading UNSIGNED plugin `{}` — no wasm_module_hash/sig/publisher_key. \ + AllowUnsigned dev policy is active; this is INSECURE and must not be used in production.", + manifest.domain + ); + return Ok(()); + } + return Err(PluginError::SignatureRejected(format!( + "plugin `{}` is unsigned (no wasm_module_hash/sig/publisher_key) and the trust policy \ + rejects unsigned modules; set PluginPolicy::AllowUnsigned to override in dev", + manifest.domain + ))); + } + + // (1) Hash check — always enforced when a hash is declared. + let digest = sha256_digest(wasm_bytes); + if let Some(declared) = &manifest.wasm_module_hash { + let expected = parse_sha256(declared)?; + if expected != digest { + return Err(PluginError::SignatureRejected(format!( + "plugin `{}` wasm hash mismatch: module does not match manifest wasm_module_hash \ + (tampered or wrong binary)", + manifest.domain + ))); + } + } else if !policy.is_dev() { + return Err(PluginError::SignatureRejected(format!( + "plugin `{}` carries a signature/publisher_key but no wasm_module_hash to bind it to", + manifest.domain + ))); + } + + // (2) Signature check + (3) allowlist. + match (&manifest.wasm_module_sig, &manifest.publisher_key) { + (Some(sig_str), Some(key_str)) => { + let key = decode_verifying_key(key_str)?; + let sig = decode_signature(sig_str)?; + key.verify(&digest, &sig).map_err(|_| { + PluginError::SignatureRejected(format!( + "plugin `{}` Ed25519 signature does not verify over the module hash under \ + publisher_key", + manifest.domain + )) + })?; + if !policy.allows(&key) { + if policy.is_dev() { + eprintln!( + "[PLUGIN WARN] plugin `{}` is validly signed but its publisher_key is NOT on \ + the trust allowlist; AllowUnsigned dev policy loads it anyway.", + manifest.domain + ); + return Ok(()); + } + return Err(PluginError::SignatureRejected(format!( + "plugin `{}` is validly signed but its publisher_key is not on the trust \ + allowlist (untrusted publisher)", + manifest.domain + ))); + } + Ok(()) + } + _ => { + // Hash present but signature/key incomplete. + if policy.is_dev() { + eprintln!( + "[PLUGIN WARN] plugin `{}` has a hash but no complete Ed25519 signature; \ + AllowUnsigned dev policy loads it anyway.", + manifest.domain + ); + return Ok(()); + } + Err(PluginError::SignatureRejected(format!( + "plugin `{}` is missing a complete wasm_module_sig + publisher_key pair; the trust \ + policy requires a valid signature", + manifest.domain + ))) + } + } +} + +/// SHA-256 of `bytes` as a 32-byte digest. +fn sha256_digest(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +/// Parse a `sha256:` manifest hash into a 32-byte digest. +fn parse_sha256(s: &str) -> Result<[u8; 32], PluginError> { + let hex_part = s.strip_prefix("sha256:").ok_or_else(|| { + PluginError::InvalidManifest(format!( + "wasm_module_hash must be `sha256:`, got {s:?}" + )) + })?; + let raw = hex::decode(hex_part).map_err(|e| { + PluginError::InvalidManifest(format!("wasm_module_hash hex decode: {e}")) + })?; + raw.try_into().map_err(|v: Vec| { + PluginError::InvalidManifest(format!( + "wasm_module_hash must decode to 32 bytes, got {}", + v.len() + )) + }) +} + +/// Decode an `ed25519:` 32-byte verifying key. +fn decode_verifying_key(s: &str) -> Result { + let b64 = s.strip_prefix("ed25519:").ok_or_else(|| { + PluginError::InvalidManifest(format!( + "publisher_key must be `ed25519:`, got {s:?}" + )) + })?; + let raw = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| PluginError::InvalidManifest(format!("publisher_key base64: {e}")))?; + let bytes: [u8; 32] = raw.try_into().map_err(|v: Vec| { + PluginError::InvalidManifest(format!( + "publisher_key must decode to 32 bytes, got {}", + v.len() + )) + })?; + VerifyingKey::from_bytes(&bytes) + .map_err(|e| PluginError::InvalidManifest(format!("publisher_key not a valid Ed25519 point: {e}"))) +} + +/// Decode an `ed25519:` 64-byte signature. +fn decode_signature(s: &str) -> Result { + let b64 = s.strip_prefix("ed25519:").ok_or_else(|| { + PluginError::InvalidManifest(format!( + "wasm_module_sig must be `ed25519:`, got {s:?}" + )) + })?; + let raw = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| PluginError::InvalidManifest(format!("wasm_module_sig base64: {e}")))?; + let bytes: [u8; 64] = raw.try_into().map_err(|v: Vec| { + PluginError::InvalidManifest(format!( + "wasm_module_sig must decode to 64 bytes, got {}", + v.len() + )) + })?; + Ok(Signature::from_bytes(&bytes)) +} + +/// Encode a SHA-256 digest as the manifest `sha256:` form. Exposed so +/// tooling (and tests) can produce a manifest hash for real `.wasm` bytes. +pub fn encode_sha256(wasm_bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(sha256_digest(wasm_bytes))) +} + +/// Encode an Ed25519 verifying key as the manifest `ed25519:` form. +pub fn encode_verifying_key(key: &VerifyingKey) -> String { + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key.to_bytes()) + ) +} + +/// Encode an Ed25519 signature as the manifest `ed25519:` form. +pub fn encode_signature(sig: &Signature) -> String { + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(sig.to_bytes()) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + /// Deterministic publisher key (mirrors witness_signing's fixed-bytes + /// seed convention — DO NOT use in production). + fn publisher() -> SigningKey { + SigningKey::from_bytes(b"homecore-plugins-pub-test-seed--") + } + + fn attacker() -> SigningKey { + SigningKey::from_bytes(b"homecore-plugins-attacker-seed--") + } + + /// Sign `wasm_bytes` with `key` and produce a manifest carrying the real + /// hash + signature + publisher key. + fn signed_manifest(wasm_bytes: &[u8], key: &SigningKey) -> PluginManifest { + let digest = sha256_digest(wasm_bytes); + let sig = key.sign(&digest); + PluginManifest { + domain: "demo".into(), + name: "Demo".into(), + version: "1.0.0".into(), + documentation: None, + iot_class: None, + config_flow: false, + integration_type: None, + dependencies: vec![], + requirements: vec![], + wasm_module: Some("demo.wasm".into()), + wasm_module_hash: Some(encode_sha256(wasm_bytes)), + wasm_module_sig: Some(encode_signature(&sig)), + publisher_key: Some(encode_verifying_key(&key.verifying_key())), + min_homecore_version: None, + host_imports_required: vec![], + homecore_permissions: vec![], + cog_id: None, + } + } + + #[test] + fn valid_sig_from_trusted_key_passes() { + let wasm = b"\0asm\x01\0\0\0fake module bytes"; + let key = publisher(); + let manifest = signed_manifest(wasm, &key); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap(); + verify_module(&manifest, wasm, &policy).expect("trusted signed module should load"); + } + + #[test] + fn tampered_module_is_rejected() { + let wasm = b"\0asm\x01\0\0\0fake module bytes"; + let key = publisher(); + let manifest = signed_manifest(wasm, &key); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap(); + // Flip a byte: hash no longer matches. + let tampered = b"\0asm\x01\0\0\0FAKE module bytes"; + let err = verify_module(&manifest, tampered, &policy).unwrap_err(); + assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}"); + } + + #[test] + fn valid_sig_from_untrusted_key_is_rejected() { + let wasm = b"\0asm\x01\0\0\0fake module bytes"; + // Signed correctly by the attacker, but the attacker is not trusted. + let manifest = signed_manifest(wasm, &attacker()); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap(); + let err = verify_module(&manifest, wasm, &policy).unwrap_err(); + assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}"); + } + + #[test] + fn forged_signature_is_rejected() { + // Manifest claims the trusted publisher_key but the signature was + // produced by the attacker (a forged sig under a trusted identity). + let wasm = b"\0asm\x01\0\0\0fake module bytes"; + let digest = sha256_digest(wasm); + let forged = attacker().sign(&digest); + let mut manifest = signed_manifest(wasm, &publisher()); + manifest.wasm_module_sig = Some(encode_signature(&forged)); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&publisher().verifying_key())]).unwrap(); + let err = verify_module(&manifest, wasm, &policy).unwrap_err(); + assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}"); + } + + #[test] + fn unsigned_module_rejected_under_default_policy() { + let wasm = b"\0asm\x01\0\0\0unsigned"; + let manifest = PluginManifest { + domain: "u".into(), + name: "U".into(), + version: "1".into(), + documentation: None, + iot_class: None, + config_flow: false, + integration_type: None, + dependencies: vec![], + requirements: vec![], + wasm_module: Some("u.wasm".into()), + wasm_module_hash: None, + wasm_module_sig: None, + publisher_key: None, + min_homecore_version: None, + host_imports_required: vec![], + homecore_permissions: vec![], + cog_id: None, + }; + let err = verify_module(&manifest, wasm, &PluginPolicy::deny_all()).unwrap_err(); + assert!(matches!(err, PluginError::SignatureRejected(_)), "got {err:?}"); + // ...but AllowUnsigned loads it (with a warn). + verify_module(&manifest, wasm, &PluginPolicy::AllowUnsigned) + .expect("AllowUnsigned should load an unsigned module"); + } +} diff --git a/v2/crates/homecore-plugins/src/wasmtime_runtime.rs b/v2/crates/homecore-plugins/src/wasmtime_runtime.rs index 023e6a67..3d4ca570 100644 --- a/v2/crates/homecore-plugins/src/wasmtime_runtime.rs +++ b/v2/crates/homecore-plugins/src/wasmtime_runtime.rs @@ -30,16 +30,27 @@ use wasmtime::{Engine, Linker, Module, Store}; use crate::error::PluginError; use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES}; +use crate::manifest::PluginManifest; +use crate::permissions::PermissionSet; +use crate::verify::{verify_module, PluginPolicy}; // ── Store data ───────────────────────────────────────────────────────────── /// Per-plugin state stored inside the Wasmtime [`Store`]. /// /// Wasmtime's `Store` exposes `T` to host functions via `caller.data()`. -/// We store the `HomeCore` handle and a list of subscribed entity IDs here. +/// We store the `HomeCore` handle, a list of subscribed entity IDs, and the +/// plugin's write-permission set (ADR-162 P5 authority isolation). pub struct PluginStoreData { pub hc: HomeCore, pub subscriptions: Vec, + /// Entity-write authority distilled from the manifest's + /// `homecore_permissions`. Consulted by `hc_state_set`. The + /// permission-free [`WasmtimeRuntime::load_wasm`] path installs an + /// all-allowing set for backward compatibility; the + /// [`WasmtimeRuntime::load_plugin`] path installs the manifest's + /// declared set. + pub permissions: PermissionSet, } // ── WasmtimeRuntime ──────────────────────────────────────────────────────── @@ -59,14 +70,53 @@ impl WasmtimeRuntime { Ok(Self { engine }) } - /// Compile and instantiate a WASM plugin from raw bytes. + /// Compile and instantiate a WASM plugin from raw bytes, **without** + /// signature verification or permission gating (the plugin gets + /// all-write authority). /// - /// Returns a [`WasmPlugin`] handle that owns the `Store` and the - /// `Instance`. The handle can be used to call into the WASM module. + /// Retained for the legacy/test path and first-party trusted modules. + /// Production plugin loading should go through [`Self::load_plugin`], + /// which verifies the module (ADR-162 P4) and scopes its write + /// authority to the manifest (P5). pub fn load_wasm( &self, wasm_bytes: &[u8], hc: HomeCore, + ) -> Result { + self.instantiate(wasm_bytes, hc, PermissionSet::allow_all()) + } + + /// Verify and instantiate a WASM plugin from its manifest + raw bytes. + /// + /// This is the secure load path (ADR-162): + /// 1. **P4** — [`verify_module`] checks the SHA-256 module hash and + /// Ed25519 signature against the manifest under `policy`. A + /// tampered module, bad/forged signature, untrusted publisher, or + /// (under the secure default) an unsigned module is rejected + /// **before** any guest code runs. + /// 2. **P5** — the plugin's `homecore_permissions` are distilled into + /// a [`PermissionSet`] installed in the store, so `hc_state_set` + /// can only write entities the plugin declared. + pub fn load_plugin( + &self, + manifest: &PluginManifest, + wasm_bytes: &[u8], + hc: HomeCore, + policy: &PluginPolicy, + ) -> Result { + // P4: verify before instantiation. + verify_module(manifest, wasm_bytes, policy)?; + // P5: scope write authority to the manifest's declared permissions. + let permissions = PermissionSet::from_manifest(manifest); + self.instantiate(wasm_bytes, hc, permissions) + } + + /// Shared compile + instantiate, installing the given permission set. + fn instantiate( + &self, + wasm_bytes: &[u8], + hc: HomeCore, + permissions: PermissionSet, ) -> Result { let module = Module::new(&self.engine, wasm_bytes) .map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?; @@ -77,6 +127,7 @@ impl WasmtimeRuntime { let store_data = PluginStoreData { hc, subscriptions: Vec::new(), + permissions, }; let mut store = Store::new(&self.engine, store_data); @@ -183,7 +234,9 @@ fn register_hc_state_get( /// Sets the state for the entity whose UTF-8 ID is at `[eid_ptr,eid_ptr+eid_len)`. /// The new state string is at `[state_ptr,state_ptr+state_len)`. /// The attributes JSON is at `[attrs_ptr,attrs_ptr+attrs_len)`. -/// Returns 0 on success, negative on error. +/// Returns 0 on success, negative on error: -1 (bad memory/args), -2 +/// (invalid entity id), -3 (permission denied — entity not in the +/// plugin's declared `homecore_permissions`, ADR-162 P5). fn register_hc_state_set( linker: &mut Linker, ) -> Result<(), PluginError> { @@ -224,6 +277,20 @@ fn register_hc_state_set( Ok(id) => id, Err(_) => return -2, }; + + // ── P5 authority isolation (ADR-162) ────────────────────── + // Reject a write to an entity the plugin did not declare in + // `homecore_permissions`. Return a typed error code to the + // guest (-3); do NOT panic the host. + if !caller.data().permissions.may_write(entity_id.as_str()) { + eprintln!( + "[PLUGIN WARN] denied hc_state_set on `{}` — not in plugin's declared \ + homecore_permissions (P5 authority isolation)", + entity_id.as_str() + ); + return -3; + } + let attrs: serde_json::Value = serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({})); diff --git a/v2/crates/homecore-plugins/tests/integration.rs b/v2/crates/homecore-plugins/tests/integration.rs index 88b1386e..34af267e 100644 --- a/v2/crates/homecore-plugins/tests/integration.rs +++ b/v2/crates/homecore-plugins/tests/integration.rs @@ -371,4 +371,259 @@ mod wasmtime_tests { let r = plugin.call_setup("{}").expect("setup"); assert_eq!(r, 0); } + + // ── ADR-162 P4: signature/integrity verification ──────────────────────── + // + // Each of these FAILS on the pre-ADR-162 code, which had no + // `load_plugin` / `verify_module` at all — the manifest hash/sig/key + // were parsed and discarded. They drive the real verification gate. + + use ed25519_dalek::{Signer, SigningKey}; + use homecore_plugins::manifest::PluginManifest; + use homecore_plugins::verify::{encode_sha256, encode_signature, encode_verifying_key}; + use homecore_plugins::PluginPolicy; + + /// Deterministic publisher key (fixed seed — never use in production; + /// mirrors the cog-ha-matter witness_signing test-key convention). + fn publisher_key() -> SigningKey { + SigningKey::from_bytes(b"hc-plugins-integration-pub-seed-") + } + + fn untrusted_key() -> SigningKey { + SigningKey::from_bytes(b"hc-plugins-integration-evil-seed") + } + + /// A minimal valid module that writes `light.kitchen` on setup, plus a + /// `light.*` permission grant. Returns the WAT source. + const WRITE_LIGHT_WAT: &str = r#" +(module + (import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32))) + (import "env" "hc_log" (func $hc_log (param i32 i32 i32))) + (memory (export "memory") 1) + (global $bump (mut i32) (i32.const 512)) + (data (i32.const 0) "light.kitchen") + (data (i32.const 64) "on") + (data (i32.const 128) "{}") + (func (export "alloc") (param i32) (result i32) + (local $p i32) + (local.set $p (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get 0))) + (local.get $p)) + (func (export "dealloc") (param i32 i32)) + (func (export "plugin_setup") (param i32 i32) (result i32) + (call $hc_state_set + (i32.const 0) (i32.const 13) ;; "light.kitchen" + (i32.const 64) (i32.const 2) ;; "on" + (i32.const 128) (i32.const 2)) ;; "{}" + drop + (i32.const 0)) + (func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0)) +) +"#; + + /// Build a manifest signed by `key` over the SHA-256 of `wasm_bytes`, + /// with the given write-permission grants. + fn signed_manifest( + wasm_bytes: &[u8], + key: &SigningKey, + perms: &[&str], + ) -> PluginManifest { + use sha2::{Digest, Sha256}; + let digest: [u8; 32] = Sha256::digest(wasm_bytes).into(); + let sig = key.sign(&digest); + let mut m = PluginManifest::parse_json( + r#"{"domain":"demo","name":"Demo","version":"1.0.0"}"#, + ) + .unwrap(); + m.wasm_module = Some("demo.wasm".into()); + m.wasm_module_hash = Some(encode_sha256(wasm_bytes)); + m.wasm_module_sig = Some(encode_signature(&sig)); + m.publisher_key = Some(encode_verifying_key(&key.verifying_key())); + m.homecore_permissions = perms.iter().map(|s| s.to_string()).collect(); + m + } + + #[test] + fn p4_valid_sig_from_trusted_key_loads() { + let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT"); + let key = publisher_key(); + let manifest = signed_manifest(&wasm, &key, &["light.*"]); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap(); + + let rt = WasmtimeRuntime::new().expect("rt"); + let hc = HomeCore::new(); + rt.load_plugin(&manifest, &wasm, hc, &policy) + .expect("a validly-signed, trusted plugin must load"); + } + + #[test] + fn p4_tampered_module_is_rejected() { + let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT"); + let key = publisher_key(); + // Manifest signs the original bytes; we then load DIFFERENT bytes. + let manifest = signed_manifest(&wasm, &key, &["light.*"]); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap(); + + // Re-compile a byte-different module (writes "off" not "on"). + let tampered_src = WRITE_LIGHT_WAT.replace(r#""on""#, r#""of""#); + let tampered = wat::parse_str(&tampered_src).expect("WAT"); + assert_ne!(wasm, tampered, "test bug: bytes must differ"); + + let rt = WasmtimeRuntime::new().expect("rt"); + let hc = HomeCore::new(); + match rt.load_plugin(&manifest, &tampered, hc, &policy) { + Err(homecore_plugins::PluginError::SignatureRejected(_)) => {} + Ok(_) => panic!("tampered module must be rejected (hash mismatch), but it loaded"), + Err(e) => panic!("expected SignatureRejected, got {e:?}"), + } + } + + #[test] + fn p4_valid_sig_from_untrusted_key_is_rejected() { + let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT"); + // Correctly signed by the untrusted key — but it is not on the allowlist. + let manifest = signed_manifest(&wasm, &untrusted_key(), &["light.*"]); + let policy = + PluginPolicy::trusted(&[&encode_verifying_key(&publisher_key().verifying_key())]) + .unwrap(); + + let rt = WasmtimeRuntime::new().expect("rt"); + let hc = HomeCore::new(); + match rt.load_plugin(&manifest, &wasm, hc, &policy) { + Err(homecore_plugins::PluginError::SignatureRejected(_)) => {} + Ok(_) => panic!("untrusted publisher must be rejected, but it loaded"), + Err(e) => panic!("expected SignatureRejected, got {e:?}"), + } + } + + #[test] + fn p4_unsigned_module_rejected_by_default_loads_only_under_allow_unsigned() { + let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT"); + let mut manifest = PluginManifest::parse_json( + r#"{"domain":"u","name":"U","version":"1"}"#, + ) + .unwrap(); + manifest.wasm_module = Some("u.wasm".into()); + manifest.homecore_permissions = vec!["light.*".into()]; + // No hash/sig/key → unsigned. + + let rt = WasmtimeRuntime::new().expect("rt"); + // Secure default: rejected. + match rt.load_plugin(&manifest, &wasm, HomeCore::new(), &PluginPolicy::deny_all()) { + Err(homecore_plugins::PluginError::SignatureRejected(_)) => {} + Ok(_) => panic!("unsigned module must be rejected under the secure default"), + Err(e) => panic!("expected SignatureRejected, got {e:?}"), + } + // Dev escape hatch: loads (with a loud warn). + rt.load_plugin( + &manifest, + &wasm, + HomeCore::new(), + &PluginPolicy::AllowUnsigned, + ) + .expect("AllowUnsigned dev policy must load an unsigned module"); + } + + // ── ADR-162 P5: authority / capability isolation ──────────────────────── + // + // FAILS on the pre-ADR-162 code, where `hc_state_set` ignored + // `homecore_permissions` entirely and let any plugin write any entity. + + /// Module that writes `lock.front_door` on setup (an over-privileged + /// write a `light.*` plugin must NOT be allowed to perform). + const WRITE_LOCK_WAT: &str = r#" +(module + (import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32))) + (import "env" "hc_log" (func $hc_log (param i32 i32 i32))) + (memory (export "memory") 1) + (global $bump (mut i32) (i32.const 512)) + (data (i32.const 0) "lock.front_door") + (data (i32.const 64) "unlocked") + (data (i32.const 128) "{}") + (func (export "alloc") (param i32) (result i32) + (local $p i32) + (local.set $p (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get 0))) + (local.get $p)) + (func (export "dealloc") (param i32 i32)) + ;; plugin_setup returns the hc_state_set result code so the host test can + ;; assert the guest saw the typed permission-denied error (-3). + (func (export "plugin_setup") (param i32 i32) (result i32) + (call $hc_state_set + (i32.const 0) (i32.const 15) ;; "lock.front_door" + (i32.const 64) (i32.const 8) ;; "unlocked" + (i32.const 128) (i32.const 2))) ;; "{}" + (func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0)) +) +"#; + + #[test] + fn p5_declared_light_plugin_may_write_light_but_not_lock() { + let key = publisher_key(); + let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap(); + let rt = WasmtimeRuntime::new().expect("rt"); + + // (a) A `light.*` plugin writing `light.kitchen` → ALLOWED. + let light_wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT"); + let light_manifest = signed_manifest(&light_wasm, &key, &["light.*"]); + let hc_a = HomeCore::new(); + let plugin_a = rt + .load_plugin(&light_manifest, &light_wasm, hc_a.clone(), &trusted) + .expect("light plugin loads"); + let r = plugin_a.call_setup("{}").expect("setup"); + assert_eq!(r, 0, "write to declared light.kitchen should succeed"); + let kitchen = homecore::EntityId::parse("light.kitchen").unwrap(); + assert_eq!( + hc_a.states().get(&kitchen).expect("light.kitchen written").state, + "on" + ); + + // (b) The SAME `light.*` plugin attempting to write `lock.front_door` + // → REJECTED with the typed -3 code, and the lock is NOT written. + let lock_wasm = wat::parse_str(WRITE_LOCK_WAT).expect("WAT"); + let lock_manifest = signed_manifest(&lock_wasm, &key, &["light.*"]); + let hc_b = HomeCore::new(); + let plugin_b = rt + .load_plugin(&lock_manifest, &lock_wasm, hc_b.clone(), &trusted) + .expect("module loads (verification ok); the WRITE is what's gated"); + let denied = plugin_b.call_setup("{}").expect("setup runs without trapping host"); + assert_eq!( + denied, -3, + "over-privileged write to lock.front_door must return -3 (permission denied)" + ); + let lock = homecore::EntityId::parse("lock.front_door").unwrap(); + assert!( + hc_b.states().get(&lock).is_none(), + "lock.front_door must NOT have been written by a light-only plugin" + ); + } + + #[test] + fn p5_plugin_with_no_permissions_can_write_nothing() { + let key = publisher_key(); + let trusted = PluginPolicy::trusted(&[&encode_verifying_key(&key.verifying_key())]).unwrap(); + let rt = WasmtimeRuntime::new().expect("rt"); + + let wasm = wat::parse_str(WRITE_LIGHT_WAT).expect("WAT"); + // No permissions declared at all. + let manifest = signed_manifest(&wasm, &key, &[]); + let hc = HomeCore::new(); + let plugin = rt + .load_plugin(&manifest, &wasm, hc.clone(), &trusted) + .expect("module loads; the write is gated"); + // WRITE_LIGHT_WAT drops the host-import result and returns 0, so we + // assert the denial via the side-effect: the write must NOT land. + plugin.call_setup("{}").expect("setup runs without trapping host"); + let kitchen = homecore::EntityId::parse("light.kitchen").unwrap(); + assert!( + hc.states().get(&kitchen).is_none(), + "no-permission plugin must not write light.kitchen (P5 authority isolation)" + ); + } }