nordisk/heimdall
crates/heimdall-wasm/tests/signatures.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! Guests are signed like holger's wasm plugins (`<file>.wasm.p7s`, a detached
//! CMS over the module's BLAKE3-256, plus the `<file>.wasm.blake3` pin) and are
//! verified BEFORE they are compiled. Unsigned, tampered, wrong-key and badly
//! pinned guests never load. Heimdall is a defensive sandbox.
mod common;
use common::*;
use guest_host::verify::sign::{dev, sign_module};
use heimdall_wasm::{Host, HostConfig, HostError, LoadRefusal, TrustFailure};
fn untrusted(r: Result<heimdall_wasm::Guest, LoadRefusal>) -> (TrustFailure, String) {
match r {
Err(LoadRefusal::Untrusted { failure, detail }) => (failure, detail),
Err(other) => panic!("refused for another reason: {other}"),
Ok(_) => panic!("an untrusted guest loaded"),
}
}
#[test]
fn a_signed_guest_loads() {
let kit = Kit::new("signed");
let p = kit.place("good", &component(&good_wat()));
assert!(kit.host().load(&p).is_ok());
}
#[test]
fn a_host_without_trust_roots_cannot_be_built() {
assert!(matches!(
Host::new(HostConfig::new([])),
Err(HostError::NoTrustRoots)
));
}
#[test]
fn an_unsigned_guest_is_refused() {
let kit = Kit::new("unsigned");
let p = kit.place("good", &component(&good_wat()));
std::fs::remove_file(sidecar(&p, "p7s")).unwrap();
let (f, detail) = untrusted(kit.host().load(&p));
assert_eq!(f, TrustFailure::Unsigned);
assert!(
detail.contains("good.wasm.p7s"),
"the refusal does not name the missing sidecar: {detail}"
);
}
#[test]
fn a_tampered_guest_with_a_matching_blake3_pin_fails_the_signature() {
let kit = Kit::new("tampered");
let p = kit.place("good", &component(&good_wat()));
// Swap in another guest and re-pin its blake3: only the signature can tell.
let other = component(&Wat::new("(call $ok (i32.const 0) (i32.const 0))").text());
std::fs::write(&p, &other).unwrap();
std::fs::write(
sidecar(&p, "blake3"),
blake3::hash(&other).to_hex().as_str(),
)
.unwrap();
let (f, _) = untrusted(kit.host().load(&p));
assert_eq!(f, TrustFailure::SignatureInvalid);
}
#[test]
fn a_single_flipped_bit_in_the_module_is_refused() {
let kit = Kit::new("bitflip");
let bytes = component(&good_wat());
let p = kit.place("good", &bytes);
let mut flipped = bytes.clone();
let i = flipped.len() / 2;
flipped[i] ^= 1;
std::fs::write(&p, &flipped).unwrap();
assert!(matches!(
kit.host().load(&p),
Err(LoadRefusal::DigestMismatch { .. })
));
}
#[test]
fn a_guest_signed_under_another_root_is_refused() {
let kit = Kit::new("wrongkey");
let bytes = component(&good_wat());
let p = kit.place("good", &bytes);
let (k, der) = dev::mint_ca("somebody-else").unwrap();
let stranger = dev::new_ed25519_signer(&k, &der, "stranger").unwrap();
std::fs::write(sidecar(&p, "p7s"), sign_module(&bytes, &stranger).unwrap()).unwrap();
let (f, _) = untrusted(kit.host().load(&p));
assert_eq!(f, TrustFailure::SignatureInvalid);
}
#[test]
fn a_garbage_signature_file_is_refused() {
let kit = Kit::new("garbage-sig");
let p = kit.place("good", &component(&good_wat()));
std::fs::write(sidecar(&p, "p7s"), b"not a CMS SignedData").unwrap();
assert_eq!(
untrusted(kit.host().load(&p)).0,
TrustFailure::SignatureInvalid
);
}
#[test]
fn an_empty_signature_file_is_refused() {
let kit = Kit::new("empty-sig");
let p = kit.place("good", &component(&good_wat()));
std::fs::write(sidecar(&p, "p7s"), b"").unwrap();
assert_eq!(
untrusted(kit.host().load(&p)).0,
TrustFailure::SignatureInvalid
);
}
#[test]
fn a_signature_for_another_guest_is_refused() {
let kit = Kit::new("swapped-sig");
let a = kit.place("a", &component(&good_wat()));
let b = kit.place(
"b",
&component(&Wat::new("(call $ok (i32.const 0) (i32.const 0))").text()),
);
std::fs::copy(sidecar(&b, "p7s"), sidecar(&a, "p7s")).unwrap();
assert_eq!(
untrusted(kit.host().load(&a)).0,
TrustFailure::SignatureInvalid
);
}
#[test]
fn an_unreadable_trust_root_is_refused_not_ignored() {
let kit = Kit::new("noroot");
let p = kit.place("good", &component(&good_wat()));
let host = Host::new(HostConfig::new([kit.dir.join("missing-root.der")])).unwrap();
assert_eq!(untrusted(host.load(&p)).0, TrustFailure::RootsUnreadable);
}
#[test]
fn a_trust_root_that_is_not_a_certificate_is_refused() {
let kit = Kit::new("junkroot");
let p = kit.place("good", &component(&good_wat()));
let junk = kit.dir.join("junk.der");
std::fs::write(&junk, b"junk").unwrap();
let host = Host::new(HostConfig::new([junk])).unwrap();
assert_eq!(untrusted(host.load(&p)).0, TrustFailure::RootsUnreadable);
}
#[test]
fn one_good_root_among_several_is_enough() {
let kit = Kit::new("tworoots");
let p = kit.place("good", &component(&good_wat()));
let (_, der) = dev::mint_ca("other").unwrap();
let other = kit.dir.join("other.der");
std::fs::write(&other, der).unwrap();
let host = Host::new(HostConfig::new([other, kit.root.clone()])).unwrap();
assert!(host.load(&p).is_ok());
}
#[test]
fn a_missing_blake3_pin_is_refused() {
let kit = Kit::new("nopin");
let p = kit.place("good", &component(&good_wat()));
std::fs::remove_file(sidecar(&p, "blake3")).unwrap();
assert!(matches!(
kit.host().load(&p),
Err(LoadRefusal::DigestMissing { .. })
));
}
#[test]
fn a_wrong_blake3_pin_is_refused_even_with_a_good_signature() {
let kit = Kit::new("wrongpin");
let p = kit.place("good", &component(&good_wat()));
std::fs::write(
sidecar(&p, "blake3"),
blake3::hash(b"other").to_hex().as_str(),
)
.unwrap();
assert!(matches!(
kit.host().load(&p),
Err(LoadRefusal::DigestMismatch { .. })
));
}
#[test]
fn a_blake3_pin_that_is_not_hex_is_refused() {
let kit = Kit::new("junkpin");
let p = kit.place("good", &component(&good_wat()));
std::fs::write(sidecar(&p, "blake3"), "zz not hex").unwrap();
assert!(matches!(
kit.host().load(&p),
Err(LoadRefusal::DigestMismatch { .. })
));
}
#[test]
fn an_uppercase_or_newline_terminated_pin_is_accepted() {
let kit = Kit::new("pinforms");
let bytes = component(&good_wat());
let p = kit.place("good", &bytes);
std::fs::write(
sidecar(&p, "blake3"),
format!("{}\n", blake3::hash(&bytes).to_hex().to_uppercase()),
)
.unwrap();
assert!(kit.host().load(&p).is_ok());
}
#[test]
fn the_callers_pin_must_match_too() {
let kit = Kit::new("callerpin");
let bytes = component(&good_wat());
let p = kit.place("good", &bytes);
let host = kit.host();
assert!(host
.load_pinned(&p, blake3::hash(&bytes).to_hex().as_str())
.is_ok());
match host.load_pinned(&p, blake3::hash(b"x").to_hex().as_str()) {
Err(LoadRefusal::DigestMismatch { pin, .. }) => assert_eq!(pin, "caller pin"),
Err(e) => panic!("other refusal: {e}"),
Ok(_) => panic!("a guest with the wrong caller pin loaded"),
}
}
#[test]
fn a_module_over_the_size_cap_is_refused_before_it_is_read_through() {
let kit = Kit::new("toolarge");
let p = kit.place("good", &component(&good_wat()));
let mut cfg = kit.config();
cfg.max_module_bytes = 100;
assert!(matches!(
Host::new(cfg).unwrap().load(&p),
Err(LoadRefusal::TooLarge { .. })
));
}
#[test]
fn a_missing_module_file_is_refused() {
let kit = Kit::new("missing");
assert!(matches!(
kit.host().load(&kit.dir.join("nope.wasm")),
Err(LoadRefusal::Unreadable { .. })
));
}
#[test]
fn a_load_refusal_reads_as_refused() {
let kit = Kit::new("display");
let e = kit.host().load(&kit.dir.join("nope.wasm")).err().unwrap();
assert!(
e.to_string().starts_with("refused: guest not loaded ("),
"{e}"
);
}