update-bundle/src/tests/trust_anchors.rs
//! The two questions a verifier must answer about a SIGNER CERTIFICATE before it
//! believes anything the certificate signed: *is this key anchored in something I
//! decided to trust*, and *is the certificate in force right now*.
//!
//! Both were answerable "yes by accident" until these tests existed.
//!
//! 1. `chains_to_root` returned `true` for an EMPTY root set and called it
//! "certificate-pinning mode". Pinning is a real posture — but it was INFERRED
//! from an absence, so a policy that lost its `trusted_roots` (a typo, a
//! rendering bug, a half-written config, a deliberate deletion by whoever could
//! reach the file) silently stopped being a chain check and started being no
//! check at all. Absence of configuration must fail closed; a posture must be
//! STATED. Every shipped policy used exactly that empty-list mode, so the X.509
//! branch was barely exercised on top of it.
//!
//! 2. Nothing anywhere read `notBefore` / `notAfter`. A signer certificate that
//! expired in 2019, or one minted to start in 2031, verified signatures exactly
//! as well as a live one. Revocation-by-expiry is the ONLY revocation an
//! airgapped verifier has — there is no CRL and no OCSP on the other side of the
//! gap — so dropping it drops the only way a compromised signer ever stops being
//! trusted.
use chrono::{DateTime, TimeZone, Utc};
use rcgen::{date_time_ymd, CertificateParams, DnType, KeyPair, PKCS_ED25519};
use super::testkit::*;
use crate::digest::sha256_hex;
use crate::finalize::{add_approval, finalize};
use crate::policy::{CertificateValidity, TrustMode, TrustPolicy};
use crate::schema::POLICY_SCHEMA;
use crate::signer::{chains_to_root, SignerCert};
use crate::verify::verify_member_map;
use crate::TargetSpec;
fn code_of(e: &crate::error::BundleError) -> &'static str {
e.code().code
}
// ── 1. an empty trust-root list is not a trust anchor ───────────────────────
/// The defect, at the smallest possible scale: in rooted-chain mode, NO root means
/// nothing can chain, so every leaf must be refused. Before the fix this returned
/// `true` — "no roots configured" was read as "every root is acceptable".
#[test]
fn an_empty_root_set_anchors_nothing_in_rooted_chain_mode() {
let signer = self_signed("lonely-signer");
let leaf = SignerCert::parse(&signer.cert_der).unwrap();
assert!(
!chains_to_root(&leaf, &[], TrustMode::RootedChain),
"an empty root set must anchor NOTHING — it is the absence of a decision, \
not a decision to trust everything"
);
}
/// Pinning stays available, but only when the policy ASKS for it by name.
#[test]
fn pinned_mode_is_honored_when_it_is_requested_explicitly() {
let signer = self_signed("pinned-signer");
let leaf = SignerCert::parse(&signer.cert_der).unwrap();
assert!(
chains_to_root(&leaf, &[], TrustMode::PinnedSigners),
"explicitly requested pinning mode carries no chain requirement"
);
}
// ── 2. the policy must STATE its posture ────────────────────────────────────
fn policy_ron(trust_mode: &str, roots: &str) -> String {
format!(
r#"(
schema_version: "{POLICY_SCHEMA}",
{trust_mode}
trusted_roots: [{roots}],
approved_signers: [],
required_approval_roles: [],
minimum_distinct_approvers: 0,
trusted_release_signers: [],
)"#
)
}
/// A policy that says nothing about its trust posture is UNUSABLE, not permissive.
#[test]
fn a_policy_that_does_not_state_a_trust_mode_is_refused() {
let err = TrustPolicy::from_ron(&policy_ron("", "")).unwrap_err();
assert_eq!(code_of(&err), holger_errcode::UPDATE_TRUST_MODE_UNSET.code);
}
/// Stating `rooted_chain` and then supplying no roots is a contradiction, and the
/// dangerous one: it is exactly the shape the old implicit rule waved through.
#[test]
fn rooted_chain_mode_with_no_roots_is_refused() {
let err = TrustPolicy::from_ron(&policy_ron("trust_mode: Some(RootedChain),", "")).unwrap_err();
assert_eq!(code_of(&err), holger_errcode::UPDATE_TRUST_ROOTS_EMPTY.code);
}
/// The other contradiction: pinning mode ignores roots, so roots listed under it
/// are a configuration the operator believes is being enforced and is not.
#[test]
fn pinned_mode_with_roots_listed_is_refused() {
let err = TrustPolicy::from_ron(&policy_ron("trust_mode: Some(PinnedSigners),", "\"aabb\""))
.unwrap_err();
assert_eq!(code_of(&err), holger_errcode::UPDATE_POLICY.code);
}
/// The RON gate is not the only door: a policy built in memory (a test, a bench, a
/// server assembling one) must hit the same refusal at VERIFY time. This is the
/// check that makes the fix structural instead of parse-deep.
#[test]
fn verification_refuses_a_policy_whose_posture_is_unstated() {
let b = valid_bundle();
let mut policy = b.policy.clone();
policy.trust_mode = None;
let err =
verify_member_map(&b.members, &policy, &TargetSpec::default(), None, b.now).unwrap_err();
assert_eq!(code_of(&err), holger_errcode::UPDATE_TRUST_MODE_UNSET.code);
policy.trust_mode = Some(TrustMode::RootedChain);
policy.trusted_roots = vec![];
let err =
verify_member_map(&b.members, &policy, &TargetSpec::default(), None, b.now).unwrap_err();
assert_eq!(code_of(&err), holger_errcode::UPDATE_TRUST_ROOTS_EMPTY.code);
}
// ── 3. certificate validity periods ─────────────────────────────────────────
/// A self-signed ed25519 signer whose certificate is in force only between
/// `(y1,m1,d1)` and `(y2,m2,d2)`.
fn signer_valid_between(cn: &str, from: (i32, u8, u8), to: (i32, u8, u8)) -> TestSigner {
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let mut params = CertificateParams::new(vec![]).unwrap();
params.distinguished_name.push(DnType::CommonName, cn);
params.not_before = date_time_ymd(from.0, from.1, from.2);
params.not_after = date_time_ymd(to.0, to.1, to.2);
let cert = params.self_signed(&key).unwrap();
let der = cert.der().to_vec();
TestSigner {
fingerprint: sha256_hex(&der),
cert_der: der,
key_pkcs8: key.serialize_der(),
}
}
fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
}
/// Build a complete, correctly-signed bundle at `built_at` from the three signers
/// given, so a test can choose each certificate's validity window.
fn bundle_signed_by(
reviewer: &TestSigner,
owner: &TestSigner,
release: &TestSigner,
built_at: DateTime<Utc>,
) -> (crate::MemberMap, TrustPolicy) {
let prep = base_preparation(5, "bundle-0005", built_at);
let candidate = prep.build().unwrap();
let mut members = candidate.members;
add_approval(
&mut members,
"security-reviewer",
&reviewer.cert_der,
&reviewer.key_pkcs8,
None,
built_at,
)
.unwrap();
add_approval(
&mut members,
"system-owner",
&owner.cert_der,
&owner.key_pkcs8,
None,
built_at,
)
.unwrap();
let policy = pinned_policy(reviewer, owner, release);
finalize(
&mut members,
&policy,
&release.cert_der,
&release.key_pkcs8,
built_at,
)
.unwrap();
(members, policy)
}
/// A release certificate that has RUN OUT is refused — with its own code, not the
/// generic "untrusted signer", because an operator's response is different: renew
/// and re-sign, not "this bundle is hostile".
#[test]
fn an_expired_release_certificate_is_refused() {
let reviewer = signer_valid_between("security-reviewer", (2020, 1, 1), (2030, 1, 1));
let owner = signer_valid_between("system-owner", (2020, 1, 1), (2030, 1, 1));
let release = signer_valid_between("release-signer", (2020, 1, 1), (2021, 1, 1));
let (members, policy) = bundle_signed_by(&reviewer, &owner, &release, at(2020, 6, 1));
// Inside the window it verifies…
assert!(verify_member_map(
&members,
&policy,
&TargetSpec::default(),
None,
at(2020, 6, 1)
)
.is_ok());
// …and after the release certificate lapses it does not.
let err = verify_member_map(
&members,
&policy,
&TargetSpec::default(),
None,
at(2022, 6, 1),
)
.unwrap_err();
assert_eq!(
code_of(&err),
holger_errcode::UPDATE_CERTIFICATE_EXPIRED.code
);
}
/// An APPROVER certificate that has run out is refused the same way: the
/// two-person rule is only as good as the two identities behind it.
#[test]
fn an_expired_approver_certificate_is_refused() {
let reviewer = signer_valid_between("security-reviewer", (2020, 1, 1), (2021, 1, 1));
let owner = signer_valid_between("system-owner", (2020, 1, 1), (2030, 1, 1));
let release = signer_valid_between("release-signer", (2020, 1, 1), (2030, 1, 1));
let (members, policy) = bundle_signed_by(&reviewer, &owner, &release, at(2020, 6, 1));
let err = verify_member_map(
&members,
&policy,
&TargetSpec::default(),
None,
at(2022, 6, 1),
)
.unwrap_err();
assert_eq!(
code_of(&err),
holger_errcode::UPDATE_CERTIFICATE_EXPIRED.code
);
}
/// A certificate that has not STARTED yet is a different verdict from one that has
/// run out, because the likeliest cause is different: a backdated verifier clock,
/// not a lapsed key. Telling them apart is the whole point of the code registry.
#[test]
fn a_not_yet_valid_certificate_is_refused_with_its_own_verdict() {
let reviewer = signer_valid_between("security-reviewer", (2020, 1, 1), (2030, 1, 1));
let owner = signer_valid_between("system-owner", (2020, 1, 1), (2030, 1, 1));
let release = signer_valid_between("release-signer", (2020, 1, 1), (2030, 1, 1));
let (members, policy) = bundle_signed_by(&reviewer, &owner, &release, at(2020, 6, 1));
let err = verify_member_map(
&members,
&policy,
&TargetSpec::default(),
None,
at(2015, 6, 1),
)
.unwrap_err();
assert_eq!(
code_of(&err),
holger_errcode::UPDATE_CERTIFICATE_NOT_YET_VALID.code
);
}
/// The escape hatch an airgapped box with a dead RTC needs — and it must be
/// REQUESTED, so a deployment that never thought about its clock still gets the
/// enforcement.
#[test]
fn the_unreliable_clock_opt_out_accepts_a_lapsed_certificate() {
let reviewer = signer_valid_between("security-reviewer", (2020, 1, 1), (2021, 1, 1));
let owner = signer_valid_between("system-owner", (2020, 1, 1), (2021, 1, 1));
let release = signer_valid_between("release-signer", (2020, 1, 1), (2021, 1, 1));
let (members, mut policy) = bundle_signed_by(&reviewer, &owner, &release, at(2020, 6, 1));
policy.require_expiry = false;
policy.certificate_validity = CertificateValidity::IgnoreUnreliableClock;
// The bundle's own expiry is still enforced; only the CERTIFICATE window is
// waived, so verify at a time the bundle itself is still inside.
assert!(verify_member_map(
&members,
&policy,
&TargetSpec::default(),
None,
at(2020, 6, 20)
)
.is_ok());
let err = verify_member_map(
&members,
&policy,
&TargetSpec::default(),
None,
at(2022, 6, 1),
)
.unwrap_err();
assert_eq!(
code_of(&err),
holger_errcode::UPDATE_EXPIRED.code,
"waiving the certificate window must not waive the BUNDLE's expiry: {err}"
);
}
/// Enforcement is the DEFAULT: a policy that says nothing about certificate
/// validity gets the check. `CertificateValidity::default()` is the posture an
/// un-migrated policy inherits, and it must be the safe one.
#[test]
fn certificate_validity_enforcement_is_the_default_posture() {
assert_eq!(CertificateValidity::default(), CertificateValidity::Enforce);
let parsed =
TrustPolicy::from_ron(&policy_ron("trust_mode: Some(PinnedSigners),", "")).unwrap();
assert_eq!(parsed.certificate_validity, CertificateValidity::Enforce);
}
/// The approved-signer list is a set of fingerprints and says nothing about time.
/// A pinned signer whose certificate lapsed is STILL refused: pinning is an
/// anchoring decision, not a promise that the key lives forever.
#[test]
fn pinning_does_not_exempt_a_certificate_from_its_validity_window() {
let signer = signer_valid_between("pinned-but-lapsed", (2020, 1, 1), (2021, 1, 1));
let cert = SignerCert::parse(&signer.cert_der).unwrap();
let err = cert.ensure_valid_at(at(2022, 1, 1)).unwrap_err();
assert_eq!(
code_of(&err),
holger_errcode::UPDATE_CERTIFICATE_EXPIRED.code
);
}
/// The SHIPPED sample policy must be a policy this verifier accepts. An example
/// that no longer parses teaches an operator the wrong shape, and the trust mode is
/// exactly the field they will copy.
#[test]
fn the_shipped_example_policy_states_its_posture() {
let text = include_str!("../../../examples/secure-update-gateway/trust-policy.ron");
let policy = TrustPolicy::from_ron(text).expect("the shipped example policy must parse");
assert_eq!(policy.trust_mode().unwrap(), TrustMode::PinnedSigners);
assert_eq!(policy.certificate_validity, CertificateValidity::Enforce);
}