Gunnar

nordisk/heimdall

guests/verify-bundle/tests/common/mod.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Real bundles, made the way Holger makes them: Holger's own
//! `holger-update-bundle` prepares the candidate, signs the approvals and
//! finalizes the release signature; znippy's own writer packs the archive.
//! Holger's own verifier is the differential oracle.
//!
//! An attacker who holds the RELEASE key (one insider, not two) is modelled by
//! [`Fixture::resign`]: it rebuilds and re-signs the release manifest exactly as
//! `finalize` does, without the approvals' consent.

#![allow(dead_code)]

pub mod zwriter;

use std::collections::BTreeMap;
use std::path::PathBuf;

use chrono::{DateTime, Duration, TimeZone, Utc};
use holger_update_bundle::core::{PolicyResult, SourceProvenance};
use holger_update_bundle::finalize::{add_approval, finalize};
use holger_update_bundle::manifest::ReleaseManifest;
use holger_update_bundle::policy::{ApprovedSigner, ExpiredPosture};
use holger_update_bundle::prepare::{EvidenceInput, PayloadInput, Preparation};
use holger_update_bundle::signer::sign_pkcs8;
use holger_update_bundle::{
    BundleError, CertificateValidity, EvidenceKind, TrustMode, TrustPolicy, TrustedTime,
};
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ED25519};

pub use heimdall_guest_verify_bundle::{output_name, verify_bundle, Outcome};

/// The member an output name was emitted for.
pub fn member_for<'a>(members: &'a MemberMap, name: &str) -> &'a Vec<u8> {
    members
        .iter()
        .find(|(p, _)| output_name(p) == name)
        .map(|(_, b)| b)
        .unwrap_or_else(|| panic!("no member is emitted as {name}"))
}

pub type MemberMap = BTreeMap<String, Vec<u8>>;

pub const RELEASE_MANIFEST: &str = "signatures/release-manifest.json";
pub const RELEASE_SIG: &str = "signatures/release.sig";
pub const RELEASE_CRT: &str = "signatures/release.crt";

/// A fixed, reproducible "now" for every fixture.
pub fn t0() -> DateTime<Utc> {
    Utc.with_ymd_and_hms(2026, 10, 30, 12, 0, 0).unwrap()
}

pub fn sha256_hex(b: &[u8]) -> String {
    holger_update_bundle::digest::sha256_hex(b)
}

pub fn hex(b: &[u8]) -> String {
    holger_update_bundle::digest::hex_lower(b)
}

// ------------------------------------------------------------------- PKI

#[derive(Clone)]
pub struct Signer {
    pub cert_der: Vec<u8>,
    pub key_pkcs8: Vec<u8>,
    pub fingerprint: String,
}

pub struct Ca {
    pub issuer: rcgen::Issuer<'static, KeyPair>,
    pub der: Vec<u8>,
}

fn params_for(cn: &str, window: Option<(DateTime<Utc>, DateTime<Utc>)>) -> CertificateParams {
    let mut p = CertificateParams::new(vec![]).unwrap();
    p.distinguished_name.push(DnType::CommonName, cn);
    if let Some((nb, na)) = window {
        p.not_before = rcgen::date_time_ymd(
            nb.format("%Y").to_string().parse().unwrap(),
            nb.format("%m").to_string().parse().unwrap(),
            nb.format("%d").to_string().parse().unwrap(),
        );
        p.not_after = rcgen::date_time_ymd(
            na.format("%Y").to_string().parse().unwrap(),
            na.format("%m").to_string().parse().unwrap(),
            na.format("%d").to_string().parse().unwrap(),
        );
    }
    p
}

pub fn self_signed(cn: &str) -> Signer {
    self_signed_window(cn, None)
}

/// A self-signed Ed25519 signer valid only inside `window` (day precision).
pub fn self_signed_window(cn: &str, window: Option<(DateTime<Utc>, DateTime<Utc>)>) -> Signer {
    let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
    let cert = params_for(cn, window).self_signed(&key).unwrap();
    let der = cert.der().to_vec();
    Signer {
        fingerprint: sha256_hex(&der),
        cert_der: der,
        key_pkcs8: key.serialize_der(),
    }
}

pub fn ca(cn: &str) -> Ca {
    let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
    let mut p = params_for(cn, None);
    p.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
    let cert = p.self_signed(&key).unwrap();
    Ca {
        der: cert.der().to_vec(),
        issuer: rcgen::Issuer::new(p, key),
    }
}

/// A self-signed Ed25519 certificate that is NOT a CA, used as a would-be root.
pub fn non_ca_root(cn: &str) -> Ca {
    let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
    let p = params_for(cn, None);
    let cert = p.self_signed(&key).unwrap();
    Ca {
        der: cert.der().to_vec(),
        issuer: rcgen::Issuer::new(p, key),
    }
}

pub fn leaf(cn: &str, ca: &Ca) -> Signer {
    let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
    let cert = params_for(cn, None).signed_by(&key, &ca.issuer).unwrap();
    let der = cert.der().to_vec();
    Signer {
        fingerprint: sha256_hex(&der),
        cert_der: der,
        key_pkcs8: key.serialize_der(),
    }
}

// ---------------------------------------------------------------- policy

pub fn pinned_policy(reviewer: &Signer, owner: &Signer, release: &Signer) -> TrustPolicy {
    TrustPolicy {
        schema_version: "holger.bundle-trust-policy/v1".into(),
        trust_mode: Some(TrustMode::PinnedSigners),
        trusted_roots: vec![],
        certificate_validity: CertificateValidity::Enforce,
        approved_signers: vec![
            ApprovedSigner {
                fingerprint: reviewer.fingerprint.clone(),
                subject: None,
                roles: vec!["security-reviewer".into()],
            },
            ApprovedSigner {
                fingerprint: owner.fingerprint.clone(),
                subject: None,
                roles: vec!["system-owner".into()],
            },
        ],
        required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
        minimum_distinct_approvers: 2,
        trusted_release_signers: vec![release.fingerprint.clone()],
        release_signer_must_be_distinct: true,
        maximum_bundle_age_secs: None,
        require_expiry: true,
        on_expired_metadata: Some(ExpiredPosture::ReadOnly),
        require_sbom: true,
        require_vex: false,
        require_scan: false,
        allowed_products: vec![],
        allowed_models: vec![],
    }
}

// --------------------------------------------------------------- bundles

pub struct Spec {
    pub sequence: u64,
    pub bundle_id: String,
    pub payloads: Vec<(String, Vec<u8>, String)>,
    pub evidence: Vec<(String, Vec<u8>, String, EvidenceKind)>,
    pub expires_in: Option<Duration>,
    pub rooted: bool,
}

impl Default for Spec {
    fn default() -> Self {
        Spec {
            sequence: 5,
            bundle_id: "bundle-0005".into(),
            payloads: vec![(
                "firmware.bin".into(),
                b"firmware-v1".to_vec(),
                "application/octet-stream".into(),
            )],
            evidence: vec![
                (
                    "sbom/app.cdx.json".into(),
                    br#"{"bomFormat":"CycloneDX","specVersion":"1.5"}"#.to_vec(),
                    "application/json".into(),
                    EvidenceKind::Sbom,
                ),
                (
                    "source-receipt.json".into(),
                    br#"{"mode":"Strict","complete":true}"#.to_vec(),
                    "application/json".into(),
                    EvidenceKind::SourceReceipt,
                ),
            ],
            expires_in: Some(Duration::days(30)),
            rooted: false,
        }
    }
}

pub struct Fixture {
    pub members: MemberMap,
    pub policy: TrustPolicy,
    pub reviewer: Signer,
    pub owner: Signer,
    pub release: Signer,
    pub candidate_digest: String,
    pub bundle_id: String,
    pub now: DateTime<Utc>,
}

pub fn preparation(spec: &Spec, now: DateTime<Utc>) -> Preparation {
    Preparation {
        bundle_id: spec.bundle_id.clone(),
        sequence: spec.sequence,
        created_at: now,
        expires_at: spec.expires_in.map(|d| now + d),
        producer: "intake-station".into(),
        supplier: "ExampleCorp".into(),
        product: "ExampleDrive".into(),
        release_version: "1.2.3".into(),
        supported_hardware_models: vec!["ACS880".into()],
        supported_hardware_revisions: vec!["R4".into()],
        source_provenance: SourceProvenance {
            source_kind: "vendor-portal".into(),
            source_uri: Some("https://vendor.example/downloads/fw".into()),
            source_repository: None,
            source_asset_path: None,
            fetched_at: Some(now),
            reported_size: Some(11),
            vendor_checksum: None,
            vendor_signature_status: Some("unverified".into()),
            tls_origin: Some("vendor.example".into()),
            transfer_receipt_digest: None,
        },
        policy_result: PolicyResult {
            policy_digest: sha256_hex(b"trust-policy-v1"),
            advisory_snapshot_digest: None,
            verdict: "pass".into(),
            non_production: true,
        },
        supersedes: vec![],
        withdraws: vec![],
        payloads: spec
            .payloads
            .iter()
            .map(|(p, b, m)| PayloadInput {
                relative_path: p.clone(),
                bytes: b.clone(),
                media_type: m.clone(),
            })
            .collect(),
        evidence: spec
            .evidence
            .iter()
            .map(|(p, b, m, k)| EvidenceInput {
                relative_path: p.clone(),
                bytes: b.clone(),
                media_type: m.clone(),
                kind: *k,
            })
            .collect(),
    }
}

/// A valid, two-approval, release-signed bundle, built by Holger's library.
pub fn build(spec: Spec) -> Fixture {
    let now = t0();
    let (reviewer, owner, release, mut policy) = if spec.rooted {
        let root = ca("bundle-root-ca");
        let reviewer = leaf("security-reviewer", &root);
        let owner = leaf("system-owner", &root);
        let release = leaf("release-signer", &root);
        let mut policy = pinned_policy(&reviewer, &owner, &release);
        policy.trust_mode = Some(TrustMode::RootedChain);
        policy.trusted_roots = vec![hex(&root.der)];
        (reviewer, owner, release, policy)
    } else {
        let reviewer = self_signed("security-reviewer");
        let owner = self_signed("system-owner");
        let release = self_signed("release-signer");
        let policy = pinned_policy(&reviewer, &owner, &release);
        (reviewer, owner, release, policy)
    };
    if spec.expires_in.is_none() {
        policy.require_expiry = false;
    }
    let candidate = preparation(&spec, now).build().unwrap();
    let candidate_digest = candidate.candidate_digest.clone();
    let mut members = candidate.members;
    add_approval(
        &mut members,
        "security-reviewer",
        &reviewer.cert_der,
        &reviewer.key_pkcs8,
        None,
        now,
    )
    .unwrap();
    add_approval(
        &mut members,
        "system-owner",
        &owner.cert_der,
        &owner.key_pkcs8,
        None,
        now,
    )
    .unwrap();
    finalize(
        &mut members,
        &policy,
        &release.cert_der,
        &release.key_pkcs8,
        now,
    )
    .unwrap();
    Fixture {
        members,
        policy,
        reviewer,
        owner,
        release,
        candidate_digest,
        bundle_id: spec.bundle_id,
        now,
    }
}

pub fn good() -> Fixture {
    build(Spec::default())
}

impl Fixture {
    /// The release-key holder rewrites the release manifest over the current
    /// members and signs it — `finalize` minus every check `finalize` makes.
    pub fn resign(&mut self) {
        self.resign_with(&self.release.clone());
    }

    pub fn resign_with(&mut self, signer: &Signer) {
        let core = self.members["core.json"].clone();
        let digest = sha256_hex(&core);
        self.resign_with_digest(signer, &digest);
    }

    pub fn resign_with_digest(&mut self, signer: &Signer, candidate_digest: &str) {
        self.members.remove(RELEASE_MANIFEST);
        self.members.remove(RELEASE_SIG);
        self.members
            .insert(RELEASE_CRT.into(), signer.cert_der.clone());
        let m = ReleaseManifest::build(&self.members, &self.bundle_id, candidate_digest);
        let bytes = m.to_json_bytes().unwrap();
        let sig = sign_pkcs8(&signer.key_pkcs8, &bytes).unwrap();
        self.members.insert(RELEASE_MANIFEST.into(), bytes);
        self.members.insert(RELEASE_SIG.into(), sig);
    }

    /// Pack with znippy's own writer, every member stored (the guest reads it).
    pub fn pack(&self) -> Vec<u8> {
        pack_stored(&self.members)
    }

    pub fn params(&self) -> Vec<u8> {
        params_for_policy(&self.policy, self.now, "")
    }

    pub fn params_with(&self, extra: &str) -> Vec<u8> {
        params_for_policy(&self.policy, self.now, extra)
    }

    /// The guest's verdict on these members, packed stored.
    pub fn guest(&self) -> Outcome {
        verify_bundle(&self.pack(), &self.params())
    }

    /// Holger's own verifier on the same archive bytes (the oracle).
    pub fn holger(
        &self,
        archive: &[u8],
    ) -> Result<holger_update_bundle::VerifyReport, BundleError> {
        holger_verify(archive, &self.policy, self.now)
    }
}

pub fn params_for_policy(policy: &TrustPolicy, now: DateTime<Utc>, extra: &str) -> Vec<u8> {
    format!(
        "VerifyParams(now: \"{}\", policy: {}, {extra})",
        now.to_rfc3339(),
        policy.to_ron().unwrap()
    )
    .into_bytes()
}

struct Fixed(DateTime<Utc>);
impl TrustedTime for Fixed {
    fn source_name(&self) -> &'static str {
        "test-fixed"
    }
    fn is_trusted(&self) -> bool {
        true
    }
    fn now(&self) -> Result<DateTime<Utc>, holger_update_bundle::TimeError> {
        Ok(self.0)
    }
}

pub fn holger_verify(
    archive: &[u8],
    policy: &TrustPolicy,
    now: DateTime<Utc>,
) -> Result<holger_update_bundle::VerifyReport, BundleError> {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path().join("b.holger.znippy");
    std::fs::write(&p, archive).unwrap();
    holger_update_bundle::verify_bundle_file_at(
        &p,
        policy,
        &holger_update_bundle::TargetSpec::default(),
        None,
        &Fixed(now),
    )
}

/// Pack with znippy's writer and `SkipPolicy::already_compressed()`: every
/// member stored, nothing through OpenZL.
pub fn pack_stored(members: &MemberMap) -> Vec<u8> {
    let staging = tempfile::tempdir().unwrap();
    for (rel, bytes) in members {
        let d = staging.path().join(rel);
        std::fs::create_dir_all(d.parent().unwrap()).unwrap();
        std::fs::write(&d, bytes).unwrap();
    }
    let out_dir = tempfile::tempdir().unwrap();
    let out: PathBuf = out_dir.path().join("bundle");
    znippy_compress::compress_dir_with_policy(
        &staging.path().to_path_buf(),
        &out,
        znippy_compress::SkipPolicy::already_compressed(),
        None,
        None,
        None,
    )
    .unwrap();
    std::fs::read(out.with_extension("znippy")).unwrap()
}

/// Pack with Holger's own `pack_bundle` (znippy's default policy: compressible
/// members go through OpenZL).
pub fn pack_holger(members: &MemberMap) -> Vec<u8> {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path().join("b.holger.znippy");
    holger_update_bundle::pack_bundle(members, &p).unwrap();
    std::fs::read(&p).unwrap()
}

/// The refusal code inside a tagged reason: `[holger-update-7] ...` -> `holger-update-7`.
pub fn code_of(o: &Outcome) -> String {
    let code = o
        .code()
        .unwrap_or_else(|| panic!("expected a refusal, got Accepted"))
        .to_string();
    let r = o.reason().unwrap();
    assert!(
        r.starts_with(&format!("[{code}] ")),
        "reason {r:?} does not carry its code {code}"
    );
    code
}

/// The holger code a BundleError carries.
pub fn holger_code(e: &BundleError) -> String {
    e.code().code.to_string()
}

#[track_caller]
pub fn assert_refused(o: &Outcome, code: &str, needle: &str) {
    let r = o
        .reason()
        .unwrap_or_else(|| panic!("expected refusal {code}, got Accepted"));
    assert_eq!(code_of(o), code, "wrong code; reason: {r}");
    assert!(
        r.contains(needle),
        "reason {r:?} does not mention {needle:?}"
    );
    assert!(o.emitted.is_empty(), "a refusal emitted outputs");
}