update-bundle/src/time.rs
// ── W13: trusted time ────────────────────────────────────────────────────────
//! **One time source, and it says whether it is trusted.**
//!
//! Every expiry check in this crate — `core.expires_at`, `policy
//! .maximum_bundle_age_secs`, the certificate validity window — compares against
//! a `now` that the verifier is HANDED. That seam was already here
//! ([`crate::verify::verify_member_map`] takes `now: DateTime<Utc>`); what was
//! missing is anything to feed it but `Utc::now()`, which is the host RTC, which
//! on an airgapped appliance is a battery and a guess. A box whose clock is wound
//! back accepts a bundle that expired last year; a box wound forward refuses a
//! bundle that is fine. Neither says so in a log.
//!
//! So: [`TrustedTime`], the seam, and [`HostRtc`], the one implementation this
//! crate carries — `SystemTime::now()`, **named `host-rtc` and `is_trusted() ==
//! false`**, so every verdict taken against it SAYS the time was untrusted.
//!
//! # Where trusted time comes from: the estate's clock, and only there
//!
//! There is ONE signed-time format in the house: edda `estate-clock`'s
//! `ESTATE-TIME/1` exchange (a nonce the box chose, answered with an ed25519
//! SSHSIG over `estate-time-v1\n<nonce>\n<unix_ms>\n` in namespace
//! `estate-time-v1@nordisk`). holger's front serves it on the utility network
//! (`:50123`); a site with no front — an airgapped downstream — serves it with
//! `estate-clock run` on its own clock host (`ESTATE_CLOCK_TRUST_SYSTEM=1` is that
//! spelling). holger's implementation of this trait over it is
//! `holger-server`'s `EstateTime` (`server/cli/src/secure_update.rs`), over
//! `holger_server_lib::clock`.
//!
//! This crate carried a second one until 2026-09-28 — `SignedTime`, a detached
//! ed25519 over `holger-time-v1\n…`, which NOTHING served and nothing
//! constructed outside its own tests. It was removed rather than served: a
//! seventh bespoke envelope is refused (2026-09-20 ruling), and a verifier for a
//! protocol with no server is a promise nobody keeps. This crate stays
//! network-free and signature-closure-minimal: it links no ssh-key and no
//! estate-clock; the caller hands it a source.
//!
//! # The floor on top (lane HOL-NCSC, 2026-10-31)
//!
//! An airgap box with no estate clock still keeps a monotone TIME FLOOR (server-lib
//! `time_floor`): every judgement is taken at `max(clock, floor)`, and the floor rises only
//! with trusted time: a verified clock, an accepted bundle's signed `created_at`, and a time
//! token, which is an ordinary signed update bundle of product `holger.time-token/v1` (the
//! bundle envelope this crate verifies; no time signature of its own, so the census below
//! still holds). This crate is unchanged by it: the floor is a [`TrustedTime`] the caller
//! hands in.
//!
//! # The windows, and that they did not grow
//!
//! holger declares **no skew tolerance of its own**: an expiry comparison here is
//! exact against whatever `now` the source returned. The round-trip bound and the
//! nonce size are estate-clock's (`peer::MAX_ROUND_TRIP`, `peer::NONCE_BYTES`);
//! the census test `no_skew_window_grew` pins the absence of a grace here.
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::{DateTime, TimeZone, Utc};
/// Why a time source could not answer.
///
/// Every variant is a REFUSAL. There is deliberately no variant meaning "the
/// signed source did not answer, so the RTC was used instead": a signed-time
/// source that silently degrades to the host clock is a host clock wearing a
/// signature's clothes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeError {
/// The host clock is before the Unix epoch, or otherwise unreadable.
HostClockUnreadable(String),
/// A signed clock source is CONFIGURED for this box and no verified answer
/// stands behind its time — the peer was silent, refused to sign, or its
/// signature did not verify. Refused rather than answered from the host RTC:
/// a box that was told to follow a clock and cannot is not a box without one.
Unverified(String),
/// The instant is not representable.
OutOfRange(i64),
}
impl std::fmt::Display for TimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TimeError::HostClockUnreadable(e) => {
write!(f, "the host clock could not be read: {e}")
}
TimeError::Unverified(why) => write!(
f,
"a signed clock source is configured and no verified answer stands behind \
this box's time ({why}) — refused, not answered from the host clock"
),
TimeError::OutOfRange(ms) => {
write!(f, "the instant {ms} is not a representable time")
}
}
}
}
impl std::error::Error for TimeError {}
/// **The one time source.**
///
/// A verifier is handed one of these instead of calling `Utc::now()`, so that a
/// verdict can record WHICH clock it was taken against and whether that clock is
/// worth anything.
pub trait TrustedTime: Send + Sync {
/// A stable name for a log line and a receipt: `host-rtc`, `estate-clock`.
/// Never a description — this string ends up in evidence.
fn source_name(&self) -> &'static str;
/// Is this time worth a refusal? `false` for the host RTC, which is a battery
/// and a guess, and `true` only for time whose authenticity was verified.
fn is_trusted(&self) -> bool;
/// The current instant, or why there is none.
///
/// # Errors
///
/// See [`TimeError`]. Every variant is a refusal; no implementation of this
/// trait may fall back to another source.
fn now(&self) -> Result<DateTime<Utc>, TimeError>;
}
/// The host real-time clock. Named, and **not** trusted.
///
/// This is what every expiry check in holger used before W13, spelled out loud
/// so that a log can say so. Using it changes nothing about what is accepted.
#[derive(Debug, Clone, Copy, Default)]
pub struct HostRtc;
/// The name [`HostRtc`] reports. A caller that wants to test for "the untrusted
/// default" compares against this rather than a literal.
pub const HOST_RTC_NAME: &str = "host-rtc";
impl TrustedTime for HostRtc {
fn source_name(&self) -> &'static str {
HOST_RTC_NAME
}
fn is_trusted(&self) -> bool {
false
}
fn now(&self) -> Result<DateTime<Utc>, TimeError> {
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| TimeError::HostClockUnreadable(e.to_string()))?;
let ms = i64::try_from(d.as_millis()).map_err(|_| TimeError::OutOfRange(i64::MAX))?;
from_unix_ms(ms)
}
}
fn from_unix_ms(ms: i64) -> Result<DateTime<Utc>, TimeError> {
Utc.timestamp_millis_opt(ms)
.single()
.ok_or(TimeError::OutOfRange(ms))
}
/// The source a caller gets when it names none.
///
/// [`HostRtc`], and the name is returned beside it so the caller can record that
/// the verdict it is about to take rests on a clock nobody verified.
#[must_use]
pub fn default_source() -> HostRtc {
HostRtc
}
#[cfg(test)]
mod tests {
use super::*;
/// W13's first promise: a box that checks an expiry against the host RTC can
/// SAY so. The source is named and declares itself untrusted, and the naming
/// is what a log line and a receipt carry.
#[test]
fn an_expiry_checked_against_an_untrusted_clock_says_so() {
let rtc = HostRtc;
assert_eq!(rtc.source_name(), HOST_RTC_NAME);
assert!(
!rtc.is_trusted(),
"the host RTC is a battery and a guess; a source that claimed to be \
trusted would make the whole seam a decoration"
);
let now = rtc.now().expect("the host clock reads");
// It still works — nothing about what is accepted changed.
assert!(now.timestamp() > 1_600_000_000, "the RTC still answers");
// And the default a caller gets when it names no source is exactly this
// one, untrusted and named.
let d = default_source();
assert_eq!(d.source_name(), HOST_RTC_NAME);
assert!(!d.is_trusted());
}
/// holger declares no skew tolerance of its own. A census, the way the other
/// guards in this repo are censuses: the expiry comparison in `verify.rs` is
/// `now > exp`, exact, with no grace on either side, and this module adds none.
#[test]
fn no_skew_window_grew() {
let src = include_str!("verify.rs");
assert!(
src.contains("if now > exp {"),
"the expiry comparison is exact and ungraced; if this line changed, a \
skew window was introduced where there was none"
);
for widened in [
"now - grace",
"now + grace",
"SKEW",
"skew_secs",
"tolerance",
] {
assert!(
!src.contains(widened),
"verify.rs grew {widened:?} — a skew allowance where there was none"
);
}
}
/// **One signed-time format in the house.** This crate verifies no time
/// signature of its own: the estate's clock (edda `estate-clock`,
/// `ESTATE-TIME/1`) is the one, and a second preamble here would be the
/// seventh bespoke envelope the 2026-09-20 ruling refused. The census reads
/// every source file of this crate.
#[test]
fn this_crate_carries_no_signed_time_protocol_of_its_own() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut stack = vec![dir];
let mut seen = 0;
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
seen += 1;
let text = std::fs::read_to_string(&p).unwrap();
let needle = ["holger-time", "-v1\""].concat();
let struct_decl = ["pub struct ", "SignedTime"].concat();
assert!(
!text.contains(&needle) && !text.contains(&struct_decl),
"{} declares a signed-time protocol of holger's own — the house has \
ONE, edda estate-clock's ESTATE-TIME/1",
p.display()
);
}
}
}
assert!(seen > 10, "the census read the crate ({seen} files)");
}
}