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}"
);
}
}