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/policy.rs

//! `holger.bundle-trust-policy/v1` — the offline trust policy a verifier enforces.
//!
//! The policy is a RON document. Role authorization is decided strictly by signer
//! **certificate fingerprint** (SHA-256 of the cert DER), never by a role string a
//! signer writes into its own statement.
//!
//! The ANCHORING posture is a stated field, `trust_mode`, never an inference:
//! `RootedChain` requires each signer/release certificate to chain to one of
//! `trusted_roots`, `PinnedSigners` requires only that the fingerprint is listed in
//! `approved_signers` / `trusted_release_signers`. It used to be inferred from
//! `trusted_roots` being empty, which meant a policy that LOST its roots — a typo,
//! a truncated write, a deliberate deletion by whoever could reach the file —
//! silently downgraded from "chain to my CA" to "no chain requirement at all", with
//! nothing in the verdict to show it. An absent decision now fails closed.

use serde::{Deserialize, Serialize};

use crate::digest::hex_decode;
use crate::error::{BundleError, Result};
use crate::schema::POLICY_SCHEMA;

/// How a signer certificate is ANCHORED — the posture a policy must state.
///
/// There is no default and no inferred value on purpose. The two postures answer
/// different threat models and a deployment must say which one it is running:
/// `RootedChain` survives key rotation (the CA re-issues and no policy file is
/// touched) but trusts the CA's issuance process; `PinnedSigners` trusts nothing
/// but the exact certificates enumerated, at the cost of editing the policy for
/// every rotation. Guessing on the operator's behalf is how "no roots configured"
/// became "every certificate is acceptable".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrustMode {
    /// Every signer/release certificate must chain to a certificate in
    /// `trusted_roots`, which must therefore be non-empty.
    RootedChain,
    /// No chain requirement: a signer is trusted iff its FINGERPRINT is named by
    /// the policy. `trusted_roots` must be empty, because nothing would read it.
    PinnedSigners,
}

/// Whether certificate validity periods (`notBefore` / `notAfter`) are enforced.
///
/// Enforcement is the default, and the opt-out is a named posture rather than a
/// bare `bool` so it reads the same way in a config as it does in a refusal.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CertificateValidity {
    /// Refuse a certificate that has expired or has not yet started. This is the
    /// default: an un-migrated policy inherits the SAFE posture.
    #[default]
    Enforce,
    /// Accept a certificate outside its validity window.
    ///
    /// For an airgapped box whose real-time clock is dead, battery-flat or never
    /// set: there `now` is fiction, and a fiction must not be allowed to refuse
    /// every bundle and brick a deployment that is otherwise perfectly signed.
    /// Choose this ONLY when the clock is genuinely untrustworthy — it also
    /// disables the only revocation an airgapped verifier has (see
    /// [`crate::signer::SignerCert::ensure_valid_at`]).
    IgnoreUnreliableClock,
}

/// What a downstream box does once its bundle metadata has EXPIRED.
///
/// **THE OWNER'S RULING (2026-09-28): [`ReadOnly`](Self::ReadOnly) is the
/// default** — keep serving what is already verified, accept nothing new, and
/// the admin sees it red. [`Refuse`](Self::Refuse) stays an explicit
/// per-estate choice (`on_expired_metadata: Some(Refuse)`). A policy that states
/// no posture is ReadOnly ([`TrustPolicy::expired_posture`]); a policy that
/// states something that is neither word is refused at the door by name
/// (`holger-update-39`).
///
/// By the time this is read the release signature has already verified — only
/// FRESHNESS is in doubt. ReadOnly accepts no writes (a box on stale metadata
/// can never extend, re-sign or re-date its own staleness); Refuse stops
/// serving. The IMPORT path refuses an expired bundle under both — an import is
/// a write.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExpiredPosture {
    /// Keep SERVING what is already verified, accept no writes.
    ReadOnly,
    /// Refuse outright: an expired bundle does not verify at all.
    Refuse,
}

/// A signer authorized to exercise one or more roles.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovedSigner {
    /// SHA-256 of the signer certificate DER (lowercase hex).
    pub fingerprint: String,
    /// Optional human-readable subject (informational; fingerprint is authoritative).
    #[serde(default)]
    pub subject: Option<String>,
    /// The roles this fingerprint may exercise.
    pub roles: Vec<String>,
}

/// The versioned trust policy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustPolicy {
    pub schema_version: String,

    /// The anchoring posture this policy runs. `None` is NOT a mode — it is an
    /// unanswered question, and [`TrustPolicy::trust_mode`] refuses it. The field
    /// is `Option` rather than a defaulted enum so that a policy written before
    /// the field existed produces a NAMED refusal
    /// (`holger-update-34`) instead of silently acquiring a posture nobody chose.
    #[serde(default)]
    pub trust_mode: Option<TrustMode>,

    /// Trust-anchor certificates as lowercase-hex DER. Required (non-empty) under
    /// [`TrustMode::RootedChain`], and must be empty under
    /// [`TrustMode::PinnedSigners`], where nothing would read it.
    #[serde(default)]
    pub trusted_roots: Vec<String>,

    /// Whether `notBefore` / `notAfter` are enforced on signer certificates.
    /// Defaults to [`CertificateValidity::Enforce`].
    #[serde(default)]
    pub certificate_validity: CertificateValidity,

    /// Which fingerprints may exercise which approval roles.
    #[serde(default)]
    pub approved_signers: Vec<ApprovedSigner>,

    /// Roles that MUST each be satisfied by a distinct approver (e.g.
    /// `["security-reviewer", "system-owner"]`).
    #[serde(default)]
    pub required_approval_roles: Vec<String>,

    /// Minimum number of distinct approver certificates.
    #[serde(default = "default_min_approvers")]
    pub minimum_distinct_approvers: usize,

    /// Fingerprints permitted to produce the final release signature.
    #[serde(default)]
    pub trusted_release_signers: Vec<String>,

    /// The release signer must not be one of the approvers.
    #[serde(default = "default_true")]
    pub release_signer_must_be_distinct: bool,

    /// Maximum age (seconds) between `created_at` and verification time.
    #[serde(default)]
    pub maximum_bundle_age_secs: Option<u64>,

    /// The bundle must carry an `expires_at`.
    #[serde(default)]
    pub require_expiry: bool,

    /// What to do when the bundle's metadata has EXPIRED (see
    /// [`ExpiredPosture`]). `None` — unstated — is the owner's default,
    /// ReadOnly (ruling 2026-09-28); `Some(Refuse)` is the explicit per-estate
    /// choice.
    #[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,

    /// Allowed target products (empty = any).
    #[serde(default)]
    pub allowed_products: Vec<String>,
    /// Allowed target hardware models (empty = any).
    #[serde(default)]
    pub allowed_models: Vec<String>,
}

fn default_min_approvers() -> usize {
    2
}
fn default_true() -> bool {
    true
}

impl TrustPolicy {
    /// Parse a policy from RON text.
    pub fn from_ron(text: &str) -> Result<Self> {
        let policy: TrustPolicy = ron::from_str(text).map_err(|e| {
            // An explicit posture that is neither ReadOnly nor Refuse is refused
            // BY NAME (`holger-update-39`), not as a generic parse error: the
            // operator wrote a posture, and it must be told which words exist.
            let msg = e.to_string();
            if msg.contains("ExpiredPosture")
                || (msg.contains("ReadOnly") && msg.contains("Refuse"))
            {
                BundleError::ExpiredPostureUnset
            } else {
                BundleError::Policy(format!("trust policy parse: {e}"))
            }
        })?;
        if policy.schema_version != POLICY_SCHEMA {
            return Err(BundleError::UnsupportedPolicySchema {
                found: policy.schema_version,
                expected: POLICY_SCHEMA,
            });
        }
        // Refuse an incoherent posture at the DOOR, so an operator learns about it
        // when the policy is loaded rather than when the first bundle arrives.
        // `trust_mode()` is nonetheless re-asked at every verification, because a
        // policy can also be built in memory and never pass through here.
        policy.trust_mode()?;
        Ok(policy)
    }

    /// The anchoring posture, or a refusal if the policy does not coherently state
    /// one. Every verification path asks this BEFORE it authorizes a certificate.
    ///
    /// Three ways to be unusable, three distinct verdicts:
    /// * no `trust_mode` at all — the question was never answered
    ///   (`holger-update-34`). An unanswered trust question is not a permissive
    ///   answer;
    /// * `RootedChain` with an empty `trusted_roots` (`holger-update-35`) — the
    ///   exact shape that used to mean "accept anything", now the loudest refusal
    ///   in the file;
    /// * `PinnedSigners` with roots listed (`holger-update-26`) — not dangerous by
    ///   itself, but the operator has configured an anchor that nothing reads and
    ///   believes it is being enforced. A trust policy with a decorative field in
    ///   it is a trust policy nobody can reason about.
    pub fn trust_mode(&self) -> Result<TrustMode> {
        match self.trust_mode {
            None => Err(BundleError::TrustModeUnset),
            Some(TrustMode::RootedChain) if self.trusted_roots.is_empty() => {
                Err(BundleError::TrustRootsEmpty)
            }
            Some(TrustMode::PinnedSigners) if !self.trusted_roots.is_empty() => {
                Err(BundleError::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),
        }
    }

    /// **The expired-metadata posture, or a refusal to start without one.**
    ///
    /// Called by every DOWNSTREAM verb (`holger-server bundle import`, `scrub`)
    /// before it touches the store. The producer's tools (`holger-bundle`,
    /// `holger-verify`) do not call it: they serve nothing, so the question is
    /// not theirs.
    ///
    /// Unstated is [`ExpiredPosture::ReadOnly`] — the owner's ruling
    /// (2026-09-28). A posture that is neither word never gets this far: it is
    /// refused when the policy is read ([`TrustPolicy::from_ron`],
    /// `holger-update-39`).
    ///
    /// # Errors
    ///
    /// None today; the `Result` is the downstream verbs' one call site, kept so
    /// a future posture that must be refused is refused here.
    pub fn expired_posture(&self) -> Result<ExpiredPosture> {
        Ok(self.on_expired_metadata.unwrap_or(ExpiredPosture::ReadOnly))
    }

    /// Serialize to pretty RON.
    pub fn to_ron(&self) -> Result<String> {
        ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
            .map_err(|e| BundleError::Policy(format!("trust policy serialize: {e}")))
    }

    /// Decode the trusted-root certificates from hex DER.
    pub fn root_certs_der(&self) -> Result<Vec<Vec<u8>>> {
        let mut out = Vec::with_capacity(self.trusted_roots.len());
        for (i, hexstr) in self.trusted_roots.iter().enumerate() {
            let der = hex_decode(hexstr).ok_or_else(|| {
                BundleError::Policy(format!("trusted_roots[{i}] is not valid hex"))
            })?;
            out.push(der);
        }
        Ok(out)
    }

    /// Look up the approved-signer record for a fingerprint.
    pub fn signer(&self, fingerprint: &str) -> Option<&ApprovedSigner> {
        self.approved_signers
            .iter()
            .find(|s| s.fingerprint.eq_ignore_ascii_case(fingerprint))
    }

    /// True if `fingerprint` is authorized for `role`.
    pub fn authorizes_role(&self, fingerprint: &str, role: &str) -> bool {
        self.signer(fingerprint)
            .map(|s| s.roles.iter().any(|r| r == role))
            .unwrap_or(false)
    }

    /// True if `fingerprint` is a permitted release signer.
    pub fn is_release_signer(&self, fingerprint: &str) -> bool {
        self.trusted_release_signers
            .iter()
            .any(|f| f.eq_ignore_ascii_case(fingerprint))
    }
}

#[cfg(test)]
mod expired_posture_tests {
    use super::*;

    /// The owner's ruling (2026-09-28): an unstated posture is ReadOnly; a
    /// posture that is neither word is refused by name at the door.
    #[test]
    fn an_unstated_posture_is_read_only_and_a_wrong_word_is_refused_by_name() {
        let ron = r#"(
            schema_version: "holger.bundle-trust-policy/v1",
            trust_mode: Some(PinnedSigners),
        )"#;
        let p = TrustPolicy::from_ron(ron).expect("the policy parses");
        assert_eq!(p.on_expired_metadata, None, "nothing stated");
        assert_eq!(
            p.expired_posture().unwrap(),
            ExpiredPosture::ReadOnly,
            "the owner's default"
        );
        let wrong = r#"(
            schema_version: "holger.bundle-trust-policy/v1",
            trust_mode: Some(PinnedSigners),
            on_expired_metadata: Some(Degrade),
        )"#;
        let err = TrustPolicy::from_ron(wrong).expect_err("a posture that is no posture");
        assert!(matches!(err, BundleError::ExpiredPostureUnset), "{err}");
        assert_eq!(err.code().code, "holger-update-39");
    }

    /// Both answers are reachable by NAME, so the choice is in the file; Refuse
    /// is the explicit per-estate choice.
    #[test]
    fn each_posture_is_selectable_and_round_trips() {
        for (word, want) in [
            ("ReadOnly", ExpiredPosture::ReadOnly),
            ("Refuse", ExpiredPosture::Refuse),
        ] {
            let ron = format!(
                r#"(
                schema_version: "holger.bundle-trust-policy/v1",
                trust_mode: Some(PinnedSigners),
                on_expired_metadata: Some({word}),
            )"#
            );
            let p = TrustPolicy::from_ron(&ron).expect("parses");
            assert_eq!(p.expired_posture().unwrap(), want);
            let back = TrustPolicy::from_ron(&p.to_ron().unwrap()).unwrap();
            assert_eq!(back.expired_posture().unwrap(), want);
        }
    }

    /// `trust_mode` is refused at PARSE; the posture at the downstream's START.
    /// Both refuse by name, neither is defaulted.
    #[test]
    fn trust_mode_still_has_no_default() {
        let ron = r#"( schema_version: "holger.bundle-trust-policy/v1" )"#;
        let err = TrustPolicy::from_ron(ron)
            .expect_err("a policy with no trust_mode is refused, not defaulted");
        assert!(
            matches!(err, BundleError::TrustModeUnset),
            "the refusal names the missing posture, got: {err}"
        );
    }
}