Gunnar

nordisk/heimdall

guests/verify-bundle/src/model.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The bundle's documents, as Holger writes them: `core.json`, `manifest.json`,
//! `signatures/release-manifest.json`, the approval statements, and the trust
//! policy. Field names, defaults and enum spellings are Holger's
//! (`update-bundle/src/{core,manifest,approval,policy}.rs`) so the same bytes
//! parse to the same values. These structs are only READ: every digest and
//! signature is checked over the exact stored bytes, never a re-serialization.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::digest::hex_decode;
use crate::limits::{BUNDLE_SCHEMA, POLICY_SCHEMA};
use crate::refusal::{Refusal, Result};

// ---------------------------------------------------------------- core.json

#[derive(Debug, Clone, Deserialize)]
pub struct BundleCore {
    pub schema_version: String,
    pub bundle_id: String,
    pub sequence: u64,
    pub created_at: DateTime<Utc>,
    #[serde(default)]
    pub expires_at: Option<DateTime<Utc>>,
    pub producer: String,
    pub supplier: String,
    pub product: String,
    pub release_version: String,
    #[serde(default)]
    pub supported_hardware_models: Vec<String>,
    #[serde(default)]
    pub supported_hardware_revisions: Vec<String>,
    pub source_provenance: SourceProvenance,
    #[serde(default)]
    pub payloads: Vec<PayloadEntry>,
    #[serde(default)]
    pub evidence: Vec<EvidenceEntry>,
    pub policy_result: PolicyResult,
    #[serde(default)]
    pub supersedes: Vec<String>,
    #[serde(default)]
    pub withdraws: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct SourceProvenance {
    pub source_kind: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct PayloadEntry {
    pub relative_path: String,
    pub media_type: String,
    pub size_bytes: u64,
    pub sha256: String,
    pub blake3: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceKind {
    SourceReceipt,
    Sbom,
    Vex,
    Scan,
    Advisory,
    Exception,
    Other,
}

#[derive(Debug, Clone, Deserialize)]
pub struct EvidenceEntry {
    pub relative_path: String,
    pub media_type: String,
    pub size_bytes: u64,
    pub sha256: String,
    pub blake3: String,
    pub evidence_kind: EvidenceKind,
}

#[derive(Debug, Clone, Deserialize)]
pub struct PolicyResult {
    pub policy_digest: String,
    pub verdict: String,
    #[serde(default = "default_true")]
    pub non_production: bool,
}

fn default_true() -> bool {
    true
}

impl BundleCore {
    pub const PASS_VERDICT: &'static str = "pass";

    pub fn parse(bytes: &[u8]) -> Result<Self> {
        Ok(serde_json::from_slice(bytes)?)
    }

    pub fn require_pass_verdict(&self) -> Result<()> {
        if self.policy_result.verdict == Self::PASS_VERDICT {
            Ok(())
        } else {
            Err(Refusal::VerdictNotPass(self.policy_result.verdict.clone()))
        }
    }

    pub fn schema_ok(&self) -> bool {
        self.schema_version == BUNDLE_SCHEMA
    }

    pub fn has_evidence_kind(&self, kind: EvidenceKind) -> bool {
        self.evidence.iter().any(|e| e.evidence_kind == kind)
    }
}

// ------------------------------------------------- release manifest, envelope

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct MemberDigest {
    pub relative_path: String,
    pub size_bytes: u64,
    pub sha256: String,
    pub blake3: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ReleaseManifest {
    pub schema_version: String,
    pub bundle_id: String,
    pub candidate_digest: String,
    pub members: Vec<MemberDigest>,
}

impl ReleaseManifest {
    pub fn parse(bytes: &[u8]) -> Result<Self> {
        Ok(serde_json::from_slice(bytes)?)
    }

    /// Index the members by path. A path listed twice is a refusal: Holger's
    /// own index would silently keep the last one, and a manifest that says
    /// two different things about one member says nothing.
    pub fn index(&self) -> Result<std::collections::BTreeMap<&str, &MemberDigest>> {
        let mut m = std::collections::BTreeMap::new();
        for d in &self.members {
            if m.insert(d.relative_path.as_str(), d).is_some() {
                return Err(Refusal::Malformed(format!(
                    "release manifest lists {:?} twice",
                    d.relative_path
                )));
            }
        }
        Ok(m)
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct EnvelopeManifest {
    pub schema_version: String,
    pub bundle_id: String,
    pub sequence: u64,
    pub supplier: String,
    pub product: String,
    pub release_version: String,
    pub created_at: DateTime<Utc>,
    pub member_count: usize,
    pub core_digest: String,
}

// ---------------------------------------------------------------- approvals

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Decision {
    Approve,
    Reject,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ApprovalStatement {
    pub candidate_digest: String,
    pub approval_role: String,
    pub signer_certificate_fingerprint: String,
    pub signer_subject: String,
    pub approved_at: DateTime<Utc>,
    pub decision: Decision,
    #[serde(default)]
    pub optional_comment: Option<String>,
    pub signature_algorithm: String,
    pub signature_path: String,
}

impl ApprovalStatement {
    pub fn parse(bytes: &[u8]) -> Result<Self> {
        Ok(serde_json::from_slice(bytes)?)
    }
    pub fn is_approve(&self) -> bool {
        self.decision == Decision::Approve
    }
}

// ------------------------------------------------------------- trust policy

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrustMode {
    RootedChain,
    PinnedSigners,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CertificateValidity {
    #[default]
    Enforce,
    IgnoreUnreliableClock,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExpiredPosture {
    ReadOnly,
    Refuse,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovedSigner {
    pub fingerprint: String,
    #[serde(default)]
    pub subject: Option<String>,
    pub roles: Vec<String>,
}

/// `holger.bundle-trust-policy/v1`, the same RON a Holger downstream reads.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustPolicy {
    pub schema_version: String,
    #[serde(default)]
    pub trust_mode: Option<TrustMode>,
    #[serde(default)]
    pub trusted_roots: Vec<String>,
    #[serde(default)]
    pub certificate_validity: CertificateValidity,
    #[serde(default)]
    pub approved_signers: Vec<ApprovedSigner>,
    #[serde(default)]
    pub required_approval_roles: Vec<String>,
    #[serde(default = "default_min_approvers")]
    pub minimum_distinct_approvers: usize,
    #[serde(default)]
    pub trusted_release_signers: Vec<String>,
    #[serde(default = "default_true")]
    pub release_signer_must_be_distinct: bool,
    #[serde(default)]
    pub maximum_bundle_age_secs: Option<u64>,
    #[serde(default)]
    pub require_expiry: bool,
    #[serde(default)]
    pub on_expired_metadata: Option<ExpiredPosture>,
    #[serde(default)]
    pub require_sbom: bool,
    #[serde(default)]
    pub require_vex: bool,
    #[serde(default)]
    pub require_scan: bool,
    #[serde(default)]
    pub allowed_products: Vec<String>,
    #[serde(default)]
    pub allowed_models: Vec<String>,
}

fn default_min_approvers() -> usize {
    2
}

impl TrustPolicy {
    /// Refuse a policy that is not `holger.bundle-trust-policy/v1`.
    pub fn check_schema(&self) -> Result<()> {
        if self.schema_version != POLICY_SCHEMA {
            return Err(Refusal::UnsupportedPolicySchema {
                found: self.schema_version.clone(),
                expected: POLICY_SCHEMA,
            });
        }
        Ok(())
    }

    /// The anchoring posture, or a refusal (Holger's three ways to be unusable).
    pub fn trust_mode(&self) -> Result<TrustMode> {
        match self.trust_mode {
            None => Err(Refusal::TrustModeUnset),
            Some(TrustMode::RootedChain) if self.trusted_roots.is_empty() => {
                Err(Refusal::TrustRootsEmpty)
            }
            Some(TrustMode::PinnedSigners) if !self.trusted_roots.is_empty() => {
                Err(Refusal::Policy(
                    "trust_mode is PinnedSigners but trusted_roots is not empty — under \
                     pinning no chain is checked, so those roots enforce nothing"
                        .into(),
                ))
            }
            Some(mode) => Ok(mode),
        }
    }

    pub fn root_certs_der(&self) -> Result<Vec<Vec<u8>>> {
        self.trusted_roots
            .iter()
            .enumerate()
            .map(|(i, h)| {
                hex_decode(h)
                    .ok_or_else(|| Refusal::Policy(format!("trusted_roots[{i}] is not valid hex")))
            })
            .collect()
    }

    pub fn authorizes_role(&self, fingerprint: &str, role: &str) -> bool {
        self.approved_signers
            .iter()
            .find(|s| s.fingerprint.eq_ignore_ascii_case(fingerprint))
            .map(|s| s.roles.iter().any(|r| r == role))
            .unwrap_or(false)
    }

    pub fn is_release_signer(&self, fingerprint: &str) -> bool {
        self.trusted_release_signers
            .iter()
            .any(|f| f.eq_ignore_ascii_case(fingerprint))
    }
}

// ------------------------------------------------------------ anti-rollback

/// One accepted bundle's identity (Holger's `rollback::AcceptedEntry`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcceptedEntry {
    pub sequence: u64,
    pub bundle_id: String,
    pub core_digest: String,
}

/// Holger's `AcceptedState::check_admissible`, over a list the host hands in.
pub fn check_admissible(
    accepted: &[AcceptedEntry],
    core: &BundleCore,
    candidate_digest: &str,
) -> Result<()> {
    for e in accepted {
        if e.bundle_id == core.bundle_id && e.core_digest != candidate_digest {
            return Err(Refusal::BundleIdReuse(core.bundle_id.clone()));
        }
    }
    if let Some(hi) = accepted.iter().map(|e| e.sequence).max() {
        if core.sequence < hi {
            return Err(Refusal::SequenceRollback {
                found: core.sequence,
                accepted: hi,
            });
        }
    }
    if let Some(existing) = accepted.iter().find(|e| e.sequence == core.sequence) {
        if existing.core_digest == candidate_digest && existing.bundle_id == core.bundle_id {
            return Ok(()); // the idempotent re-import
        }
        return Err(Refusal::SequenceReplay(core.sequence));
    }
    Ok(())
}