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

nordisk/edda

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