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

nordisk/monetize

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