//! Host clock trust: the device must never be set from a clock we do not //! trust. //! //! The primary check measures the actual clock error with a single SNTP //! query (RFC 4330) and accepts the host when the offset is under a second. //! That is a direct measurement, platform-independent, and stronger than //! asking the OS whether it believes it is synchronized. When no NTP server //! is reachable, systemd-timesyncd's verdict (`timedatectl`, Linux) is the //! fallback; hosts with neither fail safe. use std::net::UdpSocket; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const NTP_SERVERS: [&str; 2] = ["time.apple.com:123", "pool.ntp.org:123"]; /// Accept the host clock when it is within this many seconds of NTP time. /// Well inside the 5s sync read-back tolerance, far above network jitter. const MAX_OFFSET_SECONDS: f64 = 1.0; /// Seconds between the NTP era (1900) and the Unix epoch (1970). const NTP_UNIX_OFFSET: f64 = 2_208_988_800.0; /// The NTP fraction field is 32-bit fixed-point in units of 1/2^32 seconds; /// dividing by 2^32 converts it to seconds. const NTP_FRACTION_SCALE: f64 = (1u64 << 32) as f64; pub fn clock_is_ntp_synchronized() -> bool { for server in NTP_SERVERS { if let Some(offset) = sntp_offset(server) { let ok = offset.abs() <= MAX_OFFSET_SECONDS; if ok { log::debug!("Host clock is {offset:+.3}s from {server}; trusted"); } else { log::warn!("Host clock is {offset:+.3}s from {server}; not trusted"); } return ok; } } log::debug!("No NTP server reachable; falling back to timedatectl"); timedatectl_says_synchronized() } /// One SNTP client exchange: returns the approximate offset of the local /// clock relative to the server (positive = local clock ahead). Uses the /// request/response midpoint, so the error is bounded by half the round /// trip, which is milliseconds against a threshold of a second. /// /// The request carries a random transmit timestamp that the reply must echo /// in its originate field (RFC 4330 ยง5); this binds the answer to our /// request so an off-path packet or a stale reply is rejected. It cannot /// stop an on-path attacker, but the gate only decides whether to trust the /// *host's own* clock (the device is never set from the NTP value), so the /// worst an attacker gains is suppressing sync or blessing an already-wrong /// host clock. fn sntp_offset(server: &str) -> Option { let socket = UdpSocket::bind(("0.0.0.0", 0)).ok()?; socket.set_read_timeout(Some(Duration::from_secs(2))).ok()?; socket.connect(server).ok()?; let mut request = [0u8; 48]; request[0] = 0b00_100_011; // LI 0, version 4, mode 3 (client) // Random transmit timestamp (bytes 40..48) used as an anti-spoof nonce. let mut nonce = [0u8; 8]; getrandom(&mut nonce); request[40..48].copy_from_slice(&nonce); let sent_at = unix_now(); socket.send(&request).ok()?; let mut response = [0u8; 48]; let len = socket.recv(&mut response).ok()?; let received_at = unix_now(); if len < 48 { return None; } let leap = response[0] >> 6; let mode = response[0] & 0x07; let stratum = response[1]; // Reject non-server replies, unsynchronized servers (LI=3), and // kiss-of-death / invalid stratum (0, or 16+ = unsynchronized). if mode != 4 || leap == 3 || !(1..=15).contains(&stratum) { return None; } // The reply's originate timestamp (bytes 24..32) must echo our nonce. if response[24..32] != nonce { return None; } // Transmit timestamp: seconds since 1900 plus a 32-bit binary fraction. let seconds = u32::from_be_bytes(response[40..44].try_into().ok()?) as f64; let fraction = u32::from_be_bytes(response[44..48].try_into().ok()?) as f64 / NTP_FRACTION_SCALE; let server_time = seconds + fraction - NTP_UNIX_OFFSET; if server_time <= 0.0 { return None; } Some((sent_at + received_at) / 2.0 - server_time) } /// Fills `buf` with random bytes via rs-matter's crypto RNG (already a /// dependency); a nonce only needs unpredictability, not the full clock. fn getrandom(buf: &mut [u8]) { use rand::RngCore as _; rand::thread_rng().fill_bytes(buf); } fn unix_now() -> f64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs_f64()) .unwrap_or(0.0) } /// systemd-timesyncd's opinion; false on hosts without timedatectl. fn timedatectl_says_synchronized() -> bool { Command::new("timedatectl") .args(["show", "-p", "NTPSynchronized", "--value"]) .output() .map(|output| output.status.success() && output.stdout.trim_ascii() == b"yes") .unwrap_or(false) }