Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/cli/bundle.rs

//! `holger-cli bundle prepare|approve|finalize|inspect|extract` — author, approve and
//! finalize holger update bundles (this was the `holger-bundle` program).
//!
//! Workflow: `prepare` a candidate directory, collect two `approve` signatures from
//! distinct roles, then `finalize` into a signed `.holger.znippy`. Private keys are
//! read only to sign and never copied into the bundle or printed.

use std::path::{Path, PathBuf};

use chrono::{Duration, Utc};
use clap::{Args, Subcommand};

use crate::container::pack_bundle;
use crate::core::{EvidenceKind, PolicyResult, SourceProvenance};
use crate::digest::sha256_hex;
use crate::finalize::{add_approval, candidate_digest, finalize};
use crate::prepare::{
    read_members_dir, write_members_dir, EvidenceInput, PayloadInput, Preparation,
};
use crate::receipt::FinalizationReceipt;
use crate::TrustPolicy;

/// The local, offline verbs of a bundle's life (no server is dialled).
#[derive(Subcommand, Debug)]
#[allow(clippy::large_enum_variant)] // clap subcommand args structs; boxing hurts ergonomics
pub enum BundleTool {
    /// Build a candidate bundle directory from payloads + evidence.
    Prepare(PrepareArgs),
    /// Sign an approval for a candidate directory in a given role.
    Approve(ApproveArgs),
    /// Verify approvals + policy and finalize into a signed `.holger.znippy`.
    Finalize(FinalizeArgs),
    /// Print the envelope + core summary of a candidate directory.
    Inspect(InspectArgs),
    /// Verify a bundle against a trust policy, then extract it into an empty directory
    /// (it always verifies first).
    Extract(super::verify::ExtractArgs),
}

#[derive(Args, Debug)]
pub struct PrepareArgs {
    /// Output candidate directory (created).
    #[arg(long)]
    candidate: PathBuf,
    #[arg(long)]
    bundle_id: String,
    #[arg(long)]
    sequence: u64,
    #[arg(long)]
    supplier: String,
    #[arg(long)]
    product: String,
    #[arg(long)]
    release_version: String,
    #[arg(long, default_value = "holger-bundle")]
    producer: String,
    #[arg(long = "model")]
    models: Vec<String>,
    #[arg(long = "revision")]
    revisions: Vec<String>,
    #[arg(long, default_value_t = 365)]
    expires_days: i64,
    /// Payload artifact file(s) (repeatable).
    #[arg(long = "payload")]
    payloads: Vec<PathBuf>,
    #[arg(long)]
    sbom: Option<PathBuf>,
    #[arg(long)]
    vex: Option<PathBuf>,
    #[arg(long)]
    scan: Option<PathBuf>,
    /// Strict transfer receipt (embedded as evidence/source-receipt.json).
    #[arg(long)]
    receipt: Option<PathBuf>,
    /// Advisory-DB snapshot metadata file.
    #[arg(long)]
    advisory_snapshot: Option<PathBuf>,
    /// Accepted-exception document(s).
    #[arg(long = "exception")]
    exceptions: Vec<PathBuf>,
    /// The reviewers' verdict, recorded in core.json and covered by every
    /// signature. REQUIRED, with no default: a candidate nobody reviewed must
    /// never read as passed. Only `pass` can later be finalized and imported.
    #[arg(long)]
    verdict: String,
    /// Mark the advisory evidence as production intelligence (default: non-production).
    #[arg(long)]
    production: bool,
    #[arg(long)]
    source_kind: Option<String>,
    #[arg(long)]
    source_uri: Option<String>,
}

#[derive(Args, Debug)]
pub struct ApproveArgs {
    #[arg(long)]
    candidate: PathBuf,
    #[arg(long)]
    role: String,
    /// Signer certificate (DER or PEM).
    #[arg(long)]
    cert: PathBuf,
    /// Signer private key (PKCS#8 DER or PEM).
    #[arg(long)]
    key: PathBuf,
    #[arg(long)]
    comment: Option<String>,
}

#[derive(Args, Debug)]
pub struct FinalizeArgs {
    #[arg(long)]
    candidate: PathBuf,
    #[arg(long)]
    policy: PathBuf,
    #[arg(long)]
    release_cert: PathBuf,
    #[arg(long)]
    release_key: PathBuf,
    /// Output `.holger.znippy` file.
    #[arg(long)]
    output: PathBuf,
    /// Optional finalization-receipt output path.
    #[arg(long)]
    receipt: Option<PathBuf>,
}

#[derive(Args, Debug)]
pub struct InspectArgs {
    #[arg(long)]
    candidate: PathBuf,
}

/// Run one local bundle verb.
pub fn run(cmd: BundleTool) -> anyhow::Result<()> {
    match cmd {
        BundleTool::Prepare(a) => cmd_prepare(a),
        BundleTool::Approve(a) => cmd_approve(a),
        BundleTool::Finalize(a) => cmd_finalize(a),
        BundleTool::Inspect(a) => cmd_inspect(a),
        BundleTool::Extract(a) => super::verify::extract(a),
    }
}

fn media_type_for(path: &Path) -> String {
    match path.extension().and_then(|e| e.to_str()) {
        Some("json") => "application/json",
        Some("xml") => "application/xml",
        _ => "application/octet-stream",
    }
    .to_string()
}

fn file_name(path: &Path) -> anyhow::Result<String> {
    Ok(path
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| anyhow::anyhow!("invalid file name"))?
        .to_string())
}

fn cmd_prepare(a: PrepareArgs) -> anyhow::Result<()> {
    let now = Utc::now();
    let mut payloads = Vec::new();
    for p in &a.payloads {
        payloads.push(PayloadInput {
            relative_path: file_name(p)?,
            bytes: std::fs::read(p)?,
            media_type: media_type_for(p),
        });
    }
    let mut evidence = Vec::new();
    let mut add_ev =
        |opt: &Option<PathBuf>, subdir: &str, kind: EvidenceKind| -> anyhow::Result<()> {
            if let Some(p) = opt {
                evidence.push(EvidenceInput {
                    relative_path: format!("{subdir}/{}", file_name(p)?),
                    bytes: std::fs::read(p)?,
                    media_type: media_type_for(p),
                    kind,
                });
            }
            Ok(())
        };
    add_ev(&a.sbom, "sbom", EvidenceKind::Sbom)?;
    add_ev(&a.vex, "vex", EvidenceKind::Vex)?;
    add_ev(&a.scan, "scan", EvidenceKind::Scan)?;
    add_ev(&a.advisory_snapshot, "advisory", EvidenceKind::Advisory)?;
    for ex in &a.exceptions {
        evidence.push(EvidenceInput {
            relative_path: format!("exceptions/{}", file_name(ex)?),
            bytes: std::fs::read(ex)?,
            media_type: media_type_for(ex),
            kind: EvidenceKind::Exception,
        });
    }
    if let Some(p) = &a.receipt {
        evidence.push(EvidenceInput {
            relative_path: "source-receipt.json".into(),
            bytes: std::fs::read(p)?,
            media_type: "application/json".into(),
            kind: EvidenceKind::SourceReceipt,
        });
    }

    let advisory_digest = match &a.advisory_snapshot {
        Some(p) => Some(sha256_hex(&std::fs::read(p)?)),
        None => None,
    };

    let prep = Preparation {
        bundle_id: a.bundle_id,
        sequence: a.sequence,
        created_at: now,
        expires_at: Some(now + Duration::days(a.expires_days)),
        producer: a.producer,
        supplier: a.supplier,
        product: a.product,
        release_version: a.release_version,
        supported_hardware_models: a.models,
        supported_hardware_revisions: a.revisions,
        source_provenance: SourceProvenance {
            source_kind: a.source_kind.unwrap_or_else(|| "manual".into()),
            source_uri: a.source_uri,
            source_repository: None,
            source_asset_path: None,
            fetched_at: Some(now),
            reported_size: None,
            vendor_checksum: None,
            vendor_signature_status: None,
            tls_origin: None,
            transfer_receipt_digest: None,
        },
        policy_result: PolicyResult {
            policy_digest: sha256_hex(b"holger-bundle-prepare"),
            advisory_snapshot_digest: advisory_digest,
            verdict: a.verdict,
            non_production: !a.production,
        },
        supersedes: vec![],
        withdraws: vec![],
        payloads,
        evidence,
    };
    let candidate = prep.build()?;
    write_members_dir(&candidate.members, &a.candidate)?;
    println!("prepared candidate at {}", a.candidate.display());
    println!("candidate-digest: {}", candidate.candidate_digest);
    Ok(())
}

fn cmd_approve(a: ApproveArgs) -> anyhow::Result<()> {
    let mut members = read_members_dir(&a.candidate)?;
    let cert_der = read_cert_or_key(&a.cert)?;
    let key_der = read_cert_or_key(&a.key)?;
    let digest = candidate_digest(&members)?;
    let rec = add_approval(
        &mut members,
        &a.role,
        &cert_der,
        &key_der,
        a.comment,
        Utc::now(),
    )?;
    write_members_dir(&members, &a.candidate)?;
    println!(
        "approved as role {} by {} [{}]",
        rec.role, rec.subject, rec.fingerprint
    );
    println!("candidate-digest: {digest}");
    Ok(())
}

fn cmd_finalize(a: FinalizeArgs) -> anyhow::Result<()> {
    let mut members = read_members_dir(&a.candidate)?;
    let policy = TrustPolicy::from_ron(&std::fs::read_to_string(&a.policy)?)?;
    let release_cert = read_cert_or_key(&a.release_cert)?;
    let release_key = read_cert_or_key(&a.release_key)?;
    let report = finalize(
        &mut members,
        &policy,
        &release_cert,
        &release_key,
        Utc::now(),
    )?;
    pack_bundle(&members, &a.output)?;
    let bundle_bytes = std::fs::read(&a.output)?;
    let receipt = FinalizationReceipt {
        bundle_id: report.bundle_id.clone(),
        sequence: report.sequence,
        supplier: report.supplier.clone(),
        product: report.product.clone(),
        release_version: report.release_version.clone(),
        candidate_digest: report.candidate_digest.clone(),
        release_signer_fingerprint: report.release_signer_fingerprint.clone(),
        approvals: report.approvals.clone(),
        finalized_at: Utc::now(),
        bundle_sha256: sha256_hex(&bundle_bytes),
        bundle_size_bytes: bundle_bytes.len() as u64,
    };
    if let Some(rp) = &a.receipt {
        std::fs::write(rp, serde_json::to_vec_pretty(&receipt)?)?;
    }
    println!(
        "finalized {} ({} bytes)",
        a.output.display(),
        receipt.bundle_size_bytes
    );
    println!("bundle-sha256: {}", receipt.bundle_sha256);
    println!("candidate-digest: {}", receipt.candidate_digest);
    for ap in &receipt.approvals {
        println!("  approval: {} [{}]", ap.role, ap.fingerprint);
    }
    Ok(())
}

fn cmd_inspect(a: InspectArgs) -> anyhow::Result<()> {
    let members = read_members_dir(&a.candidate)?;
    let core = members
        .get(crate::schema::CORE_JSON)
        .ok_or_else(|| anyhow::anyhow!("no core.json in candidate"))?;
    let core = crate::BundleCore::from_json_bytes(core)?;
    println!("bundle-id:        {}", core.bundle_id);
    println!("supplier/product: {} / {}", core.supplier, core.product);
    println!("release-version:  {}", core.release_version);
    println!("sequence:         {}", core.sequence);
    println!("candidate-digest: {}", candidate_digest(&members)?);
    println!("payloads:         {}", core.payloads.len());
    println!("evidence:         {}", core.evidence.len());
    let approvals = members
        .keys()
        .filter(|k| k.ends_with(".approval.json"))
        .count();
    println!("approvals:        {approvals}");
    Ok(())
}

// ---- cert/key loading (DER or PEM) ----

fn read_cert_or_key(path: &Path) -> anyhow::Result<Vec<u8>> {
    let raw = std::fs::read(path)?;
    if raw.starts_with(b"-----BEGIN") {
        pem_to_der(&raw)
    } else {
        Ok(raw)
    }
}

fn pem_to_der(pem: &[u8]) -> anyhow::Result<Vec<u8>> {
    let s = std::str::from_utf8(pem)?;
    let b64: String = s
        .lines()
        .filter(|l| !l.starts_with("-----"))
        .flat_map(|l| l.chars())
        .filter(|c| !c.is_whitespace())
        .collect();
    base64_decode(&b64)
}

fn base64_decode(s: &str) -> anyhow::Result<Vec<u8>> {
    fn val(c: u8) -> anyhow::Result<u8> {
        Ok(match c {
            b'A'..=b'Z' => c - b'A',
            b'a'..=b'z' => c - b'a' + 26,
            b'0'..=b'9' => c - b'0' + 52,
            b'+' => 62,
            b'/' => 63,
            _ => anyhow::bail!("invalid base64 character"),
        })
    }
    let bytes: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
    for chunk in bytes.chunks(4) {
        let mut buf = [0u8; 4];
        let n = chunk.len();
        for (i, &c) in chunk.iter().enumerate() {
            buf[i] = val(c)?;
        }
        let b0 = (buf[0] << 2) | (buf[1] >> 4);
        out.push(b0);
        if n >= 3 {
            out.push((buf[1] << 4) | (buf[2] >> 2));
        }
        if n >= 4 {
            out.push((buf[2] << 6) | buf[3]);
        }
    }
    Ok(out)
}