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