mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
feat(homecore-plugins): enforce plugin signature + capability isolation (ADR-162 P4/P5)
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:<glob> 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 <ruv@ruv.net>
This commit is contained in:
@@ -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<T>` 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<String>,
|
||||
/// 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<WasmPlugin, PluginError> {
|
||||
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<WasmPlugin, PluginError> {
|
||||
// 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<WasmPlugin, PluginError> {
|
||||
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<PluginStoreData>,
|
||||
) -> 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!({}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user