verify-bundle — Heimdall's wasm guest for Holger's update bundles
Heimdall is a defensive sandbox. This guest reads a holger.update-bundle/v1 (.holger.znippy) that came from somewhere nobody trusts, so that the Holger server never parses those bytes itself. Its tests exercise its own containment only.
Bytes in, a verdict and named outputs out; no host access. In order:
- the znippy v0.7 container (footer, Arrow IPC index framing walked before arrow reads it, chunk geometry, per-chunk BLAKE3, member names through the path guard, member count, the bomb shape from the declared sizes) — before a blob byte is read;
- the control members only, under their own caps;
- the release signature (Ed25519 over X.509, pinned or rooted, as Holger signs), the approvals and the two-person rule;
- every other member, named by the signed manifest before it is read;
- Holger's full verification order (content list both ways, envelope, expiry, evidence, target, anti-rollback);
- every DSSE envelope: payload type, then every signature by a pinned signer, and only then the in-toto statement, which must name a payload of this bundle.
Verdict: Accepted (outputs: verify-report.json plus the payload and evidence members) or Refused { code, reason }. emit-file takes one plain file name, so a member path is emitted under an injective name (payload/fw.bin is payload+2ffw.bin; see output_name), and the report lists both. In the wasm export (src/wasm.rs, bound to crates/heimdall-wasm/wit/heimdall.wit) the WIT refusal is one string, so the reason starts with the code in brackets. The one job input is the .holger.znippy itself; everything else arrives as job parameters. Codes are Holger's frozen holger-update-<n> wherever Holger's own verifier refuses the same thing, else heimdall-bundle-<n> (1 parameters, 2 archive, 3 codec, 4 DSSE, 5 DSSE binding, 6 parser panic).
Parameters: a RON VerifyParams document or Heimdall's string pairs (now, policy, product, model, revision, accepted, dsse, emit); see src/params.rs.
OpenZL: znippy compresses members with OpenZL, a C library. With the default feature openzl the guest compiles OpenZL's decoder (vendored in openzl/, built for wasm32 by build.rs against wasi-libc headers; needs clang and the wasi-libc package, see ci/ci.Containerfile) into the module, which still imports nothing but the WIT. A frame is decoded only after its header declares exactly the size the index declares. --no-default-features refuses compressed members by name (heimdall-bundle-3). Details and the open risk: docs/OPENZL-WASM.md.
Split guest: --no-default-features --features split builds the VERIFIER (world verifier, no C), --features decoder the DECODER (world decoder, imports nothing); the host runs many decoder instances in parallel and the verifier never shares memory with one. Same verdicts as the single-instance guest; see docs/GATLING-WASM.md.
cargo test --release # unit, integration, differential, proptest, and
# tests/wasm_e2e.rs: the component in the real host
cargo build --release --target wasm32-unknown-unknown
cargo +nightly fuzz run archive fuzz/seeds/archive # also: bundle dsse_envelope params certificate openzl_frame
Release components: how Holger pins and loads them
A release ships three components (not core modules; the host refuses a core module), each with its .wasm.blake3 and its .wasm.p7s:
| File | Build | Loaded with |
heimdall-verify-bundle.wasm |
default features (world job, OpenZL inside) |
Host::load_pinned |
heimdall-verify-bundle-verifier.wasm |
--no-default-features --features split (world verifier, no C) |
Host::load_split_pinned, after its decoder |
heimdall-verify-bundle-decoder.wasm |
--features decoder (world decoder, imports nothing) |
Host::load_decoder_pinned |
Built by cargo run --release --locked --example dist -- <out-dir> [<target-root>] in the toolchain image with wasi-libc (ci/ci.Containerfile; rustc 1.97.1). The build is reproducible: tools/guest_build.rs remaps every Rust source path (--remap-path-prefix): each dependency, wherever cargo found it (registry, git, a vendor directory, versioned or not), becomes /stage/<name>-<version> — the release build's own layout, so a plain dist on any machine builds the release's bytes — and the guest, CARGO_HOME, the sysroot and the target directory get fixed names; build.rs maps the C sources' paths away (-ffile-prefix-map). Two builds from different checkout paths, cargo homes (vendored or not) and target directories give byte-identical files (tests/release_dist.rs builds all three modules from two other checkouts, one vendored, on every CI run). The same recipe (tools/guest_build.rs) builds what every test in this directory loads, and dist writes exactly those bytes.
Signed the way Holger's own wasm plugins are signed: .wasm.p7s is a detached CMS over the module's BLAKE3-256 by the forge's plugin-signing certificate (CN=nordisk plugin signing, dwarves trust/plugin-signing.der, sha256 6b08efec…f159f1), issued by the korp root (korp-root-ca.der, sha256 6b19bf47…570b16), which is the root Holger already trusts for its plugins (ron_plugin_trust_roots, holger-recipe trust/korp-root-ca.pem). cargo run --release --locked --example sign -- <material-dir> <dist-dir> signs all three with the material dwarves sign-cert issue --purpose plugin keeps beside the root seed, verifies each signature against the root and loads all three, pinned, in the real host; any failure leaves no signature behind. The release's SLSA provenance (the owner's YubiKey key) names the three files by sha256 as its subjects.
Holger pins and loads them like this:
- Pin the three BLAKE3 digests from the release (the
.wasm.blake3files, cross-checked against the SLSA statement's sha256 subjects) in Holger's own configuration, not only beside the files: the sidecar travels with the module, the caller pin does not. - Give the host the korp root as its only trust root, as DER (Holger already writes the
.derof its stagedkorp-root-ca.pem). - Load, single instance:
heimdall_run::wasm::load_guest(&job, &[root_der], Some(job_b3), &limits)(orHost::load_pinned); split:heimdall_run::wasm::load_split_guest(&verifier, Some(verifier_b3), &decoder, Some(decoder_b3), &[root_der], &limits)(the decoder is loaded first). As a job:JobKind::Wasm { module: GuestRef { path, blake3 }, decoder: Some(GuestRef { .. }) }throughWasmJobRunner, orheimdall run --job wasm --module <verifier.wasm> --pin-blake3 <hex> --decoder <decoder.wasm> --decoder-pin-blake3 <hex> --trust-root korp-root-ca.der ...(docs/CLI.md). - Any refusal is a load error by name (
heimdall-wasm-*,decoder refused: ...for the decoder); Holger refuses the bundle, it never falls back to parsing it itself.