crates/estate-clock/src/sntp.rs
//! **SNTPv4** (RFC 4330) — for a front whose site has an NTP server.
//!
//! Not used on gunnar.rs: UpCloud's firewall drops the replies (see the crate
//! note). It exists because in an airgap a local NTP server is the only root of
//! time a front can have, and "the source is configuration" has to include it.
//! Plaintext; refused on the appliance by [`crate::config`].
//!
//! The one defence SNTP itself offers is used: the reply must echo our transmit
//! timestamp as its *originate* timestamp, so a blind spoofer has to guess 64
//! bits, and a stale reply answers a question nobody asked.
use std::net::{ToSocketAddrs, UdpSocket};
use std::time::{Duration, SystemTime};
use crate::decide::Sample;
/// Seconds from 1900-01-01 (the NTP era) to 1970-01-01.
pub const NTP_UNIX_DELTA_S: i64 = 2_208_988_800;
/// How long one server gets.
pub const NTP_TIMEOUT: Duration = Duration::from_secs(4);
/// An NTP 64-bit timestamp for an epoch millisecond.
pub fn to_ntp(unix_ms: i64) -> [u8; 8] {
let secs = (unix_ms.div_euclid(1_000) + NTP_UNIX_DELTA_S) as u32;
// Rounded UP, so `from_ntp`'s floor lands back on the same millisecond.
let frac = ((unix_ms.rem_euclid(1_000) as u64) << 32).div_ceil(1_000);
let mut out = [0u8; 8];
out[..4].copy_from_slice(&secs.to_be_bytes());
out[4..].copy_from_slice(&(frac as u32).to_be_bytes());
out
}
/// An epoch millisecond for an NTP 64-bit timestamp (era 0).
pub fn from_ntp(bytes: &[u8]) -> i64 {
let secs = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as i64;
let frac = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as u64;
(secs - NTP_UNIX_DELTA_S) * 1_000 + ((frac * 1_000) >> 32) as i64
}
/// A client request: LI 0, version 4, mode 3, transmit timestamp `t1`.
pub fn request(t1_unix_ms: i64) -> [u8; 48] {
let mut packet = [0u8; 48];
packet[0] = 0b00_100_011;
packet[40..48].copy_from_slice(&to_ntp(t1_unix_ms));
packet
}
/// `(offset_ms, server_transmit_unix_ms)` from a reply, per RFC 4330 §5:
/// `offset = ((T2 - T1) + (T3 - T4)) / 2`.
pub fn parse_reply(reply: &[u8], sent: &[u8; 48], t4_unix_ms: i64) -> Result<(i64, i64), String> {
if reply.len() < 48 {
return Err(format!("the reply is {} bytes, not 48", reply.len()));
}
let mode = reply[0] & 0b111;
if mode != 4 {
return Err(format!("the reply is mode {mode}, not a server reply (4)"));
}
let leap = reply[0] >> 6;
if leap == 3 {
return Err("the server says its clock is unsynchronised (LI = 3)".to_owned());
}
let stratum = reply[1];
if stratum == 0 || stratum > 15 {
return Err(format!(
"the server answered stratum {stratum} — a kiss-o'-death or an unsynchronised server"
));
}
if reply[24..32] != sent[40..48] {
return Err(
"the reply's originate timestamp is not our transmit timestamp — spoofed or stale"
.to_owned(),
);
}
let t1 = from_ntp(&sent[40..48]);
let t2 = from_ntp(&reply[32..40]);
let t3 = from_ntp(&reply[40..48]);
let offset = ((t2 - t1) + (t3 - t4_unix_ms)) / 2;
Ok((offset, t3))
}
/// One exchange with `host:port`.
pub fn query(host: &str, port: u16) -> Result<Sample, String> {
let label = format!("ntp://{host}:{port}");
let addr = (host, port)
.to_socket_addrs()
.map_err(|err| format!("{label}: {err}"))?
.next()
.ok_or_else(|| format!("{label}: the name resolved to nothing"))?;
let bind = if addr.is_ipv4() {
"0.0.0.0:0"
} else {
"[::]:0"
};
let socket = UdpSocket::bind(bind).map_err(|err| format!("{label}: {err}"))?;
let _ = socket.set_read_timeout(Some(NTP_TIMEOUT));
let sent = request(crate::unix_ms(SystemTime::now()));
socket
.send_to(&sent, addr)
.map_err(|err| format!("{label}: sending failed: {err}"))?;
let mut buffer = [0u8; 512];
let (len, from) = socket
.recv_from(&mut buffer)
.map_err(|err| format!("{label}: no reply ({err})"))?;
let t4 = crate::unix_ms(SystemTime::now());
if from != addr {
return Err(format!(
"{label}: a reply came from {from}, not the server asked"
));
}
let (offset_ms, server_ms) =
parse_reply(&buffer[..len], &sent, t4).map_err(|why| format!("{label}: {why}"))?;
Ok(Sample {
source: label,
source_unix_ms: server_ms,
offset_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
const NOW_MS: i64 = 1_789_394_521_628;
fn reply_for(sent: &[u8; 48], t2: i64, t3: i64, stratum: u8, li: u8) -> [u8; 48] {
let mut r = [0u8; 48];
r[0] = (li << 6) | 0b00_100_100;
r[1] = stratum;
r[24..32].copy_from_slice(&sent[40..48]);
r[32..40].copy_from_slice(&to_ntp(t2));
r[40..48].copy_from_slice(&to_ntp(t3));
r
}
#[test]
fn a_timestamp_survives_the_round_trip_to_the_ntp_era_to_the_millisecond() {
for ms in [NOW_MS, NOW_MS + 1, 0, 1_767_225_600_000] {
assert_eq!(from_ntp(&to_ntp(ms)), ms);
}
}
#[test]
fn the_offset_is_the_rfc_4330_formula() {
// Server is 7200 s behind this box; 20 ms each way.
let t1 = NOW_MS;
let sent = request(t1);
let reply = reply_for(&sent, t1 + 20 - 7_200_000, t1 + 21 - 7_200_000, 2, 0);
let (offset, server) = parse_reply(&reply, &sent, t1 + 41).unwrap();
assert_eq!(offset, -7_200_000);
assert_eq!(server, t1 + 21 - 7_200_000);
}
#[test]
fn a_reply_not_echoing_our_timestamp_is_refused() {
let sent = request(NOW_MS);
let other = request(NOW_MS - 1);
let reply = reply_for(&other, NOW_MS, NOW_MS, 2, 0);
assert!(parse_reply(&reply, &sent, NOW_MS)
.unwrap_err()
.contains("originate"));
}
#[test]
fn an_unsynchronised_or_kiss_of_death_server_is_refused() {
let sent = request(NOW_MS);
assert!(parse_reply(&reply_for(&sent, NOW_MS, NOW_MS, 0, 0), &sent, NOW_MS).is_err());
assert!(parse_reply(&reply_for(&sent, NOW_MS, NOW_MS, 16, 0), &sent, NOW_MS).is_err());
assert!(parse_reply(&reply_for(&sent, NOW_MS, NOW_MS, 2, 3), &sent, NOW_MS).is_err());
}
}