crates/guest-host/src/verify.rs
//! **Is this module the one we signed?**
//!
//! A module `foo.wasm` is verified against a **detached CMS signature** beside
//! it at `foo.wasm.p7s`, over the BLAKE3-256 digest of the module's bytes,
//! chained to DER trusted roots the operator configures. No crypto is written
//! here — this file reads a file, hashes it, and calls the one spec-exact CMS
//! verifier the constellation already ships (`znippy_common::sign`).
//!
//! # Every failure is a load error, never a smaller catalog
//!
//! A module that fails verification is refused **by name**. It is never dropped
//! quietly, because a quietly smaller catalog does not fail at boot — it fails
//! in production, as a missing product or a 404 on a repository the operator
//! configured and can see in the config file.
//!
//! # Trust is opt-in, and opting in cannot be silently ignored
//!
//! [`ModuleTrust::NotRequired`] is the default for one reason only: requiring
//! signatures with no configured roots could not verify anything, so it could
//! only refuse everything. What is NOT permitted is naming roots and having
//! them ignored: roots configured into a build without the `verify-modules`
//! feature is [`TrustError::VerifierUnavailable`], a refusal.
use std::path::{Path, PathBuf};
/// The extension a module's detached signature carries, appended to the
/// module's full file name: `foo.wasm` is signed by `foo.wasm.p7s`. Appended
/// rather than substituted so `foo.wasm` and `foo.wat.wasm` cannot collide.
pub const SIGNATURE_EXT: &str = "p7s";
/// **Whether THIS build of guest-host carries the CMS verifier** (its
/// `verify-modules` feature). Cargo unifies features: a crate that did not ask
/// for the verifier still gets it when another crate in the same build did, so
/// a caller's own `cfg(feature = "verify-modules")` does not say what
/// [`ModuleTrust::verify`] will do. This does.
pub const VERIFIER_COMPILED: bool = cfg!(feature = "verify-modules");
/// Whether loaded modules must prove their provenance, and against what.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ModuleTrust {
/// No provenance check. The default, because "required" with no roots
/// configured can only refuse every module.
#[default]
NotRequired,
/// Every module must carry a signature that verifies against these DER
/// trusted roots.
Required { roots: Vec<PathBuf> },
}
/// Why a module did not verify. Four distinct answers, because an operator
/// acts differently on each; `Display` is the plain-language refusal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustError {
/// Trust is required but no root was named, or a named root could not be
/// read or parsed. An unreadable root is NOT an empty root.
RootsUnreadable { module: PathBuf, detail: String },
/// No `.p7s` beside the module, or it could not be read.
Unsigned { module: PathBuf, signature: PathBuf, detail: String },
/// The signature is not over these bytes, or the signer chains to no
/// configured root.
SignatureInvalid { module: PathBuf, signature: PathBuf, detail: String },
/// Trust is required but this build carries no verifier
/// (`verify-modules` off). Refused, never skipped.
VerifierUnavailable { module: PathBuf, roots: Vec<PathBuf> },
}
impl TrustError {
/// The module the refusal is about.
pub fn module(&self) -> &Path {
match self {
TrustError::RootsUnreadable { module, .. }
| TrustError::Unsigned { module, .. }
| TrustError::SignatureInvalid { module, .. }
| TrustError::VerifierUnavailable { module, .. } => module,
}
}
}
impl std::fmt::Display for TrustError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustError::RootsUnreadable { module, detail } => {
write!(f, "{}: {detail}", module.display())
}
TrustError::Unsigned { module, signature, detail } => write!(
f,
"{}: no usable detached signature at {} ({detail}). Module trust is required, so \
an unsigned module is refused at load — it is never left out quietly, because a \
smaller catalog surfaces in production instead of as a stop here.",
module.display(),
signature.display()
),
TrustError::SignatureInvalid { module, signature, detail } => write!(
f,
"{}: signature {} did not verify against the configured trusted roots: {detail}. \
Either these are not the bytes that were signed, or the signer does not chain to \
a root this host trusts.",
module.display(),
signature.display()
),
TrustError::VerifierUnavailable { module, roots } => write!(
f,
"{}: module trust is required (roots: {:?}) but this host was built WITHOUT the \
`verify-modules` feature, so no signature can be checked at all. Rebuild with \
`--features verify-modules`, or stop requiring module trust — loading it \
unverified is not on offer.",
module.display(),
roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>()
),
}
}
}
impl std::error::Error for TrustError {}
impl ModuleTrust {
/// Require signatures chained to `roots`.
pub fn required(roots: impl IntoIterator<Item = PathBuf>) -> Self {
ModuleTrust::Required { roots: roots.into_iter().collect() }
}
/// True when a module without a valid signature is refused.
pub fn is_required(&self) -> bool {
matches!(self, ModuleTrust::Required { .. })
}
/// Where `module`'s detached signature lives.
pub fn signature_path(module: &Path) -> PathBuf {
let mut name = module.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(SIGNATURE_EXT);
module.with_file_name(name)
}
/// Verify `bytes` — the module file's contents — against this policy.
///
/// Returns the verified signer's common name on success, or `None` when no
/// verification was required. There is no third answer, and in particular
/// there is no "could not check, carrying on".
pub fn verify(&self, module: &Path, bytes: &[u8]) -> Result<Option<String>, TrustError> {
let roots = match self {
ModuleTrust::NotRequired => return Ok(None),
ModuleTrust::Required { roots } => roots,
};
verify_against_roots(module, bytes, roots).map(Some)
}
}
/// The verifying half, present only when this build carries a verifier.
#[cfg(feature = "verify-modules")]
fn verify_against_roots(module: &Path, bytes: &[u8], roots: &[PathBuf]) -> Result<String, TrustError> {
use znippy_common::sign::{verify_digest, CertStore};
let unreadable = |detail: String| TrustError::RootsUnreadable { module: module.to_path_buf(), detail };
if roots.is_empty() {
return Err(unreadable(
"module trust is required but NO trusted roots are configured, so no signature can \
chain to anything. Name at least one DER root certificate, or stop requiring trust."
.to_string(),
));
}
let mut ders = Vec::with_capacity(roots.len());
for r in roots {
let der = std::fs::read(r).map_err(|e| {
unreadable(format!(
"trusted root '{}' could not be read: {e}. An unreadable root is NOT an empty root \
— refusing rather than verifying against fewer roots than the operator named.",
r.display()
))
})?;
ders.push(der);
}
let store = CertStore::from_der_certs(&ders).map_err(|e| {
unreadable(format!(
"trusted roots {:?} could not be parsed as DER certificates: {e}",
roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>()
))
})?;
let signature = ModuleTrust::signature_path(module);
let cms = std::fs::read(&signature).map_err(|e| TrustError::Unsigned {
module: module.to_path_buf(),
signature: signature.clone(),
detail: e.to_string(),
})?;
// The content digest the signature covers: BLAKE3-256 over the module
// bytes, the same 32-byte shape `verify_digest` asserts the CMS
// `messageDigest` attribute over, and the same digest the census names the
// module by.
let digest: [u8; 32] = *blake3::hash(bytes).as_bytes();
let signer = verify_digest(&digest, &cms, &store).map_err(|e| TrustError::SignatureInvalid {
module: module.to_path_buf(),
signature: signature.clone(),
detail: e.to_string(),
})?;
Ok(signer.common_name().to_string())
}
/// Fail-closed stand-in for a build with no verifier.
#[cfg(not(feature = "verify-modules"))]
fn verify_against_roots(module: &Path, _bytes: &[u8], roots: &[PathBuf]) -> Result<String, TrustError> {
Err(TrustError::VerifierUnavailable { module: module.to_path_buf(), roots: roots.to_vec() })
}
/// **Signing, for tests and for a release door.** The detached CMS over a
/// module's blake3 digest, in the exact shape [`ModuleTrust::verify`] checks.
/// One writer of the signed form, so a host's tests cannot drift from what a
/// release pipeline produces.
#[cfg(feature = "verify-modules")]
pub mod sign {
use std::path::Path;
pub use znippy_common::sign::{dev, ArchiveSigner, CertStore, EcdsaP256Signer, Ed25519Signer};
/// The detached CMS `SignedData` over `module_bytes`, by `signer`.
pub fn sign_module(module_bytes: &[u8], signer: &dyn ArchiveSigner) -> Result<Vec<u8>, String> {
let digest: [u8; 32] = *blake3::hash(module_bytes).as_bytes();
znippy_common::sign::sign_digest_with_attrs(&digest, &[], signer).map_err(|e| e.to_string())
}
/// Write `module`'s `.p7s` beside it. Returns the sidecar's path.
pub fn sign_module_file(module: &Path, signer: &dyn ArchiveSigner) -> Result<std::path::PathBuf, String> {
let bytes = std::fs::read(module).map_err(|e| format!("{}: {e}", module.display()))?;
let cms = sign_module(&bytes, signer)?;
let sidecar = super::ModuleTrust::signature_path(module);
std::fs::write(&sidecar, cms).map_err(|e| format!("{}: {e}", sidecar.display()))?;
Ok(sidecar)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("guest-host-verify-{name}-{}-{:?}", std::process::id(), std::thread::current().id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_signature_sits_beside_its_module_under_the_full_file_name() {
let p = ModuleTrust::signature_path(Path::new("/var/lib/x/plugins/foo.wasm"));
assert_eq!(p, PathBuf::from("/var/lib/x/plugins/foo.wasm.p7s"));
assert_ne!(ModuleTrust::signature_path(Path::new("x/foo.wasm")), ModuleTrust::signature_path(Path::new("x/foo.wat.wasm")));
}
#[test]
fn the_default_policy_requires_nothing_and_reports_no_signer() {
let t = ModuleTrust::default();
assert!(!t.is_required());
assert_eq!(t.verify(Path::new("x.wasm"), b"\0asm").unwrap(), None);
}
#[test]
fn requiring_trust_with_no_roots_is_refused_by_name() {
let t = ModuleTrust::required([]);
assert!(t.is_required());
let err = t.verify(Path::new("x.wasm"), b"\0asm").expect_err("an empty root set verified");
assert!(matches!(err, TrustError::RootsUnreadable { .. } | TrustError::VerifierUnavailable { .. }), "{err}");
}
#[cfg(not(feature = "verify-modules"))]
#[test]
fn a_build_without_a_verifier_refuses_rather_than_skips() {
let t = ModuleTrust::required([PathBuf::from("/etc/x/root.der")]);
let err = t.verify(Path::new("x.wasm"), b"\0asm").expect_err("unverified load allowed");
assert!(matches!(err, TrustError::VerifierUnavailable { .. }));
assert!(err.to_string().contains("verify-modules"), "the refusal does not say how to fix it: {err}");
}
#[cfg(feature = "verify-modules")]
#[test]
fn an_unsigned_module_is_refused_by_name_not_skipped() {
let dir = scratch("unsigned");
let module = dir.join("unsigned.wasm");
std::fs::write(&module, b"\0asm\x01\0\0\0").unwrap();
let (_, ca_der) = sign::dev::mint_ca("guest-host-test-ca").unwrap();
let root = dir.join("root.der");
std::fs::write(&root, &ca_der).unwrap();
let err = ModuleTrust::required([root]).verify(&module, &std::fs::read(&module).unwrap()).expect_err("an unsigned module verified");
assert!(matches!(err, TrustError::Unsigned { .. }), "{err}");
assert!(err.to_string().contains("unsigned.wasm.p7s"), "the refusal does not name the sidecar: {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "verify-modules")]
#[test]
fn a_signature_over_other_bytes_does_not_verify_and_a_real_one_names_the_signer() {
let dir = scratch("tampered");
let module = dir.join("m.wasm");
std::fs::write(&module, b"\0asm\x01\0\0\0the real module").unwrap();
let (ca_key, ca_der) = sign::dev::mint_ca("guest-host-test-ca").unwrap();
let root = dir.join("root.der");
std::fs::write(&root, &ca_der).unwrap();
let trust = ModuleTrust::required([root]);
let signer = sign::dev::new_ed25519_signer(&ca_key, &ca_der, "release-door").unwrap();
// Signed: the signer's common name comes back.
sign::sign_module_file(&module, &signer).unwrap();
let cn = trust.verify(&module, &std::fs::read(&module).unwrap()).unwrap();
assert_eq!(cn.as_deref(), Some("release-door"));
// Tampered after signing: refused, and the signature is checked, not counted.
std::fs::write(&module, b"\0asm\x01\0\0\0the real module, edited").unwrap();
let err = trust.verify(&module, &std::fs::read(&module).unwrap()).expect_err("tampered bytes verified");
assert!(matches!(err, TrustError::SignatureInvalid { .. }), "{err}");
// A signer under a DIFFERENT root: refused as untrusted.
let (other_key, other_der) = sign::dev::mint_ca("somebody-else").unwrap();
let stranger = sign::dev::new_ed25519_signer(&other_key, &other_der, "stranger").unwrap();
std::fs::write(&module, b"\0asm\x01\0\0\0the real module").unwrap();
sign::sign_module_file(&module, &stranger).unwrap();
let err = trust.verify(&module, &std::fs::read(&module).unwrap()).expect_err("a stranger's signature verified");
assert!(matches!(err, TrustError::SignatureInvalid { .. }), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
}