server/lib/src/job_runner/verify_bundle.rs
//! **The `verify-bundle` job** (lane HOL-SANDBOX-TRAIT): a signed update or airgap
//! bundle, verified in the sandbox, never in the server.
//!
//! In (the job's input directory, read-only): [`BUNDLE_FILE`] (the bundle, linked in
//! by the server without being opened), [`POLICY_FILE`] (this side's trust policy),
//! and for an airgap export [`ATTEST_FILE`] (this side's attestation trust). The
//! parameters ([`Params`]) carry the estate's time and the target.
//!
//! What it checks, in the order the import has always kept:
//!
//! 1. the release signature (Ed25519 / X.509) from the control members, BEFORE any
//! payload is decompressed, with the container's size caps and its znippy index
//! (`load_bundle_verified`; never `load_bundle`);
//! 2. every member's digest against the release-signed manifest, the approvals and
//! the expiry against the estate's time (`verify_member_map_with_time`);
//! 3. an airgap export: every payload's sha256, blake3 and size, and every evidence
//! attestation (DSSE) against this side's trust, before the evidence is believed
//! (`airgap::verify_import`).
//!
//! Out: every member as its own file (`m-000000`, …), [`MEMBERS_FILE`] naming them,
//! and, for a bundle that is not an export, the bundle's own bytes as [`BUNDLE_OUT`]
//! (it lands as one artifact). A failure is the job's refusal, with the code and the
//! words the import has always answered.
use std::path::Path;
use holger_job_runner::wire::{Answer, Dirs, WireJob};
use serde::{Deserialize, Serialize};
use update_bundle::core::BundleCore;
use update_bundle::schema::CORE_JSON;
use update_bundle::verify::TargetSpec;
use update_bundle::TrustPolicy;
/// The program's name.
pub const PROGRAM: &str = "verify-bundle";
/// The bundle, in the input.
pub const BUNDLE_FILE: &str = "bundle.holger.znippy";
/// This side's trust policy, JSON, in the input.
pub const POLICY_FILE: &str = "policy.json";
/// This side's attestation trust ([`crate::attest::TrustDoc`]), JSON, in the input.
pub const ATTEST_FILE: &str = "attest.json";
/// The members' names and files, in the output.
pub const MEMBERS_FILE: &str = "members.json";
/// The bundle's own bytes, in the output (a bundle that is not an export).
pub const BUNDLE_OUT: &str = "bundle";
/// The manifest's schema.
pub const MANIFEST_SCHEMA: &str = "holger.verified-bundle/v1";
/// The estate's time, as the server read it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TimeParam {
/// The time, and whether a verified clock stands behind it.
At { unix_ms: i64, trusted: bool },
/// A clock source is configured and nothing verified answered.
Unverified(String),
}
/// The job's parameters.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Params {
pub time: TimeParam,
#[serde(default)]
pub product: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub revision: Option<String>,
/// Lane HOL-HEIMDALL-04: verify with Heimdall's released split guest, whose files are the
/// job's read path `guest.read` (its trust root is [`GUEST_ROOT_FILE`] in the input).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guest: Option<GuestParam>,
}
/// Where a job finds the verify-bundle guests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GuestParam {
/// The index of the read path that holds the release's files.
pub read: usize,
}
/// The guests' trust root (the korp root, DER), in the input.
pub const GUEST_ROOT_FILE: &str = "korp-root-ca.der";
impl Params {
/// The parameters for `target`, at the estate's time now.
pub fn now(target: &TargetSpec) -> Self {
Self::at(target, &crate::import_door::EstateTime)
}
/// The parameters for `target`, at `t` (the import door hands its clock judged at the
/// time floor, lane HOL-NCSC).
pub fn at(target: &TargetSpec, t: &dyn update_bundle::time::TrustedTime) -> Self {
let time = match t.now() {
Ok(at) => TimeParam::At {
unix_ms: at.timestamp_millis(),
trusted: t.is_trusted(),
},
Err(e) => TimeParam::Unverified(match e {
update_bundle::time::TimeError::Unverified(w) => w,
other => other.to_string(),
}),
};
Self {
time,
product: target.product.clone(),
model: target.model.clone(),
revision: target.revision.clone(),
guest: None,
}
}
fn target(&self) -> TargetSpec {
TargetSpec {
product: self.product.clone(),
model: self.model.clone(),
revision: self.revision.clone(),
}
}
}
/// The verified members, as the job wrote them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: String,
/// `(member path, output file)`, in the bundle's order.
pub members: Vec<(String, String)>,
/// An airgap export.
pub export: bool,
pub bundle_sha256: String,
pub bundle_bytes: u64,
}
/// The estate's time inside the job.
struct JobTime(TimeParam);
impl update_bundle::time::TrustedTime for JobTime {
fn source_name(&self) -> &'static str {
crate::clock::SOURCE_NAME
}
fn is_trusted(&self) -> bool {
matches!(self.0, TimeParam::At { trusted: true, .. })
}
fn now(&self) -> Result<chrono::DateTime<chrono::Utc>, update_bundle::time::TimeError> {
use update_bundle::time::TimeError;
match &self.0 {
TimeParam::At { unix_ms, .. } => {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(*unix_ms)
.ok_or(TimeError::OutOfRange(*unix_ms))
}
TimeParam::Unverified(w) => Err(TimeError::Unverified(w.clone())),
}
}
}
/// The program.
pub fn run(job: &WireJob, dirs: &Dirs) -> Answer {
match verify(job, dirs) {
Ok(outputs) => Answer::accepted(outputs),
Err((code, reason)) => Answer::refused(code, reason),
}
}
type Refused = (String, String);
fn refused_import(why: impl std::fmt::Display) -> Refused {
(holger_errcode::IMPORT_REFUSED.code.into(), why.to_string())
}
fn verify(job: &WireJob, dirs: &Dirs) -> Result<Vec<String>, Refused> {
let params: Params = serde_json::from_value(job.params.clone())
.map_err(|e| refused_import(format!("the verify-bundle job's parameters: {e}")))?;
let policy: TrustPolicy = serde_json::from_slice(&read(&dirs.input.join(POLICY_FILE))?)
.map_err(|e| refused_import(format!("the trust policy: {e}")))?;
let bundle = dirs.input.join(BUNDLE_FILE);
let members = match (params.guest, ¶ms.time) {
// Lane HOL-HEIMDALL-04: (1) and (2) in Heimdall's released split guest, a wasm
// sandbox inside this job (the decoder in its own instances): Holger's own order.
// Without a verified time the guest has no instant to judge at, so the native order
// judges it (it refuses what needs the time, as before).
(Some(g), TimeParam::At { unix_ms, .. }) => {
guest::members(¶ms, g, *unix_ms, &policy, &bundle, dirs)?
}
_ => {
// (1) The release signature before any payload is decompressed.
let members = update_bundle::container::load_bundle_verified(&bundle, &policy)
.map_err(|e| {
refused_import(format!("bundle verification failed: {}", e.code().tag(&e)))
})?;
// (2) Every member against the signed manifest, at the estate's time.
update_bundle::verify::verify_member_map_with_time(
&members,
&policy,
¶ms.target(),
None,
&JobTime(params.time.clone()),
)
.map_err(|e| {
refused_import(format!("bundle verification failed: {}", e.code().tag(&e)))
})?;
members
}
};
let core = BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| refused_import("bundle has no core.json"))?,
)
.map_err(|e| refused_import(format!("core.json: {e}")))?;
let export = core.source_provenance.source_kind == crate::promote::airgap::SOURCE_KIND;
// (3) An export's payloads and their evidence attestations (DSSE), before the
// evidence is believed.
if export {
let attest = match std::fs::read(dirs.input.join(ATTEST_FILE)) {
Ok(b) => {
let doc: crate::attest::TrustDoc = serde_json::from_slice(&b)
.map_err(|e| refused_import(format!("the attestation trust: {e}")))?;
let scratch = std::env::temp_dir().join("holger-verify-attest");
Some(
crate::attest::AttestStore::from_trust_doc(scratch, &doc)
.map_err(refused_import)?,
)
}
Err(_) => None,
};
let payloads: Vec<crate::promote::airgap::CorePayload> = core
.payloads
.iter()
.map(|p| crate::promote::airgap::CorePayload {
path: p.relative_path.clone(),
sha256: p.sha256.clone(),
blake3: p.blake3.clone(),
size: p.size_bytes,
})
.collect();
crate::promote::airgap::verify_import(&members, &payloads, attest.as_ref()).map_err(
|e| {
(
e.code().code.to_string(),
format!("bundle import refused: {e}"),
)
},
)?;
}
// The verified members, one file each.
let mut outputs = Vec::new();
let mut listed = Vec::new();
for (i, (name, bytes)) in members.iter().enumerate() {
let file = format!("m-{i:06}");
write(&dirs.out.join(&file), bytes)?;
listed.push((name.clone(), file.clone()));
outputs.push(file);
}
let raw = read(&bundle)?;
if !export {
write(&dirs.out.join(BUNDLE_OUT), &raw)?;
outputs.push(BUNDLE_OUT.into());
}
let manifest = Manifest {
schema: MANIFEST_SCHEMA.into(),
members: listed,
export,
bundle_sha256: update_bundle::digest::sha256_hex(&raw),
bundle_bytes: raw.len() as u64,
};
write(
&dirs.out.join(MEMBERS_FILE),
&serde_json::to_vec(&manifest).map_err(|e| refused_import(e.to_string()))?,
)?;
outputs.push(MEMBERS_FILE.into());
Ok(outputs)
}
fn read(p: &Path) -> Result<Vec<u8>, Refused> {
std::fs::read(p).map_err(|e| {
refused_import(format!(
"read {}: {e}",
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
))
})
}
fn write(p: &Path, bytes: &[u8]) -> Result<(), Refused> {
std::fs::write(p, bytes).map_err(|e| refused_import(format!("write {}: {e}", p.display())))
}
/// **The server's half**: read the job's verified outputs in `out` (already
/// checked against the signed result) back into the member map, the manifest and,
/// for a bundle that is not an export, the bundle's bytes.
pub fn read_outputs(
out: &Path,
) -> Result<(update_bundle::MemberMap, Manifest, Option<Vec<u8>>), String> {
let m: Manifest = serde_json::from_slice(
&std::fs::read(out.join(MEMBERS_FILE)).map_err(|e| format!("{MEMBERS_FILE}: {e}"))?,
)
.map_err(|e| format!("{MEMBERS_FILE}: {e}"))?;
if m.schema != MANIFEST_SCHEMA {
return Err(format!("{MEMBERS_FILE} has schema {:?}", m.schema));
}
let mut members = update_bundle::MemberMap::new();
for (name, file) in &m.members {
if !heimdall_valid(file) {
return Err(format!("{MEMBERS_FILE} names the file {file:?}"));
}
members.insert(
name.clone(),
std::fs::read(out.join(file)).map_err(|e| format!("{file}: {e}"))?,
);
}
let bundle = if m.export {
None
} else {
Some(std::fs::read(out.join(BUNDLE_OUT)).map_err(|e| format!("{BUNDLE_OUT}: {e}"))?)
};
Ok((members, m, bundle))
}
fn heimdall_valid(name: &str) -> bool {
name.strip_prefix("m-")
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
/// **The bundle verified by Heimdall's released split guest** (lane HOL-HEIMDALL-04), inside
/// the job: the vendored release loaded under its trust root with Holger's own pins (the
/// decoder first), run over the bundle with `emit: All`, and its outputs mapped back to the
/// door's members: each `verify-report.json` member record names its path and the file it was
/// emitted under ([`output_name`](https://git.gunnar.rs/nordisk/heimdall): `payload/fw.bin` is
/// `payload+2ffw.bin`), and every file is held to the record's size and sha256.
mod guest {
use super::*;
/// The members of `bundle`, verified by the guest; its refusal is the job's.
#[cfg(feature = "heimdall-wasm")]
pub(super) fn members(
params: &Params,
g: GuestParam,
unix_ms: i64,
policy: &TrustPolicy,
bundle: &Path,
dirs: &Dirs,
) -> Result<update_bundle::MemberMap, Refused> {
use heimdall_run::GuestRunner as _;
use holger_job_runner::{guests, heimdall_run};
let dir = dirs.read.get(g.read).ok_or_else(|| {
refused_import(format!(
"the job has no read path {} for the verify-bundle guests",
g.read
))
})?;
let bytes = read(bundle)?;
let limits = heimdall_run::RunLimits {
// About 94 fuel per content byte for the split verifier (heimdall
// docs/GATLING-WASM.md); a bundle is at most a few times its bytes as content.
fuel: (bytes.len() as u64)
.saturating_mul(1024)
.clamp(4_000_000_000, 1_000_000_000_000),
wall_time_ms: 600_000,
memory_bytes: 4 << 30,
max_input_bytes: (bytes.len() as u64).clamp(1, 4 << 30),
max_output_files: 10_000,
max_output_file_bytes: 4 << 30,
max_output_total_bytes: 16 << 30,
..Default::default()
};
let root = dirs.input.join(GUEST_ROOT_FILE);
let guest = guests::load_split(dir, &guests::RELEASE, &[root], &limits)
.map_err(|e| refused_import(format!("bundle verification failed: {e}")))?;
let now = chrono::DateTime::<chrono::Utc>::from_timestamp_millis(unix_ms)
.ok_or_else(|| refused_import(format!("the estate's time {unix_ms} is out of range")))?
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut p = serde_json::json!({
"now": now,
"policy": policy.to_ron().map_err(|e| refused_import(format!("the trust policy: {e}")))?,
"emit": "All",
});
// The DSSE envelopes a bundle carries are believed only from the gates this side
// enrolled (the attestation trust); an export's evidence is then verified natively
// again, against the roots too (step 3).
if let Ok(b) = std::fs::read(dirs.input.join(ATTEST_FILE)) {
let doc: crate::attest::TrustDoc = serde_json::from_slice(&b)
.map_err(|e| refused_import(format!("the attestation trust: {e}")))?;
let signers = doc
.enrolled
.iter()
.map(|f| format!("{:?}", f.to_ascii_lowercase()))
.collect::<Vec<_>>()
.join(", ");
p["dsse"] = serde_json::Value::String(format!("(trusted_signers: [{signers}])"));
}
for (k, v) in [
("product", ¶ms.product),
("model", ¶ms.model),
("revision", ¶ms.revision),
] {
if let Some(v) = v {
p[k] = serde_json::Value::String(v.clone());
}
}
let pairs = heimdall_run::params_from_json(&p)
.map_err(|e| refused_import(format!("the guest's parameters: {e}")))?;
let run = guest.run_guest(&bytes, &pairs, &limits);
if let heimdall_run::GuestVerdict::Refused { code, reason } = &run.verdict {
return Err(refusal_of(code, reason));
}
from_outputs(&run.files)
}
#[cfg(not(feature = "heimdall-wasm"))]
pub(super) fn members(
_params: &Params,
_g: GuestParam,
_unix_ms: i64,
_policy: &TrustPolicy,
_bundle: &Path,
_dirs: &Dirs,
) -> Result<update_bundle::MemberMap, Refused> {
Err((
holger_errcode::SANDBOX_CONFIG.code.into(),
holger_errcode::SANDBOX_CONFIG.tag(
"the job asks for Heimdall's verify-bundle guest, and this worker was built without it (feature `heimdall-wasm`)",
),
))
}
/// The guest's refusal as the job's: the reason starts with the code in brackets
/// (`[holger-update-12] …`); Holger's own codes stand, Heimdall's (`heimdall-bundle-<n>`,
/// `heimdall-wasm-<n>`) are an import refusal that names them.
#[cfg(feature = "heimdall-wasm")]
pub(super) fn refusal_of(code: &str, reason: &str) -> Refused {
let inner = reason
.strip_prefix('[')
.and_then(|r| r.split_once(']'))
.map(|(c, _)| c.to_string());
let named = inner.unwrap_or_else(|| code.to_string());
match holger_errcode::by_code(&named) {
Some(c) => (
c.code.into(),
format!("bundle verification failed: {reason}"),
),
None => refused_import(format!(
"bundle verification failed in Heimdall's verify-bundle guest: [{named}] {reason}"
)),
}
}
/// The guest's report and files as the member map: every member the report lists, from
/// the file it names, at the size and sha256 it names; a file the report does not name,
/// or a record whose file is missing or other, refuses.
#[cfg(feature = "heimdall-wasm")]
pub(super) fn from_outputs(
files: &[(String, Vec<u8>)],
) -> Result<update_bundle::MemberMap, Refused> {
#[derive(Deserialize)]
struct Report {
schema: String,
members: Vec<Member>,
}
#[derive(Deserialize)]
struct Member {
path: String,
output_name: String,
size: u64,
sha256: String,
}
let bad = |w: String| refused_import(format!("the verify-bundle guest's answer: {w}"));
let by_name: BTreeMapAlias<'_> = files.iter().map(|(n, b)| (n.as_str(), b)).collect();
let report = by_name
.get(REPORT_FILE)
.ok_or_else(|| bad(format!("no {REPORT_FILE}")))?;
let r: Report =
serde_json::from_slice(report).map_err(|e| bad(format!("{REPORT_FILE}: {e}")))?;
if r.schema != REPORT_SCHEMA {
return Err(bad(format!("{REPORT_FILE} has schema {:?}", r.schema)));
}
let mut members = update_bundle::MemberMap::new();
for m in &r.members {
let b = by_name
.get(m.output_name.as_str())
.ok_or_else(|| bad(format!("member {:?}: no file {:?}", m.path, m.output_name)))?;
if b.len() as u64 != m.size || update_bundle::digest::sha256_hex(b) != m.sha256 {
return Err(bad(format!(
"member {:?}: the file {:?} is not the size and sha256 its record names",
m.path, m.output_name
)));
}
if members.insert(m.path.clone(), b.to_vec()).is_some() {
return Err(bad(format!("member {:?} twice", m.path)));
}
}
if files.len() != r.members.len() + 1 {
return Err(bad(format!(
"{} files for {} members and the report",
files.len(),
r.members.len()
)));
}
Ok(members)
}
#[cfg(feature = "heimdall-wasm")]
type BTreeMapAlias<'a> = std::collections::BTreeMap<&'a str, &'a Vec<u8>>;
/// The guest's report file and schema (heimdall guests/verify-bundle).
#[cfg(feature = "heimdall-wasm")]
pub(super) const REPORT_FILE: &str = "verify-report.json";
#[cfg(feature = "heimdall-wasm")]
pub(super) const REPORT_SCHEMA: &str = "heimdall.verify-bundle.report/v1";
}