plugin/host/tests/an_unsigned_module_stops_the_boot.rs
//! **A module that cannot prove where it came from stops the boot — it is never
//! quietly left out of the catalog.**
//!
//! Until `verify.rs` landed there was no provenance check on a `.wasm` at all:
//! anything in `ron_plugin_dir` was compiled and served, so write access to one
//! directory was code execution inside an artifact server whose entire pitch is
//! airgap provenance.
//!
//! The failure shape matters as much as the check. `registry.rs` already states
//! the rule for a missing plugin directory — absence is LOUD — and this is the
//! same rule one level down: a module that fails verification is a
//! [`ScanError::UntrustedModule`], which `server/lib`'s `handler_catalog` turns
//! into a boot error. A catalog that merely came back one handler shorter would
//! surface in production as a 404 on a repository the operator can read in the
//! RON file.
//!
//! Signing here uses the constellation's own CMS signer (`znippy_common::sign`),
//! the same one that produces the detached signatures for znippy archives. No
//! crypto is written, in the host or in the test.
use std::path::{Path, PathBuf};
use holger_plugin_host::registry::ScanError;
use holger_plugin_host::verify::ModuleTrust;
use holger_plugin_host::{HandlerCatalog, PluginPolicy};
use znippy_common::sign::{dev, ArchiveSigner};
/// The smallest thing the host will accept as a handler: every ABI export, a
/// linear memory, and a manifest it can actually decode. Verification runs
/// BEFORE any of that matters, but a module that could not load for an unrelated
/// reason would make the positive assertion below meaningless.
fn signable_module_bytes() -> Vec<u8> {
// `plugin_manifest` returns a pointer to a wire-encoded manifest laid into
// data at offset 0; `result_len` reports its length. Encoded here rather than
// built from a handler crate so this test does not depend on one.
let manifest = holger_plugin_abi::WireManifest {
abi_version: holger_plugin_abi::ABI_VERSION,
handler: "signed-probe".to_string(),
format: "raw".to_string(),
writable: false,
identity: None,
}
.to_bytes();
let hex: String = manifest.iter().map(|b| format!("\\{b:02x}")).collect();
let wat = format!(
r#"(module
(memory (export "memory") 1)
(data (i32.const 0) "{hex}")
(func (export "alloc") (param i32) (result i32) (i32.const 4096))
(func (export "result_len") (result i32) (i32.const {len}))
(func (export "plugin_manifest") (result i32) (i32.const 0))
(func (export "plugin_fetch") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_put") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_list") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_coordinate_for_path") (param i32 i32) (result i32) (i32.const 0))
(func (export "plugin_http") (param i32 i32) (result i32) (i32.const 0))
)"#,
len = manifest.len()
);
wat::parse_str(wat).expect("the probe module does not assemble")
}
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"holger-plugin-trust-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).expect("scratch dir");
p
}
/// A plugin directory holding one module, plus a trust policy rooted at a fresh
/// CA. `sign` decides whether the module gets its detached signature.
fn staged(name: &str, sign: bool) -> (PathBuf, PluginPolicy) {
let dir = scratch(name);
let module = dir.join("probe.wasm");
let bytes = signable_module_bytes();
std::fs::write(&module, &bytes).expect("write module");
let (ca_key, ca_der) = dev::mint_ca("holger plugin test CA").expect("mint CA");
let root = dir.join("root.der");
std::fs::write(&root, &ca_der).expect("write root");
if sign {
let signer =
dev::new_p256_signer(&ca_key, &ca_der, "holger plugin publisher").expect("signer");
// The digest the host verifies: BLAKE3-256 over the module's bytes.
let digest: [u8; 32] = *znippy_common::blake3::hash(&bytes).as_bytes();
let cms = signer.sign_digest(&digest).expect("sign");
std::fs::write(ModuleTrust::signature_path(&module), cms).expect("write signature");
}
(
dir,
PluginPolicy::new().with_trust(ModuleTrust::required([root])),
)
}
/// THE assertion this file exists for.
#[test]
fn an_unsigned_module_is_a_scan_error_not_a_shorter_catalog() {
let (dir, policy) = staged("unsigned", false);
let mut catalog = HandlerCatalog::new();
let err = catalog
.load_dir_with(&dir, &policy)
.expect_err("an unsigned module was accepted into the catalog");
assert!(
matches!(err, ScanError::UntrustedModule { .. }),
"wrong variant: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains(holger_errcode::PLUGIN_MODULE_UNSIGNED.code),
"the refusal carries no frozen code: {msg}"
);
assert!(
msg.contains("probe.wasm"),
"the refusal does not name the module: {msg}"
);
// And the catalog really is unchanged — this is the half that distinguishes
// "refused" from "dropped".
assert!(
catalog.is_empty(),
"the refused module was registered anyway: {:?}",
catalog.accepted()
);
}
/// A signature that verifies lets the module through, so the test above is
/// measuring verification and not merely "trust required means nothing loads".
#[test]
fn a_module_signed_by_a_trusted_key_loads() {
let (dir, policy) = staged("signed", true);
let mut catalog = HandlerCatalog::new();
let rejected = catalog
.load_dir_with(&dir, &policy)
.expect("a correctly signed module was refused");
assert!(
rejected.is_empty(),
"the signed module was rejected: {rejected:?}"
);
assert_eq!(catalog.accepted(), vec!["signed-probe".to_string()]);
}
/// One flipped byte and the same signature no longer verifies — the difference
/// between checking a signature and counting one.
#[test]
fn a_tampered_module_under_a_valid_signature_is_refused() {
let (dir, policy) = staged("tampered", true);
let module = dir.join("probe.wasm");
let mut bytes = std::fs::read(&module).expect("read module");
// Flip a byte in the manifest DATA, not the header: the module still parses
// and still loads, so the ONLY thing that can catch this is the signature.
let last = bytes.len() - 1;
bytes[last] ^= 0xff;
std::fs::write(&module, &bytes).expect("rewrite module");
let mut catalog = HandlerCatalog::new();
let err = catalog
.load_dir_with(&dir, &policy)
.expect_err("a tampered module was accepted");
assert!(
matches!(err, ScanError::UntrustedModule { .. }),
"wrong variant: {err:?}"
);
assert!(
err.to_string()
.contains(holger_errcode::PLUGIN_SIGNATURE_INVALID.code),
"{err}"
);
assert!(catalog.is_empty());
}
/// A signature from a key the operator never trusted does not verify, even
/// though it is a perfectly valid signature over exactly these bytes.
#[test]
fn a_signature_from_an_untrusted_ca_is_refused() {
let dir = scratch("wrong-ca");
let module = dir.join("probe.wasm");
let bytes = signable_module_bytes();
std::fs::write(&module, &bytes).expect("write module");
// Signed by one CA…
let (their_key, their_der) = dev::mint_ca("somebody else's CA").expect("mint CA");
let signer = dev::new_p256_signer(&their_key, &their_der, "stranger").expect("signer");
let digest: [u8; 32] = *znippy_common::blake3::hash(&bytes).as_bytes();
std::fs::write(
ModuleTrust::signature_path(&module),
signer.sign_digest(&digest).expect("sign"),
)
.expect("write signature");
// …trusted against another.
let (_, ours_der) = dev::mint_ca("holger plugin test CA").expect("mint CA");
let root = dir.join("root.der");
std::fs::write(&root, &ours_der).expect("write root");
let policy = PluginPolicy::new().with_trust(ModuleTrust::required([root]));
let mut catalog = HandlerCatalog::new();
let err = catalog
.load_dir_with(&dir, &policy)
.expect_err("a stranger's signature passed");
assert!(
err.to_string()
.contains(holger_errcode::PLUGIN_SIGNATURE_INVALID.code),
"{err}"
);
}
/// An unreadable trusted root is NOT an empty root set. Fails closed, by its own
/// code, rather than verifying against fewer roots than the operator named.
#[test]
fn an_unreadable_trusted_root_fails_closed_under_its_own_code() {
let (dir, _) = staged("bad-root", true);
let policy = PluginPolicy::new()
.with_trust(ModuleTrust::required([
Path::new("/nonexistent/root.der").to_path_buf()
]));
let mut catalog = HandlerCatalog::new();
let err = catalog
.load_dir_with(&dir, &policy)
.expect_err("a missing root verified anyway");
assert!(
err.to_string()
.contains(holger_errcode::PLUGIN_TRUST_ROOTS_UNREADABLE.code),
"{err}"
);
}
/// With no trust configured, nothing changes: this is the default the agreement
/// harness and the router guards run under, and they must keep loading modules
/// that carry no signature at all.
#[test]
fn the_default_policy_still_loads_an_unsigned_module() {
let (dir, _) = staged("default", false);
let mut catalog = HandlerCatalog::new();
let rejected = catalog
.load_dir_with(&dir, &PluginPolicy::default())
.expect("the default policy refused an unsigned module");
assert!(rejected.is_empty(), "{rejected:?}");
assert_eq!(catalog.accepted(), vec!["signed-probe".to_string()]);
}