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