Skip to main content

ad_time/protocols/
ntp.rs

1/// SNTP client (RFC 4330) — fallback time source on UDP/123.
2///
3/// Protocol Specifications:
4/// - **RFC 5905 §7.3**: NTP Packet Header Format (updates RFC 4330)
5use std::net::{SocketAddr, UdpSocket};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use super::common::{map_io_err, system_time_to_us};
9use super::socket_opts::set_windows_ttl_udp;
10use crate::time_src::{OffsetMicros, TimeSource, TimeSourceError};
11
12pub struct NtpSource;
13
14// Seconds between NTP epoch (1900-01-01) and Unix epoch (1970-01-01).
15const NTP_TO_UNIX: u64 = 2_208_988_800;
16
17impl TimeSource for NtpSource {
18    fn name(&self) -> &'static str {
19        "ntp"
20    }
21
22    fn fetch(
23        &self,
24        target: SocketAddr,
25        timeout: Duration,
26    ) -> Result<OffsetMicros, TimeSourceError> {
27        let ntp_addr: SocketAddr = (target.ip(), 123).into();
28        fetch_ntp(ntp_addr, timeout)
29    }
30}
31
32fn fetch_ntp(addr: SocketAddr, timeout: Duration) -> Result<OffsetMicros, TimeSourceError> {
33    let socket = UdpSocket::bind(if addr.is_ipv4() {
34        "0.0.0.0:0"
35    } else {
36        "[::]:0"
37    })
38    .map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
39    socket
40        .set_read_timeout(Some(timeout))
41        .map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
42
43    // Build 48-byte SNTP request: LI=0, VN=4, Mode=3.
44    let mut req = [0u8; 48];
45    req[0] = 0b00_100_011; // LI=0 VN=4 Mode=3
46
47    // t1: local time before send, as NTP timestamp.
48    let t1_sys = SystemTime::now();
49    let t1_ntp = system_time_to_ntp(t1_sys);
50    req[40..44].copy_from_slice(&t1_ntp.0.to_be_bytes());
51    req[44..48].copy_from_slice(&t1_ntp.1.to_be_bytes());
52
53    socket.connect(addr).map_err(|e| map_io_err(e, "connect"))?;
54    set_windows_ttl_udp(&socket).map_err(|e| TimeSourceError::Protocol(e.to_string()))?;
55
56    let t_send = Instant::now();
57    socket.send(&req).map_err(|e| map_io_err(e, "send"))?;
58
59    let mut buf = [0u8; 48];
60    let n = socket.recv(&mut buf).map_err(|e| map_io_err(e, "recv"))?;
61    let rtt = t_send.elapsed();
62
63    if n < 48 {
64        return Err(TimeSourceError::Parse(format!(
65            "short NTP response: {} bytes",
66            n
67        )));
68    }
69
70    let mode = buf[0] & 0x07;
71    if mode != 4 && mode != 5 {
72        return Err(TimeSourceError::Protocol(format!(
73            "unexpected NTP mode: {}",
74            mode
75        )));
76    }
77
78    // t2 = receive timestamp (server received our packet), bytes 32..40
79    let t2 = parse_ntp_timestamp(&buf[32..40])?;
80    // t3 = transmit timestamp (server sent the response), bytes 40..48
81    let t3 = parse_ntp_timestamp(&buf[40..48])?;
82
83    // t4 = local time at receive; approximate as t1 + RTT.
84    let t4_us = system_time_to_us(t1_sys)? + rtt.as_micros() as i64;
85
86    // RFC 4330 offset: ((t2 - t1) + (t3 - t4)) / 2
87    let t1_us = system_time_to_us(t1_sys)?;
88    let offset_us = ((t2 - t1_us) + (t3 - t4_us)) / 2;
89
90    Ok(offset_us)
91}
92
93/// Parse 8-byte NTP timestamp (u32 seconds + u32 fraction) into Unix microseconds.
94///
95/// Assumes NTP Era 0 (epoch 1900-01-01). Era 0 wraps on 2036-02-07; Era 1 detection
96/// is not implemented (out of scope for a pentest tool — see Known Limitations).
97fn parse_ntp_timestamp(b: &[u8]) -> Result<i64, TimeSourceError> {
98    if b.len() < 8 {
99        return Err(TimeSourceError::Parse("NTP timestamp too short".into()));
100    }
101    let secs = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as u64;
102    let frac = u32::from_be_bytes([b[4], b[5], b[6], b[7]]);
103
104    if secs < NTP_TO_UNIX {
105        return Err(TimeSourceError::Parse(format!(
106            "NTP seconds {} predates Unix epoch",
107            secs
108        )));
109    }
110    let unix_secs = secs - NTP_TO_UNIX;
111    // Integer-only: frac * 1_000_000 / 2^32 microseconds
112    let frac_us = (frac as u64 * 1_000_000) >> 32;
113    i64::try_from(unix_secs * 1_000_000 + frac_us)
114        .map_err(|_| TimeSourceError::Parse("NTP timestamp overflows i64 (post-2262)".into()))
115}
116
117/// Convert SystemTime to (NTP seconds, NTP fraction).
118fn system_time_to_ntp(t: SystemTime) -> (u32, u32) {
119    let dur = t.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
120    let ntp_secs = (dur.as_secs() + NTP_TO_UNIX) as u32;
121    // fraction: subsec_nanos * 2^32 / 1_000_000_000
122    let frac = ((dur.subsec_nanos() as u64) << 32) / 1_000_000_000;
123    (ntp_secs, frac as u32)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn parse_known_ntp_timestamp() {
132        // NTP seconds 3913056000 = 2024-01-01 00:00:00 UTC
133        // Unix = 3913056000 - 2208988800 = 1704067200
134        let secs: u32 = 3_913_056_000;
135        let frac: u32 = 0;
136        let mut b = [0u8; 8];
137        b[0..4].copy_from_slice(&secs.to_be_bytes());
138        b[4..8].copy_from_slice(&frac.to_be_bytes());
139        let us = parse_ntp_timestamp(&b).unwrap();
140        assert_eq!(us, 1_704_067_200 * 1_000_000);
141    }
142
143    #[test]
144    fn parse_ntp_with_fraction() {
145        // NTP secs for Unix 0 = 2208988800, frac = 2^31 = 0.5s = 500_000us
146        let secs: u32 = NTP_TO_UNIX as u32;
147        let frac: u32 = 1 << 31;
148        let mut b = [0u8; 8];
149        b[0..4].copy_from_slice(&secs.to_be_bytes());
150        b[4..8].copy_from_slice(&frac.to_be_bytes());
151        let us = parse_ntp_timestamp(&b).unwrap();
152        assert_eq!(us, 500_000);
153    }
154
155    #[test]
156    fn roundtrip_ntp_conversion() {
157        let now = SystemTime::now();
158        let (secs, frac) = system_time_to_ntp(now);
159        let mut b = [0u8; 8];
160        b[0..4].copy_from_slice(&secs.to_be_bytes());
161        b[4..8].copy_from_slice(&frac.to_be_bytes());
162        let us = parse_ntp_timestamp(&b).unwrap();
163        let expected = system_time_to_us(now).unwrap();
164        // Allow 1ms rounding error from integer division
165        assert!(
166            (us - expected).abs() < 1000,
167            "roundtrip error: {}us",
168            us - expected
169        );
170    }
171
172    use proptest::prelude::*;
173
174    proptest! {
175        #[test]
176        fn parse_ntp_timestamp_never_panics(data in proptest::collection::vec(any::<u8>(), 0..16)) {
177            let _ = parse_ntp_timestamp(&data);
178        }
179    }
180}