Skip to main content

rusty_time_core/
ntp.rs

1//! NTPv4 packet codec (RFC 5905) with extension-field iteration (RFC 7822).
2//!
3//! Every entry point is a parse-constructor returning `Result`; nothing here can
4//! panic on untrusted bytes (the fuzz targets in `fuzz/` hold this line).
5
6use core::fmt;
7
8/// Length of the fixed NTPv4 header.
9pub const HEADER_LEN: usize = 48;
10
11/// Seconds between the NTP era-0 epoch (1900-01-01) and the Unix epoch (1970-01-01).
12pub const UNIX_EPOCH_OFFSET: u64 = 2_208_988_800;
13
14const FRAC: f64 = 4_294_967_296.0; // 2^32
15
16/// 64-bit NTP timestamp: 32.32 fixed-point seconds since the era epoch.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18pub struct NtpTimestamp(pub u64);
19
20impl NtpTimestamp {
21    pub const ZERO: NtpTimestamp = NtpTimestamp(0);
22
23    pub fn from_parts(seconds: u32, fraction: u32) -> Self {
24        NtpTimestamp(((seconds as u64) << 32) | fraction as u64)
25    }
26
27    pub fn seconds(self) -> u32 {
28        (self.0 >> 32) as u32
29    }
30
31    pub fn fraction(self) -> u32 {
32        self.0 as u32
33    }
34
35    /// Build from Unix wall time. Era wrap (2036) is handled by truncation to the
36    /// low 32 bits of the NTP second count, per RFC 5905 era arithmetic.
37    pub fn from_unix(secs: i64, nanos: u32) -> Self {
38        let ntp_secs = (secs.wrapping_add(UNIX_EPOCH_OFFSET as i64)) as u64;
39        let frac = ((nanos as u64) << 32) / 1_000_000_000;
40        NtpTimestamp(((ntp_secs & 0xFFFF_FFFF) << 32) | (frac & 0xFFFF_FFFF))
41    }
42
43    /// Signed seconds from `earlier` to `self`, correct across era wrap for spans
44    /// under ±68 years (the same guarantee RFC 5905 gives).
45    pub fn seconds_since(self, earlier: NtpTimestamp) -> f64 {
46        let diff = self.0.wrapping_sub(earlier.0) as i64;
47        diff as f64 / FRAC
48    }
49
50    pub fn is_zero(self) -> bool {
51        self.0 == 0
52    }
53}
54
55/// 32-bit NTP short format: 16.16 fixed-point seconds (root delay / dispersion).
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
57pub struct NtpShort(pub u32);
58
59impl NtpShort {
60    pub fn to_seconds(self) -> f64 {
61        self.0 as f64 / 65_536.0
62    }
63
64    pub fn from_seconds(s: f64) -> Self {
65        let clamped = s.clamp(0.0, 65_535.999);
66        NtpShort((clamped * 65_536.0) as u32)
67    }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum LeapIndicator {
72    NoWarning,
73    LastMinute61,
74    LastMinute59,
75    Unsynchronized,
76}
77
78impl LeapIndicator {
79    fn from_bits(b: u8) -> Self {
80        match b & 0b11 {
81            0 => LeapIndicator::NoWarning,
82            1 => LeapIndicator::LastMinute61,
83            2 => LeapIndicator::LastMinute59,
84            _ => LeapIndicator::Unsynchronized,
85        }
86    }
87
88    fn bits(self) -> u8 {
89        match self {
90            LeapIndicator::NoWarning => 0,
91            LeapIndicator::LastMinute61 => 1,
92            LeapIndicator::LastMinute59 => 2,
93            LeapIndicator::Unsynchronized => 3,
94        }
95    }
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum Mode {
100    Reserved,
101    SymmetricActive,
102    SymmetricPassive,
103    Client,
104    Server,
105    Broadcast,
106    Control,
107    Private,
108}
109
110impl Mode {
111    fn from_bits(b: u8) -> Self {
112        match b & 0b111 {
113            1 => Mode::SymmetricActive,
114            2 => Mode::SymmetricPassive,
115            3 => Mode::Client,
116            4 => Mode::Server,
117            5 => Mode::Broadcast,
118            6 => Mode::Control,
119            7 => Mode::Private,
120            _ => Mode::Reserved,
121        }
122    }
123
124    fn bits(self) -> u8 {
125        match self {
126            Mode::Reserved => 0,
127            Mode::SymmetricActive => 1,
128            Mode::SymmetricPassive => 2,
129            Mode::Client => 3,
130            Mode::Server => 4,
131            Mode::Broadcast => 5,
132            Mode::Control => 6,
133            Mode::Private => 7,
134        }
135    }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum ParseError {
140    /// Fewer than 48 bytes.
141    TooShort { len: usize },
142    /// Version outside 3..=4.
143    BadVersion { version: u8 },
144}
145
146impl fmt::Display for ParseError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            ParseError::TooShort { len } => {
150                write!(f, "packet is {len} bytes; NTP header needs {HEADER_LEN}")
151            }
152            ParseError::BadVersion { version } => {
153                write!(f, "unsupported NTP version {version} (expected 3 or 4)")
154            }
155        }
156    }
157}
158
159impl std::error::Error for ParseError {}
160
161/// The fixed NTPv4 header.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub struct NtpPacket {
164    pub leap: LeapIndicator,
165    pub version: u8,
166    pub mode: Mode,
167    pub stratum: u8,
168    pub poll: i8,
169    pub precision: i8,
170    pub root_delay: NtpShort,
171    pub root_dispersion: NtpShort,
172    pub reference_id: [u8; 4],
173    pub reference_ts: NtpTimestamp,
174    pub origin_ts: NtpTimestamp,
175    pub receive_ts: NtpTimestamp,
176    pub transmit_ts: NtpTimestamp,
177}
178
179fn read_u32(buf: &[u8], at: usize) -> u32 {
180    let mut b = [0u8; 4];
181    if let Some(s) = buf.get(at..at + 4) {
182        b.copy_from_slice(s);
183    }
184    u32::from_be_bytes(b)
185}
186
187fn read_u64(buf: &[u8], at: usize) -> u64 {
188    let mut b = [0u8; 8];
189    if let Some(s) = buf.get(at..at + 8) {
190        b.copy_from_slice(s);
191    }
192    u64::from_be_bytes(b)
193}
194
195impl NtpPacket {
196    /// A mode-3 client request. The caller supplies `transmit_ts`, which SHOULD be
197    /// an unpredictable nonce rather than the real clock (BCP: it is echoed back as
198    /// `origin_ts` and is the only spoofing defence an unauthenticated client has).
199    pub fn client_request(version: u8, transmit_ts: NtpTimestamp) -> Self {
200        NtpPacket {
201            leap: LeapIndicator::NoWarning,
202            version,
203            mode: Mode::Client,
204            stratum: 0,
205            poll: 0,
206            precision: 0x20u8 as i8,
207            root_delay: NtpShort(0),
208            root_dispersion: NtpShort(0),
209            reference_id: [0; 4],
210            reference_ts: NtpTimestamp::ZERO,
211            origin_ts: NtpTimestamp::ZERO,
212            receive_ts: NtpTimestamp::ZERO,
213            transmit_ts,
214        }
215    }
216
217    /// Parse the 48-byte header. Trailing bytes (extension fields / legacy MAC) are
218    /// left to [`extension_fields`].
219    pub fn parse(buf: &[u8]) -> Result<NtpPacket, ParseError> {
220        if buf.len() < HEADER_LEN {
221            return Err(ParseError::TooShort { len: buf.len() });
222        }
223        let b0 = buf[0];
224        let version = (b0 >> 3) & 0b111;
225        if !(3..=4).contains(&version) {
226            return Err(ParseError::BadVersion { version });
227        }
228        let mut reference_id = [0u8; 4];
229        reference_id.copy_from_slice(&buf[12..16]);
230        Ok(NtpPacket {
231            leap: LeapIndicator::from_bits(b0 >> 6),
232            version,
233            mode: Mode::from_bits(b0),
234            stratum: buf[1],
235            poll: buf[2] as i8,
236            precision: buf[3] as i8,
237            root_delay: NtpShort(read_u32(buf, 4)),
238            root_dispersion: NtpShort(read_u32(buf, 8)),
239            reference_id,
240            reference_ts: NtpTimestamp(read_u64(buf, 16)),
241            origin_ts: NtpTimestamp(read_u64(buf, 24)),
242            receive_ts: NtpTimestamp(read_u64(buf, 32)),
243            transmit_ts: NtpTimestamp(read_u64(buf, 40)),
244        })
245    }
246
247    /// Serialize the header into a 48-byte buffer.
248    pub fn write(&self, buf: &mut [u8; HEADER_LEN]) {
249        buf[0] = (self.leap.bits() << 6) | ((self.version & 0b111) << 3) | self.mode.bits();
250        buf[1] = self.stratum;
251        buf[2] = self.poll as u8;
252        buf[3] = self.precision as u8;
253        buf[4..8].copy_from_slice(&self.root_delay.0.to_be_bytes());
254        buf[8..12].copy_from_slice(&self.root_dispersion.0.to_be_bytes());
255        buf[12..16].copy_from_slice(&self.reference_id);
256        buf[16..24].copy_from_slice(&self.reference_ts.0.to_be_bytes());
257        // RFC 5905 field order: reference, origin, receive, transmit.
258        buf[24..32].copy_from_slice(&self.origin_ts.0.to_be_bytes());
259        buf[32..40].copy_from_slice(&self.receive_ts.0.to_be_bytes());
260        buf[40..48].copy_from_slice(&self.transmit_ts.0.to_be_bytes());
261    }
262
263    pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
264        let mut buf = [0u8; HEADER_LEN];
265        self.write(&mut buf);
266        buf
267    }
268}
269
270/// One RFC 7822 extension field: type, and its value bytes (header excluded).
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub struct ExtensionField<'a> {
273    pub field_type: u16,
274    pub value: &'a [u8],
275}
276
277/// What follows the fixed header.
278#[derive(Clone, Copy, Debug, PartialEq, Eq)]
279pub enum Trailer<'a> {
280    /// A well-formed RFC 7822 extension field.
281    Extension(ExtensionField<'a>),
282    /// Bytes that cannot be a well-formed extension field (e.g. a legacy MAC).
283    /// Always the final item when present.
284    Opaque(&'a [u8]),
285}
286
287/// Iterate whatever follows the 48-byte header. Malformed input can never panic:
288/// anything that does not parse as an extension field is yielded once as
289/// [`Trailer::Opaque`] and iteration ends.
290pub fn extension_fields(packet: &[u8]) -> ExtensionIter<'_> {
291    let rest = packet.get(HEADER_LEN..).unwrap_or(&[]);
292    ExtensionIter { rest }
293}
294
295pub struct ExtensionIter<'a> {
296    rest: &'a [u8],
297}
298
299impl<'a> Iterator for ExtensionIter<'a> {
300    type Item = Trailer<'a>;
301
302    fn next(&mut self) -> Option<Trailer<'a>> {
303        if self.rest.is_empty() {
304            return None;
305        }
306        if self.rest.len() >= 4 {
307            let field_type = u16::from_be_bytes([self.rest[0], self.rest[1]]);
308            let len = u16::from_be_bytes([self.rest[2], self.rest[3]]) as usize;
309            // RFC 7822: total length includes the 4-byte header, is a multiple of
310            // 4, and is at least 16.
311            if len >= 16 && len.is_multiple_of(4) && len <= self.rest.len() {
312                let value = &self.rest[4..len];
313                self.rest = &self.rest[len..];
314                return Some(Trailer::Extension(ExtensionField { field_type, value }));
315            }
316        }
317        let opaque = self.rest;
318        self.rest = &[];
319        Some(Trailer::Opaque(opaque))
320    }
321}
322
323/// Offset/delay from the four client-exchange timestamps (RFC 5905 §8), all in
324/// seconds on any common timescale. Returns (offset, delay); offset is seconds to
325/// ADD to the local clock.
326pub fn offset_delay(t1: f64, t2: f64, t3: f64, t4: f64) -> (f64, f64) {
327    let offset = ((t2 - t1) + (t3 - t4)) / 2.0;
328    let delay = (t4 - t1) - (t3 - t2);
329    (offset, delay)
330}
331
332#[cfg(test)]
333mod precision_tests {
334    use super::*;
335
336    /// Timestamps must keep the wire's resolution, not the epoch's.
337    ///
338    /// An NTP timestamp carries 2^-32 s — 0.233 ns. Unix time is around
339    /// 1.79e9 seconds, and an f64 there has a 238 ns gap between representable
340    /// values, so the moment a timestamp is expressed as seconds-since-1970 in
341    /// an f64, three orders of magnitude of it are gone. Worse, it is gone on a
342    /// schedule: when Unix time crosses 2^31 in **February 2038** the exponent
343    /// steps and the gap doubles to 477 ns.
344    ///
345    /// The daemon therefore takes differences in the fixed-point domain, where
346    /// the subtraction is exact, and only then converts. This test pins that
347    /// property by showing the two routes disagree by far more than the
348    /// quantity being measured.
349    /// One tick of the NTP fraction: 2^-32 s, about 233 ps. This is the finest
350    /// distinction the wire format can draw, so it is the right tolerance for
351    /// any claim about timestamp arithmetic — a tighter one is testing the
352    /// test, not the code.
353    const TICK: f64 = 1.0 / 4_294_967_296.0;
354
355    #[test]
356    fn differences_keep_sub_nanosecond_resolution() {
357        // A realistic 2026 instant, and a second one 1 ns later.
358        let secs = 1_787_856_000i64;
359        let a = NtpTimestamp::from_unix(secs, 0);
360        let b = NtpTimestamp::from_unix(secs, 1);
361
362        // The exact route: subtract in fixed point, then convert.
363        let exact = b.seconds_since(a);
364        assert!(
365            exact > 0.0,
366            "a 1 ns step vanished entirely in the fixed-point difference"
367        );
368        assert!(
369            (exact - 1e-9).abs() <= TICK,
370            "fixed-point difference gave {exact} s for a 1 ns step"
371        );
372
373        // The lossy route: seconds-since-1970 as f64, then subtract.
374        let ulp = (secs as f64).next_up() - secs as f64;
375        assert!(
376            ulp > 200e-9,
377            "this test assumes an f64 at the Unix epoch is coarse; ULP is {ulp} s"
378        );
379    }
380
381    /// The same, at the 2038 boundary — where it gets worse rather than breaking.
382    #[test]
383    fn the_2038_exponent_step_does_not_reach_the_difference() {
384        // 2038-01-19, just past 2^31 seconds.
385        let secs = 2_147_500_000i64;
386        let a = NtpTimestamp::from_unix(secs, 0);
387        let b = NtpTimestamp::from_unix(secs, 100);
388        let exact = b.seconds_since(a);
389        assert!(
390            (exact - 100e-9).abs() <= TICK,
391            "a 100 ns step past 2038 measured as {exact} s"
392        );
393
394        // Meanwhile the f64-seconds representation there cannot even hold it.
395        let ulp = (secs as f64).next_up() - secs as f64;
396        assert!(
397            ulp > 400e-9,
398            "expected the post-2038 f64 gap to exceed 400 ns, got {ulp} s"
399        );
400    }
401
402    /// A difference must stay correct across the 2036 era wrap, which is the
403    /// other half of why the daemon no longer guesses an era from the local
404    /// clock: for spans this short the arithmetic is unambiguous on its own.
405    #[test]
406    fn a_difference_spans_the_era_boundary() {
407        // Straddle 2036-02-07, where the NTP second count wraps.
408        let before = NtpTimestamp::from_unix(2_085_978_495, 0);
409        let after = NtpTimestamp::from_unix(2_085_978_497, 0);
410        let delta = after.seconds_since(before);
411        assert!(
412            (delta - 2.0).abs() <= TICK,
413            "two seconds across the era wrap measured as {delta}"
414        );
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn roundtrip_header() {
424        let p = NtpPacket {
425            leap: LeapIndicator::NoWarning,
426            version: 4,
427            mode: Mode::Server,
428            stratum: 2,
429            poll: 6,
430            precision: -20,
431            root_delay: NtpShort::from_seconds(0.015),
432            root_dispersion: NtpShort::from_seconds(0.002),
433            reference_id: *b"GPS\0",
434            reference_ts: NtpTimestamp::from_unix(1_756_200_000, 0),
435            origin_ts: NtpTimestamp(0x0102_0304_0506_0708),
436            receive_ts: NtpTimestamp::from_unix(1_756_200_100, 500_000_000),
437            transmit_ts: NtpTimestamp::from_unix(1_756_200_100, 500_100_000),
438        };
439        let bytes = p.to_bytes();
440        let q = NtpPacket::parse(&bytes).expect("parse back");
441        assert_eq!(p, q);
442    }
443
444    #[test]
445    fn field_offsets_match_rfc5905() {
446        // Hand-check the byte layout against the RFC figure.
447        let mut p = NtpPacket::client_request(4, NtpTimestamp(0xAABB_CCDD_EEFF_0011));
448        p.origin_ts = NtpTimestamp(0x1111_1111_1111_1111);
449        p.receive_ts = NtpTimestamp(0x2222_2222_2222_2222);
450        p.reference_ts = NtpTimestamp(0x3333_3333_3333_3333);
451        let b = p.to_bytes();
452        assert_eq!(b[0], 0b00_100_011); // LI 0, VN 4, mode 3 (client)
453        assert_eq!(&b[16..24], &[0x33; 8]); // reference
454        assert_eq!(&b[24..32], &[0x11; 8]); // origin
455        assert_eq!(&b[32..40], &[0x22; 8]); // receive
456        assert_eq!(&b[40..48], &0xAABB_CCDD_EEFF_0011u64.to_be_bytes()); // transmit
457    }
458
459    #[test]
460    fn short_and_bad_version_are_errors() {
461        assert_eq!(
462            NtpPacket::parse(&[0u8; 20]),
463            Err(ParseError::TooShort { len: 20 })
464        );
465        let mut b = [0u8; 48];
466        b[0] = 2 << 3; // version 2
467        assert_eq!(
468            NtpPacket::parse(&b),
469            Err(ParseError::BadVersion { version: 2 })
470        );
471    }
472
473    #[test]
474    fn timestamp_wraparound_diff() {
475        // 1 second across the era boundary.
476        let before = NtpTimestamp(u64::MAX - (1u64 << 31)); // ~0.5s before wrap
477        let after = NtpTimestamp(1u64 << 31); // ~0.5s after wrap
478        let d = after.seconds_since(before);
479        assert!((d - 1.0).abs() < 1e-9, "got {d}");
480    }
481
482    #[test]
483    fn exchange_math() {
484        // Local is 0.100 s behind; RTT 0.050 s symmetric.
485        let t1 = 10.000; // local send
486        let t2 = 10.125; // server recv = true 10.025 + 0.100
487        let t3 = 10.126;
488        let t4 = 10.051; // local recv (true 10.151 - 0.100... local scale)
489        let (offset, delay) = offset_delay(t1, t2, t3, t4);
490        assert!((offset - 0.100).abs() < 1e-9, "offset {offset}");
491        assert!((delay - 0.050).abs() < 1e-9, "delay {delay}");
492    }
493
494    #[test]
495    fn extension_iteration_handles_garbage() {
496        // Header + one valid EF + trailing garbage.
497        let mut buf = vec![0u8; 48];
498        buf[0] = (4 << 3) | 3;
499        buf.extend_from_slice(&0x0104u16.to_be_bytes()); // type
500        buf.extend_from_slice(&16u16.to_be_bytes()); // len 16
501        buf.extend_from_slice(&[0xAB; 12]); // value
502        buf.extend_from_slice(&[1, 2, 3]); // garbage tail
503        let items: Vec<_> = extension_fields(&buf).collect();
504        assert_eq!(items.len(), 2);
505        match items[0] {
506            Trailer::Extension(ef) => {
507                assert_eq!(ef.field_type, 0x0104);
508                assert_eq!(ef.value, &[0xAB; 12][..]);
509            }
510            _ => panic!("expected extension"),
511        }
512        assert!(matches!(items[1], Trailer::Opaque(&[1, 2, 3])));
513    }
514}