crates/gunnar-core/src/expiry.rs
//! **An expired certificate on a box with no shell is a brick, and this is the
//! thing that refuses before it becomes one.**
//!
//! # The risk, stated plainly
//!
//! The appliance and the twin ship with `shell = false`. There is no way in to
//! fix a certificate by hand, and **their only door is the very gRPC plane the
//! certificate protects**. A client certificate that lapses does not degrade
//! service; it removes the last way to repair the box. The issuer is ten years
//! ([`DEFAULT_ISSUER_DAYS`](crate::DEFAULT_ISSUER_DAYS)) so it is a planned
//! event, but a leaf is **one year**
//! ([`DEFAULT_LEAF_DAYS`](crate::DEFAULT_LEAF_DAYS)) and one year passes
//! without anybody doing anything at all.
//!
//! # Is WireGuard underneath enough to recover? NO, and it must be said so
//!
//! The tunnel keeps carrying packets whatever the certificate says — it is a
//! different key, a different lifetime and no expiry at all. So after a lapse
//! the box is still *reachable*. It is not *repairable*: the thing listening at
//! the other end of the tunnel is the mutual listener, and it refuses the
//! expired peer at the handshake, before any RPC exists to fix anything with.
//! WireGuard turns "unreachable brick" into "reachable brick". That is worth
//! having and it is not a recovery.
//!
//! The recovery therefore has to be **not lapsing**, and that is what the
//! grades below are for.
//!
//! # Four grades, and why refusal comes EARLY
//!
//! [`Grade::Renew`] fires with a quarter of the lifetime left — 91 days on a
//! 365-day leaf. It is deliberately not "a week before": a box that only
//! notices at seven days has to be rescued inside a week by whoever reads the
//! log, and this estate's boxes are checked on a cadence of months.
//!
//! [`Grade::Refuse`] fires with [`REFUSE_DAYS`] left, **while the certificate
//! is still perfectly valid**. That is the whole idea and it is the estate's
//! own idiom: the refusal happens at a moment when the box can still be talked
//! to, so an operator is forced to act while acting is possible. A warning at
//! the same moment would be a line in a log nobody reads until the box is
//! already dead — the health line that cannot fail, which this codebase has
//! found eight of in a single day. A door that refuses while it can still be
//! opened is worth more than one that reports while it cannot.
//!
//! [`Grade::Expired`] is the state the two above exist to make unreachable.
/// Milliseconds in a day.
const DAY_MS: i64 = 86_400_000;
/// Start asking for a new certificate with a **quarter of the lifetime** left.
///
/// A fraction rather than a day count so a deployment that issues 30-day leaves
/// gets a proportionate warning instead of one that fires before the
/// certificate is installed.
pub const RENEW_AT_FRACTION_REMAINING: f64 = 0.25;
/// Refuse with this many days left — while the certificate still WORKS.
///
/// Fourteen days is two weekly cycles: long enough that a box checked once a
/// week is caught twice before it lapses, short enough that it is not firing
/// for a third of the certificate's life.
pub const REFUSE_DAYS: i64 = 14;
/// How close a certificate is to being the thing that bricks its box.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
/// Nothing to do.
Fine,
/// Renew now, loudly, by name. Still serving.
Renew,
/// **Refuse, while the door can still be opened.** See the module docs.
Refuse,
/// Already past `not_after`. The box is a brick unless something below the
/// control plane can still reach it.
Expired,
}
/// A certificate's position on that scale, with the numbers that produced it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Expiry {
/// Whole days from `now` to `not_after`. Negative once it has lapsed.
pub days_left: i64,
/// Whole days the certificate was issued for.
pub lifetime_days: i64,
/// How close it is to bricking its box.
pub grade: Grade,
}
/// Grade a certificate from its validity window and the current time, all in
/// Unix milliseconds.
///
/// Pure arithmetic on three numbers: no clock, no filesystem and no store, so
/// every grade can be driven from a test rather than waited for. A predicate
/// that can only be exercised by letting a year pass is a predicate that is
/// never exercised.
///
/// A `not_before` at or after `not_after` — a certificate valid for zero days,
/// which the issuer already refuses to mint — grades [`Grade::Expired`]
/// whatever the clock says, because a lifetime of zero cannot be a fraction of
/// anything and guessing would be worse than refusing.
pub fn grade(now_unix_ms: i64, not_before_unix_ms: i64, not_after_unix_ms: i64) -> Expiry {
let lifetime_days = (not_after_unix_ms - not_before_unix_ms).div_euclid(DAY_MS);
let days_left = (not_after_unix_ms - now_unix_ms).div_euclid(DAY_MS);
if lifetime_days <= 0 {
return Expiry {
days_left,
lifetime_days,
grade: Grade::Expired,
};
}
let renew_at = (lifetime_days as f64 * RENEW_AT_FRACTION_REMAINING).floor() as i64;
// **The refusal must never fire before the renewal was asked for.** On a
// leaf shorter than `REFUSE_DAYS / RENEW_AT_FRACTION_REMAINING` (56 days)
// the absolute threshold overtakes the proportional one, and a box would go
// straight from `Fine` to `Refuse` having never once been told to renew.
// That is a refusal nobody could have acted on, which is the opposite of
// this module's whole argument — so the absolute figure yields, and the
// refusal always sits at least a day inside the renewal window.
let refuse_at = REFUSE_DAYS.min((renew_at - 1).max(0));
let grade = if days_left < 0 {
Grade::Expired
} else if days_left <= refuse_at {
Grade::Refuse
} else if days_left <= renew_at {
Grade::Renew
} else {
Grade::Fine
};
Expiry {
days_left,
lifetime_days,
grade,
}
}
impl Expiry {
/// The line an operator has to be able to act on, naming the box's role,
/// the number of days, and what happens if nothing is done.
///
/// `who` is what the certificate is for — `"this twin's client certificate"`,
/// `"the control plane's own listener certificate"` — because the recovery
/// is different for each and a message that does not say which one it is
/// about sends the operator to the wrong file.
pub fn line(&self, who: &str) -> String {
match self.grade {
Grade::Fine => format!(
"{who} is good for {} more day(s) of its {}-day life.",
self.days_left, self.lifetime_days
),
Grade::Renew => format!(
"RE-ISSUE NOW: {who} expires in {} day(s) of its {}-day life. This box has no shell \
and its only door is the control plane this certificate opens, so a lapse is \
not a degraded service — it is a box nobody can repair. WireGuard underneath \
keeps it REACHABLE and cannot make it repairable.",
self.days_left, self.lifetime_days
),
Grade::Refuse => format!(
"REFUSED WITH {} DAY(S) STILL VALID: {who} expires in {} day(s). It is refused \
NOW, while the certificate still works and this box can still be talked to, \
because in {} day(s) it could not be. Re-enrol it — the control plane is open \
to you today and will not be. A warning here would be a line in a log read \
after the box was already dead.",
self.days_left, self.days_left, self.days_left
),
Grade::Expired => format!(
"EXPIRED: {who} lapsed {} day(s) ago. The mutual listener refuses this box at \
the handshake, before any RPC it could be repaired with. If it has no shell, \
the way back is re-imaging it from the medium and re-enrolling — WireGuard \
still carries packets to it and there is nothing at the other end that will \
take them.",
-self.days_left
),
}
}
/// Should this stop a box from starting or from following?
pub fn is_refusal(&self) -> bool {
matches!(self.grade, Grade::Refuse | Grade::Expired)
}
}
/// Grade a DER certificate against `now`.
///
/// The parse failing is **not** graded as fine: bytes that are not a
/// certificate are the same operational fact as a certificate that cannot be
/// used, and grading them `Fine` is how a broken file becomes a silent one.
pub fn grade_der(now_unix_ms: i64, der: &[u8]) -> Result<Expiry, crate::Error> {
let (_rest, cert) = x509_parser::parse_x509_certificate(der).map_err(|err| {
crate::Error::Pki(format!("this is not a usable X.509 certificate: {err}"))
})?;
let validity = cert.validity();
let ms = |t: x509_parser::time::ASN1Time| t.timestamp() * 1000;
Ok(grade(
now_unix_ms,
ms(validity.not_before),
ms(validity.not_after),
))
}
/// Grade the **first** certificate in a PEM bundle against `now`.
///
/// First and not last: a chain is written leaf-first, and the leaf is the one
/// with the short life and therefore the one that bricks the box. Grading the
/// issuer instead would report ten comfortable years while the credential that
/// actually opens the door had a fortnight left.
pub fn grade_pem(now_unix_ms: i64, pem: &[u8]) -> Result<Expiry, crate::Error> {
let (_rest, block) = x509_parser::pem::parse_x509_pem(pem)
.map_err(|err| crate::Error::Pki(format!("this is not a usable PEM certificate: {err}")))?;
grade_der(now_unix_ms, &block.contents)
}
#[cfg(test)]
mod tests {
use super::*;
const YEAR: i64 = 365;
fn at(day_of_life: i64, lifetime: i64) -> Expiry {
let not_before = 1_700_000_000_000;
let not_after = not_before + lifetime * DAY_MS;
grade(not_before + day_of_life * DAY_MS, not_before, not_after)
}
/// **All four grades are reachable.** Without this the whole module could be
/// a function that returns `Fine` and every other test would still pass.
#[test]
fn every_grade_can_happen() {
assert_eq!(at(0, YEAR).grade, Grade::Fine);
assert_eq!(at(280, YEAR).grade, Grade::Renew);
assert_eq!(at(360, YEAR).grade, Grade::Refuse);
assert_eq!(at(400, YEAR).grade, Grade::Expired);
}
/// The refusal fires while the certificate is STILL VALID. This is the
/// property the module exists for: a door that refuses while it can still
/// be opened.
#[test]
fn the_refusal_comes_before_the_lapse() {
let e = at(YEAR - REFUSE_DAYS, YEAR);
assert_eq!(e.grade, Grade::Refuse);
assert!(
e.days_left > 0,
"refusing only once it has lapsed is refusing too late"
);
assert!(e.is_refusal());
let text = e.line("this twin's client certificate");
assert!(text.contains("STILL VALID"), "{text}");
assert!(text.contains("this twin's client certificate"), "{text}");
}
/// The renewal threshold is a FRACTION, so a short-lived leaf is not warned
/// about from the day it is installed.
#[test]
fn a_short_lifetime_gets_a_proportionate_warning() {
// A 60-day leaf: renew at 15 days left, not at 91 (which is before it
// was even issued) and not at 274.
assert_eq!(at(0, 60).grade, Grade::Fine);
assert_eq!(at(30, 60).grade, Grade::Fine);
assert_eq!(
at(45, 60).grade,
Grade::Renew,
"a quarter of 60 days left is the renewal edge"
);
assert_eq!(
at(46, 60).grade,
Grade::Refuse,
"14 days left is inside REFUSE_DAYS"
);
// A 365-day leaf renews at 91 days left = day 274.
assert_eq!(at(273, YEAR).grade, Grade::Fine);
assert_eq!(at(274, YEAR).grade, Grade::Renew);
}
/// **A box is never refused without first having been told to renew.**
///
/// On a leaf shorter than 56 days the absolute [`REFUSE_DAYS`] would
/// overtake the proportional renewal threshold, and the box would go from
/// `Fine` straight to `Refuse` having never been asked for anything. This
/// walks every day of several lifetimes and asserts the order holds.
#[test]
fn the_refusal_never_overtakes_the_renewal() {
for lifetime in [7, 20, 30, 56, 60, 90, 365, 3650] {
let mut seen_renew = false;
let mut refused_on = None;
for day in 0..=lifetime {
match at(day, lifetime).grade {
Grade::Renew => seen_renew = true,
Grade::Refuse if refused_on.is_none() => refused_on = Some(day),
_ => {}
}
}
assert!(
refused_on.is_some(),
"a {lifetime}-day leaf must eventually be refused, or the guard is decoration"
);
assert!(
seen_renew,
"a {lifetime}-day leaf was refused on day {:?} without ever being told to renew",
refused_on
);
}
}
/// A certificate with no lifetime is never graded usable.
#[test]
fn a_zero_length_certificate_is_expired_whatever_the_clock_says() {
let t = 1_700_000_000_000;
assert_eq!(grade(t, t, t).grade, Grade::Expired);
assert_eq!(grade(t - 100 * DAY_MS, t, t).grade, Grade::Expired);
}
/// The expired line says what the way back is, and that WireGuard is not it.
#[test]
fn the_expired_line_is_honest_about_wireguard() {
let text = at(400, YEAR).line("this twin's client certificate");
assert!(text.contains("EXPIRED"), "{text}");
assert!(
text.contains("re-imaging"),
"the way back must be named: {text}"
);
assert!(
text.contains("WireGuard still carries packets"),
"it must say plainly that the tunnel does not rescue this: {text}"
);
}
/// Bytes that are not a certificate are an error, never a pass.
#[test]
fn unparseable_bytes_are_not_graded_fine() {
assert!(grade_der(1_700_000_000_000, b"not a certificate at all").is_err());
}
}