Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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());
    }
}