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

nordisk/monetize

crates/monetize-cloud-wasm/tests/cloud_security.rs

//! **The cloud guest host's rules against a hostile module, one place** (P5-SEC,
//! `security.rule-tests`).
//!
//! A cloud guest spends money at a provider under a ceiling kept by name, so the host must
//! know exactly which module it runs and under which name. Each test names one rule and was
//! seen to fail against a deliberately broken check before it passed (the breaks are in
//! /work/handback/p5-sec.md):
//!
//! | rule | the check | what is refused |
//! |---|---|---|
//! | a cloud module nobody vouched for does not load (unauthenticated) | the load order, steps 1 and 3 | no roots; a stranger's signature |
//! | bytes changed after signing do not load (forged) | `ModuleTrust`, and the `guest_digest` pin | one byte appended |
//! | a module for another cloud does not load under this name (wrong tenant: its spend would land under another ceiling) | the manifest name | `upcloud` under `[clouds.wat]` |
//! | a product module does not load as a cloud | the kind gate (`kinds::CLOUD`) | a `product` manifest |
//! | a DNS block loads only its own vendor's writer (wrong tenant) | `dns::names_vendor` | `cloudflareholger`, `other` |
//! | a module that wants more memory than the block allows does not instantiate (oversized) | `guest_max_memory_bytes` | 4 pages under a 1-page cap |
//!
//! Replay does not apply to this crate: it holds no nonce, sequence or once-only check (the
//! journal is a write-ahead log, not a replay guard). Hand-written WAT, no `build-guests`.

use std::path::{Path, PathBuf};

use monetize_cloud_wasm::dns::names_vendor;
use monetize_cloud_wasm::GuestCloud;

fn scratch(name: &str) -> PathBuf {
    let p = std::env::temp_dir().join(format!(
        "monetize-cloud-sec-{name}-{}-{:?}",
        std::process::id(),
        std::thread::current().id()
    ));
    let _ = std::fs::remove_dir_all(&p);
    std::fs::create_dir_all(&p).unwrap();
    p
}

/// A guest speaking the convention with a canned manifest; `plugin_call` answers `{"Ok":"x"}`
/// to every op, so a module that passes the whole load order is refused at `verify` (it cannot
/// prove it reaches a provider). That refusal is the control: everything before it held.
fn wat_cloud(product: &str, kind: &str) -> Vec<u8> {
    let m = format!(
        r#"{{"abi_min":1,"abi_max":1,"product":"{product}","kind":"{kind}","grants":[],"identity":false}}"#
    );
    let src = format!(
        r#"(module
  (memory (export "memory") 4)
  (data (i32.const 1024) "{esc}")
  (data (i32.const 4096) "{{\"Ok\":\"x\"}}")
  (global $bump (mut i32) (i32.const 8192))
  (func (export "alloc") (param $n i32) (result i32)
    (local $p i32)
    (local.set $p (global.get $bump))
    (global.set $bump (i32.add (global.get $bump) (local.get $n)))
    (local.get $p))
  (func (export "dealloc") (param i32 i32))
  (func (export "result_len") (result i32) (i32.const {mlen}))
  (func (export "plugin_abi") (result i64) (i64.const {abi}))
  (func (export "plugin_manifest") (result i32) (i32.const 1024))
  (func (export "plugin_call") (param i32 i32 i32) (result i64)
    (i64.or (i64.shl (i64.const 4096) (i64.const 32)) (i64.const 10)))
)"#,
        esc = m.replace('"', "\\\""),
        mlen = m.len(),
        abi = (1u64 << 32) | 1,
    );
    wat::parse_str(src).expect("valid wat")
}

/// Sign `module` under a CA of its own, written beside it; answers the root.
fn vouched(module: &Path) -> PathBuf {
    use guest_host::verify::sign::{self, dev};
    let (ca_key, ca_der) = dev::mint_ca("test-plugin-root").unwrap();
    let mut root = module.as_os_str().to_owned();
    root.push(".root.der");
    let root = PathBuf::from(root);
    std::fs::write(&root, &ca_der).unwrap();
    sign::sign_module_file(
        module,
        &dev::new_ed25519_signer(&ca_key, &ca_der, "test-plugin-signing").unwrap(),
    )
    .unwrap();
    root
}

/// `module` written, signed, and a `[clouds.*]` block naming it, pinned by digest.
fn block(dir: &Path, file: &str, bytes: &[u8]) -> (PathBuf, serde_json::Value) {
    let module = dir.join(file);
    std::fs::write(&module, bytes).unwrap();
    let root = vouched(&module);
    let b = serde_json::json!({
        "guest": module,
        "guest_digest": guest_host::digest_hex(bytes),
        "guest_trust_roots": [root],
        "grants": [],
    });
    (module, b)
}

fn refused(name: &str, dir: &Path, b: &serde_json::Value) -> String {
    match GuestCloud::build(name, dir, b) {
        Ok(_) => panic!("[clouds.{name}] loaded"),
        Err(e) => e,
    }
}

/// The control: signed, pinned, named and kinded right, it passes the whole load order and is
/// stopped only at `verify`.
#[test]
fn a_vouched_cloud_module_passes_the_load_order() {
    let dir = scratch("ok");
    let (_, b) = block(&dir, "wat.wasm", &wat_cloud("wat", "cloud"));
    let e = refused("wat", &dir, &b);
    assert!(
        e.contains("could not prove it can reach the provider"),
        "the load order held for the control: {e}"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// RULE: nobody vouched, nothing runs: no roots, or a signature under a root the block does
/// not name.
#[test]
fn a_cloud_module_nobody_vouched_for_is_refused() {
    let dir = scratch("unvouched");
    let (_, mut b) = block(&dir, "wat.wasm", &wat_cloud("wat", "cloud"));
    b["guest_trust_roots"] = serde_json::json!([]);
    let e = refused("wat", &dir, &b);
    assert!(
        e.contains("[clouds.wat]") && e.contains("nobody vouches"),
        "{e}"
    );

    let (module, b) = block(&dir, "s.wasm", &wat_cloud("wat", "cloud"));
    use guest_host::verify::sign::{self, dev};
    let (k, der) = dev::mint_ca("stranger-ca").unwrap();
    sign::sign_module_file(
        &module,
        &dev::new_ed25519_signer(&k, &der, "stranger").unwrap(),
    )
    .unwrap();
    let e = refused("wat", &dir, &b);
    assert!(e.contains("did not verify"), "{e}");
    let _ = std::fs::remove_dir_all(&dir);
}

/// RULE: bytes changed after signing are refused by the pin, and by the signature alone.
#[test]
fn a_tampered_cloud_module_is_refused() {
    let dir = scratch("tamper");
    let (module, mut b) = block(&dir, "wat.wasm", &wat_cloud("wat", "cloud"));
    let mut bytes = std::fs::read(&module).unwrap();
    bytes.push(0);
    std::fs::write(&module, &bytes).unwrap();
    let e = refused("wat", &dir, &b);
    assert!(e.contains("not the bytes the operator vouched for"), "{e}");
    b.as_object_mut().unwrap().remove("guest_digest");
    let e = refused("wat", &dir, &b);
    assert!(e.contains("did not verify"), "{e}");
    let _ = std::fs::remove_dir_all(&dir);
}

/// RULE: a module that says it is another cloud does not load under this name.
#[test]
fn a_module_for_another_cloud_is_refused() {
    let dir = scratch("other");
    let (_, b) = block(&dir, "up.wasm", &wat_cloud("upcloud", "cloud"));
    let e = refused("wat", &dir, &b);
    assert!(
        e.contains("[clouds.wat]") && e.contains("`upcloud`") && e.contains("refused"),
        "{e}"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// RULE: a product module is not a cloud.
#[test]
fn a_product_module_is_refused_as_a_cloud() {
    let dir = scratch("kind");
    let (_, b) = block(&dir, "p.wasm", &wat_cloud("wat", "product"));
    let e = refused("wat", &dir, &b);
    assert!(e.contains("[guest-wrong-kind]"), "{e}");
    let _ = std::fs::remove_dir_all(&dir);
}

/// RULE: a DNS block loads its own vendor's writer, or a zone of it, and nothing that merely
/// shares the vendor's letters.
#[test]
fn a_dns_block_names_only_its_own_vendor() {
    assert!(names_vendor("cloudflare", "cloudflare"));
    assert!(
        names_vendor("cloudflare-holger", "cloudflare"),
        "a zone of the vendor"
    );
    assert!(
        !names_vendor("cloudflareholger", "cloudflare"),
        "shares the letters only"
    );
    assert!(!names_vendor("cloudflare-", "cloudflare"), "an empty zone");
    assert!(!names_vendor("other", "cloudflare"), "another vendor");
    assert!(
        !names_vendor("cloudflare", "cloudflare-holger"),
        "a zone does not name its vendor"
    );
}

/// RULE: the block's memory cap holds: a module declaring 4 pages under a 1-page cap does
/// not instantiate, so it never reaches `verify`.
#[test]
fn a_module_over_the_memory_cap_is_refused() {
    let dir = scratch("mem");
    let (_, mut b) = block(&dir, "wat.wasm", &wat_cloud("wat", "cloud"));
    b["guest_max_memory_bytes"] = serde_json::json!(65536);
    let e = refused("wat", &dir, &b);
    assert!(
        !e.contains("could not prove"),
        "it must not get as far as verify: {e}"
    );
    assert!(e.contains("[clouds.wat]"), "{e}");
    let _ = std::fs::remove_dir_all(&dir);
}