Gunnar

nordisk/heimdall

guests/verify-bundle/src/lib.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! # verify-bundle — Heimdall's wasm guest for Holger's update bundles
//!
//! **Heimdall is a defensive sandbox.** This guest is the code that reads a
//! `holger.update-bundle/v1` (`.holger.znippy`) that arrived from somewhere
//! nobody trusts, so that the Holger server never parses those bytes itself: it
//! hands them to Heimdall, and believes only the signed verdict that comes back.
//!
//! The guest has no host access at all: bytes in, a verdict and a few named
//! outputs out. It reads the znippy container, the bundle signature (Ed25519 over
//! X.509, as Holger signs), every SHA-256 and BLAKE3, every size cap, and every
//! DSSE envelope BEFORE the statement inside it is parsed. See [`verify`] for the
//! order.
//!
//! The verification core is a plain Rust library ([`verify_bundle`]); the wasm
//! export ([`wasm`]) is a thin shim over it.

#![deny(unsafe_code)]

pub mod cert;
pub mod digest;
pub mod dsse;
pub mod fuzz_api;
pub mod ipc;
pub mod limits;
pub mod model;
pub mod params;
pub mod pathsafe;
pub mod refusal;
pub mod verify;
#[cfg(feature = "openzl")]
pub mod zl;
pub mod znippy;

pub use params::VerifyParams;
pub use refusal::Refusal;
pub use verify::{VerifyReport, REPORT_SCHEMA};

/// The guest's answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
    Accepted,
    /// `code` is stable and machine-checkable: Holger's frozen
    /// `holger-update-<n>` for every refusal Holger's own verifier makes, or
    /// `heimdall-bundle-<n>` for one only this guest makes. `reason` is the
    /// human half, prefixed with `[code]` so it stands alone in a log line.
    Refused {
        code: String,
        reason: String,
    },
}

/// One named output: [`REPORT_NAME`], or a member under its [`output_name`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Emitted {
    pub name: String,
    pub bytes: Vec<u8>,
}

/// The name of the JSON report an accepted bundle emits.
pub const REPORT_NAME: &str = "verify-report.json";

/// The output file name a bundle member is emitted under.
///
/// Heimdall's `emit-file` takes ONE plain file name (ASCII letters, digits,
/// `.`, `_`, `-`, `+`; no `/`, no leading `.`, at most 255 bytes), never a
/// path. A member path is mapped injectively: every byte outside
/// `[A-Za-z0-9._-]`, and a leading `.`, becomes `+xx` (lowercase hex), so
/// `payload/fw.bin` is `payload+2ffw.bin` and a literal `+` is `+2b`. A name
/// that would collide with [`REPORT_NAME`] has its first byte escaped too; a
/// name over 255 bytes becomes `member+` and the sha256 of the path. The
/// report lists every member's output name beside its path.
pub fn output_name(path: &str) -> String {
    let mut out = String::with_capacity(path.len() + 8);
    for (i, b) in path.bytes().enumerate() {
        let plain = b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-');
        if plain && !(i == 0 && b == b'.') && !(i == 0 && path == REPORT_NAME) {
            out.push(b as char);
        } else {
            out.push_str(&format!("+{b:02x}"));
        }
    }
    if out.is_empty() || out.len() > 255 {
        return format!("member+{}", digest::sha256_hex(path.as_bytes()));
    }
    out
}

/// Everything one run produces. On `Refused`, `emitted` is always empty and
/// `output` is the tagged reason.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
    pub verdict: Verdict,
    /// Accepted: the report JSON. Refused: the tagged reason, UTF-8.
    pub output: Vec<u8>,
    pub emitted: Vec<Emitted>,
}

impl Outcome {
    fn refused(r: Refusal) -> Self {
        let reason = r.tagged();
        Outcome {
            output: reason.clone().into_bytes(),
            verdict: Verdict::Refused {
                code: r.code().as_string(),
                reason,
            },
            emitted: Vec::new(),
        }
    }

    /// The stable refusal code, if refused.
    pub fn code(&self) -> Option<&str> {
        match &self.verdict {
            Verdict::Refused { code, .. } => Some(code),
            Verdict::Accepted => None,
        }
    }

    pub fn is_accepted(&self) -> bool {
        self.verdict == Verdict::Accepted
    }

    /// The refusal reason, if refused.
    pub fn reason(&self) -> Option<&str> {
        match &self.verdict {
            Verdict::Refused { reason, .. } => Some(reason),
            Verdict::Accepted => None,
        }
    }
}

/// Verify `bundle` (the raw `.holger.znippy` bytes) under `params` (a RON
/// [`VerifyParams`] document). Never panics on hostile input by design; a
/// panic in a dependency is caught where unwinding exists and refused.
pub fn verify_bundle(bundle: &[u8], params: &[u8]) -> Outcome {
    verify_bundle_with(bundle, || VerifyParams::from_ron_bytes(params))
}

/// [`verify_bundle`] with the parameters as Heimdall's job `Params` (string
/// key/value pairs, see [`VerifyParams::from_pairs`]).
pub fn verify_bundle_pairs<'a>(
    bundle: &[u8],
    params: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Outcome {
    let pairs: Vec<(&str, &str)> = params.into_iter().collect();
    verify_bundle_with(bundle, || VerifyParams::from_pairs(pairs.iter().copied()))
}

fn verify_bundle_with(
    bundle: &[u8],
    params: impl FnOnce() -> Result<VerifyParams, Refusal>,
) -> Outcome {
    let run = || -> Result<Outcome, Refusal> {
        let params = params()?;
        let (report, members) = verify::verify(bundle, &params)?;
        let json = serde_json::to_vec_pretty(&report)?;
        let mut emitted = vec![Emitted {
            name: REPORT_NAME.to_string(),
            bytes: json.clone(),
        }];
        for (path, bytes) in members {
            let keep = match params.emit {
                params::Emit::Report => false,
                params::Emit::All => true,
                params::Emit::Content => {
                    path.starts_with(limits::PAYLOAD_PREFIX)
                        || path.starts_with(limits::EVIDENCE_PREFIX)
                }
            };
            if keep {
                emitted.push(Emitted {
                    name: output_name(&path),
                    bytes,
                });
            }
        }
        Ok(Outcome {
            verdict: Verdict::Accepted,
            output: json,
            emitted,
        })
    };
    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)) {
        Ok(Ok(o)) => o,
        Ok(Err(r)) => Outcome::refused(r),
        Err(_) => Outcome::refused(Refusal::ParserPanic("the verifier".into())),
    }
}

#[cfg(target_arch = "wasm32")]
pub mod wasm;