crates/monetize-product-wasm/tests/guest_security.rs
//! **The guest host's rules against a hostile guest or caller, one place** (P5-SEC,
//! `security.rule-tests`).
//!
//! The product guest host is a door the other way round: what crosses it is a module the server
//! is asked to run, and what it reaches through is the host's doors. 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 module nobody vouched for does not load (unauthenticated) | `load.rs` step 1 and 3 (`ModuleTrust::required`) | no roots; signed by a stranger |
//! | a module that says it is another product does not load (wrong tenant) | `load.rs` step 6 (manifest product) | `other` under `[products.wat]` |
//! | bytes changed after signing do not load (forged) | `ModuleTrust` (CMS over blake3), and the `guest_digest` pin | one byte appended |
//! | a door the block did not grant is not on the linker | `load.rs` step 5 (`grants::for_import`) | `host_http` with no `http` grant |
//! | a nonce is admitted once (replay) | `Doors::state_admit` | the same ticket nonce twice |
//! | a secret the block does not name is not compared against | `Doors::hmac_verify` | an un-named secret |
//! | the host bounds what a guest can make it hold (oversized) | `state_put`, `hmac_verify`, `secrets::read` | 64 KiB + 1 of state, 9 candidates, a secret over 256 KiB |
//!
//! Hand-written WAT and the doors themselves: no `cargo xtask build-guests` is needed.
mod common;
use std::path::Path;
use std::time::Duration;
use common::*;
use monetize_product::GuestSpec;
use monetize_product_guest::wire::HmacHead;
use monetize_product_wasm::doors::{DoorSpec, Doors, MAX_STATE_BYTES};
use monetize_product_wasm::{Block, LoadedGuest};
fn manifest(product: &str) -> String {
format!(r#"{{"abi_min":1,"abi_max":1,"product":"{product}","grants":[],"identity":false}}"#)
}
/// A module written to `dir/name`, signed under its own root and pinned by digest.
fn signed(dir: &Path, name: &str, bytes: &[u8]) -> GuestSpec {
let module = dir.join(name);
std::fs::write(&module, bytes).unwrap();
GuestSpec {
digest: Some(digest_of(&module)),
trust_roots: vouched(&module),
module,
..Default::default()
}
}
fn load(spec: &GuestSpec) -> Result<LoadedGuest, String> {
LoadedGuest::load_block(Block {
section: "products",
id: "wat",
spec: Some(spec),
kind: "product",
})
}
/// The control: a signed, pinned, well-named WAT guest loads. Every refusal below is
/// therefore the rule's, not a guest that could never have loaded.
#[test]
fn a_vouched_guest_under_its_own_name_loads() {
let dir = scratch("sec-ok");
load(&signed(
&dir,
"ok.wasm",
&wat_guest(&manifest("wat"), 1, 1, ""),
))
.expect("the control loads");
let _ = std::fs::remove_dir_all(&dir);
}
/// RULE: nobody vouched, nothing runs. No roots at all; and a signature that chains to a
/// root the block does not name.
#[test]
fn a_guest_nobody_vouched_for_is_refused() {
let dir = scratch("sec-unvouched");
let mut spec = signed(&dir, "g.wasm", &wat_guest(&manifest("wat"), 1, 1, ""));
spec.trust_roots.clear();
let e = load(&spec).err().expect("no roots");
assert!(e.contains("[guest-unsigned]"), "{e}");
// Signed, but by a stranger: the block trusts only its own root.
let spec = signed(&dir, "s.wasm", &wat_guest(&manifest("wat"), 1, 1, ""));
let (_stranger_root, stranger) = mint_signer(&dir, "stranger");
guest_host::verify::sign::sign_module_file(&spec.module, &stranger).unwrap();
let e = load(&spec).err().expect("a stranger's signature");
assert!(e.contains("did not verify"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// RULE: a guest that says it is another product does not load under this one's block (it
/// would meter one product's tenants as another's).
#[test]
fn a_guest_for_another_product_is_refused() {
let dir = scratch("sec-other");
let e = load(&signed(
&dir,
"o.wasm",
&wat_guest(&manifest("other"), 1, 1, ""),
))
.err()
.expect("another product");
assert!(e.contains("its manifest says it is `other`"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// RULE: bytes changed after they were signed are refused by the signature, and again by
/// the digest pin when the block has one.
#[test]
fn a_tampered_guest_is_refused_by_the_signature_and_by_the_pin() {
let dir = scratch("sec-tamper");
let mut spec = signed(&dir, "t.wasm", &wat_guest(&manifest("wat"), 1, 1, ""));
let mut bytes = std::fs::read(&spec.module).unwrap();
bytes.push(0);
std::fs::write(&spec.module, &bytes).unwrap();
let e = load(&spec).err().expect("tampered, pinned");
assert!(e.contains("not the bytes the operator vouched for"), "{e}");
spec.digest = None;
let e = load(&spec).err().expect("tampered, signature only");
assert!(e.contains("did not verify"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A WAT guest like [`wat_guest`] that also imports `env::<import>`.
fn importing(import: &str) -> Vec<u8> {
let m = manifest("wat");
let src = format!(
r#"(module
(import "env" "{import}" (func (param i32 i32) (result i64)))
(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")
}
/// RULE: a door the block does not grant is not on the guest's linker: a module that asks for
/// `host_http` with no `http` grant does not load.
#[test]
fn a_door_the_block_did_not_grant_is_refused() {
let dir = scratch("sec-grant");
let e = load(&signed(&dir, "h.wasm", &importing("host_http")))
.err()
.expect("http not granted");
assert!(
e.contains("needs the grant `http`") && e.contains("NOT DEFINED"),
"{e}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn doors<'a>(names: &'a [String], secrets: &'a Path, grants: &'a [String]) -> Doors {
Doors::open_with(DoorSpec {
product: "sec",
endpoint: "",
bearer: None,
twin: None,
tls_ca: None,
tls_client: None,
grants,
http_hosts: &[],
grpc_hosts: &[],
call_timeout: Duration::from_secs(5),
http_auth: &[],
hmac_secrets: names,
secrets_dir: Some(secrets),
http_resolve: &[],
})
.expect("the doors open")
}
fn secret(dir: &Path, name: &str, bytes: &[u8]) {
monetize_product_wasm::secrets::write(dir, name, bytes).expect("a secret is written 0600");
}
/// RULE: the host's ticket-nonce ledger admits a nonce once; the second presentation is a
/// replay.
#[test]
fn a_nonce_is_admitted_once() {
let dir = tempfile::tempdir().unwrap();
let d = doors(&[], dir.path(), &[]);
assert!(
d.state_admit("ticket.nonce.n-1").unwrap(),
"first time: admitted"
);
assert!(
!d.state_admit("ticket.nonce.n-1").unwrap(),
"second time: a replay"
);
assert!(
d.state_admit("ticket.nonce.n-2").unwrap(),
"another nonce is its own"
);
}
/// RULE: the HMAC door compares only against the secrets the block names, even when another
/// secret file sits in the same directory.
#[test]
fn a_secret_the_block_did_not_name_is_never_compared() {
let dir = tempfile::tempdir().unwrap();
secret(dir.path(), "named", b"the-named-key");
secret(dir.path(), "other", b"another-vendors-key");
let names = vec!["named".to_string()];
let d = doors(&names, dir.path(), &["hmac".to_string()]);
let head = |s: &str| HmacHead {
secret: s.into(),
algo: "sha256".into(),
candidates_hex: vec!["00".repeat(32)],
};
let e = d
.hmac_verify(&head("other"), b"msg")
.expect_err("not named");
assert!(e.contains("[hmac-secret-not-named]"), "{e}");
d.hmac_verify(&head("named"), b"msg")
.expect("the named secret is compared");
}
/// RULE: the host bounds what a guest can make it hold or compute: one state value, the
/// HMAC candidate list, and a secret file.
#[test]
fn oversized_state_candidates_and_secrets_are_refused() {
let dir = tempfile::tempdir().unwrap();
secret(dir.path(), "named", b"the-named-key");
let names = vec!["named".to_string()];
let d = doors(
&names,
dir.path(),
&["hmac".to_string(), "state".to_string()],
);
let e = d
.state_put("k", vec![0; MAX_STATE_BYTES + 1])
.expect_err("one byte over");
assert!(e.contains("the ceiling is"), "{e}");
d.state_put("k", vec![0; MAX_STATE_BYTES])
.expect("at the ceiling");
let head = HmacHead {
secret: "named".into(),
algo: "sha256".into(),
candidates_hex: vec!["00".repeat(32); 9],
};
let e = d.hmac_verify(&head, b"msg").expect_err("nine candidates");
assert!(e.contains("[hmac-candidates]"), "{e}");
secret(dir.path(), "big", &vec![b'a'; 256 * 1024 + 1]);
let e =
monetize_product_wasm::secrets::read(dir.path(), "big").expect_err("a secret over 256 KiB");
assert!(e.contains("[secret-too-large]"), "{e}");
}