// 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, ¶ms)?;
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;