Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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()]);
}