Skip to main content

ntp_usg_wasm/
lib.rs

1// Copyright 2026 U.S. Federal Government (in countries where recognized)
2// SPDX-License-Identifier: Apache-2.0
3
4//! WebAssembly bindings for NTP packet parsing and timestamp conversion.
5//!
6//! This crate provides a thin JavaScript-friendly API over `ntp_usg-proto` for
7//! use in browser-based packet inspection tools. Build with `wasm-pack`:
8//!
9//! ```sh
10//! wasm-pack build crates/ntp_usg-wasm --target web
11//! ```
12//!
13//! # Examples (JavaScript)
14//!
15//! ```js
16//! import { NtpPacket, buildClientRequest, ntpTimestampToUnixSeconds } from 'ntp_usg-wasm';
17//!
18//! // Build an NTP client request
19//! const request = buildClientRequest();
20//!
21//! // Parse a captured packet
22//! const packet = new NtpPacket(capturedBytes);
23//! console.log(`Version: ${packet.version}, Mode: ${packet.mode}`);
24//!
25//! // Convert NTP timestamp to Unix seconds
26//! const ts = packet.transmitTimestamp;
27//! const unixSecs = ntpTimestampToUnixSeconds(ts[0], ts[1], Date.now() / 1000);
28//! ```
29
30use wasm_bindgen::prelude::*;
31
32use ntp_proto::extension::iter_extension_fields;
33use ntp_proto::protocol::{
34    self, ConstPackedSizeBytes, FromBytes, LeapIndicator, Mode, Packet, Stratum, ToBytes, Version,
35};
36use ntp_proto::unix_time;
37
38/// Returns the packed size of an NTP packet header in bytes (48).
39#[wasm_bindgen(js_name = "ntpPacketSize")]
40pub fn ntp_packet_size() -> usize {
41    Packet::PACKED_SIZE_BYTES
42}
43
44/// Parsed NTP packet with JavaScript-friendly accessors.
45#[wasm_bindgen]
46pub struct NtpPacket {
47    inner: Packet,
48}
49
50#[wasm_bindgen]
51impl NtpPacket {
52    /// Parse an NTP packet from raw bytes (e.g., from a pcap capture).
53    ///
54    /// Expects at least 48 bytes (the NTP header).
55    #[wasm_bindgen(constructor)]
56    pub fn new(bytes: &[u8]) -> Result<NtpPacket, JsError> {
57        let (packet, _consumed) =
58            Packet::from_bytes(bytes).map_err(|e| JsError::new(&format!("{e}")))?;
59        Ok(NtpPacket { inner: packet })
60    }
61
62    /// Leap indicator (0=no warning, 1=+1s, 2=-1s, 3=unsynchronized).
63    #[wasm_bindgen(getter, js_name = "leapIndicator")]
64    pub fn leap_indicator(&self) -> u8 {
65        self.inner.leap_indicator as u8
66    }
67
68    /// NTP version number (typically 4).
69    #[wasm_bindgen(getter)]
70    pub fn version(&self) -> u8 {
71        self.inner.version.value()
72    }
73
74    /// Association mode (3=client, 4=server, 5=broadcast, etc.).
75    #[wasm_bindgen(getter)]
76    pub fn mode(&self) -> u8 {
77        self.inner.mode as u8
78    }
79
80    /// Stratum level (0=unspecified, 1=primary, 2-15=secondary, 16=unsynchronized).
81    #[wasm_bindgen(getter)]
82    pub fn stratum(&self) -> u8 {
83        self.inner.stratum.0
84    }
85
86    /// Poll interval exponent (log2 seconds).
87    #[wasm_bindgen(getter)]
88    pub fn poll(&self) -> i8 {
89        self.inner.poll
90    }
91
92    /// Precision exponent (log2 seconds, e.g., -18 ~ 1 microsecond).
93    #[wasm_bindgen(getter)]
94    pub fn precision(&self) -> i8 {
95        self.inner.precision
96    }
97
98    /// Root delay in seconds.
99    #[wasm_bindgen(getter, js_name = "rootDelay")]
100    pub fn root_delay(&self) -> f64 {
101        let sf = &self.inner.root_delay;
102        sf.seconds as f64 + sf.fraction as f64 / 65536.0
103    }
104
105    /// Root dispersion in seconds.
106    #[wasm_bindgen(getter, js_name = "rootDispersion")]
107    pub fn root_dispersion(&self) -> f64 {
108        let sf = &self.inner.root_dispersion;
109        sf.seconds as f64 + sf.fraction as f64 / 65536.0
110    }
111
112    /// Reference identifier (4 bytes).
113    #[wasm_bindgen(getter, js_name = "referenceId")]
114    pub fn reference_id(&self) -> Vec<u8> {
115        self.inner.reference_id.as_bytes().to_vec()
116    }
117
118    /// Reference timestamp as `[seconds, fraction]`.
119    #[wasm_bindgen(getter, js_name = "referenceTimestamp")]
120    pub fn reference_timestamp(&self) -> Vec<u32> {
121        let ts = &self.inner.reference_timestamp;
122        vec![ts.seconds, ts.fraction]
123    }
124
125    /// Origin timestamp as `[seconds, fraction]`.
126    #[wasm_bindgen(getter, js_name = "originTimestamp")]
127    pub fn origin_timestamp(&self) -> Vec<u32> {
128        let ts = &self.inner.origin_timestamp;
129        vec![ts.seconds, ts.fraction]
130    }
131
132    /// Receive timestamp as `[seconds, fraction]`.
133    #[wasm_bindgen(getter, js_name = "receiveTimestamp")]
134    pub fn receive_timestamp(&self) -> Vec<u32> {
135        let ts = &self.inner.receive_timestamp;
136        vec![ts.seconds, ts.fraction]
137    }
138
139    /// Transmit timestamp as `[seconds, fraction]`.
140    #[wasm_bindgen(getter, js_name = "transmitTimestamp")]
141    pub fn transmit_timestamp(&self) -> Vec<u32> {
142        let ts = &self.inner.transmit_timestamp;
143        vec![ts.seconds, ts.fraction]
144    }
145
146    /// Serialize this packet back to 48 bytes.
147    #[wasm_bindgen(js_name = "toBytes")]
148    pub fn to_bytes_js(&self) -> Result<Vec<u8>, JsError> {
149        let mut buf = vec![0u8; Packet::PACKED_SIZE_BYTES];
150        self.inner
151            .to_bytes(&mut buf)
152            .map_err(|e| JsError::new(&format!("{e}")))?;
153        Ok(buf)
154    }
155
156    /// Human-readable debug string.
157    #[wasm_bindgen(js_name = "toString")]
158    pub fn to_string_js(&self) -> String {
159        format!("{:?}", self.inner)
160    }
161
162    /// Create a default NTPv4 client request packet.
163    #[wasm_bindgen(js_name = "clientRequest")]
164    pub fn client_request() -> NtpPacket {
165        NtpPacket {
166            inner: Packet::default(),
167        }
168    }
169
170    /// Set the NTP version (1-5).
171    #[wasm_bindgen(js_name = "setVersion")]
172    pub fn set_version(&mut self, v: u8) -> Result<(), JsError> {
173        self.inner.version =
174            Version::new(v).ok_or_else(|| JsError::new(&format!("invalid version: {v}")))?;
175        Ok(())
176    }
177
178    /// Set the association mode (0-7).
179    #[wasm_bindgen(js_name = "setMode")]
180    pub fn set_mode(&mut self, m: u8) -> Result<(), JsError> {
181        self.inner.mode = match m {
182            0 => Mode::Reserved,
183            1 => Mode::SymmetricActive,
184            2 => Mode::SymmetricPassive,
185            3 => Mode::Client,
186            4 => Mode::Server,
187            5 => Mode::Broadcast,
188            6 => Mode::NtpControlMessage,
189            7 => Mode::ReservedForPrivateUse,
190            _ => return Err(JsError::new(&format!("invalid mode: {m}"))),
191        };
192        Ok(())
193    }
194
195    /// Set the stratum level (0-255).
196    #[wasm_bindgen(js_name = "setStratum")]
197    pub fn set_stratum(&mut self, s: u8) {
198        self.inner.stratum = Stratum(s);
199    }
200
201    /// Set the poll interval exponent (log2 seconds).
202    #[wasm_bindgen(js_name = "setPoll")]
203    pub fn set_poll(&mut self, p: i8) {
204        self.inner.poll = p;
205    }
206
207    /// Set the precision exponent (log2 seconds).
208    #[wasm_bindgen(js_name = "setPrecision")]
209    pub fn set_precision(&mut self, p: i8) {
210        self.inner.precision = p;
211    }
212
213    /// Set the transmit timestamp from `[seconds, fraction]`.
214    #[wasm_bindgen(js_name = "setTransmitTimestamp")]
215    pub fn set_transmit_timestamp(&mut self, seconds: u32, fraction: u32) {
216        self.inner.transmit_timestamp = protocol::TimestampFormat { seconds, fraction };
217    }
218
219    /// Set the origin timestamp from `[seconds, fraction]`.
220    #[wasm_bindgen(js_name = "setOriginTimestamp")]
221    pub fn set_origin_timestamp(&mut self, seconds: u32, fraction: u32) {
222        self.inner.origin_timestamp = protocol::TimestampFormat { seconds, fraction };
223    }
224
225    /// Set the receive timestamp from `[seconds, fraction]`.
226    #[wasm_bindgen(js_name = "setReceiveTimestamp")]
227    pub fn set_receive_timestamp(&mut self, seconds: u32, fraction: u32) {
228        self.inner.receive_timestamp = protocol::TimestampFormat { seconds, fraction };
229    }
230
231    /// Set the reference timestamp from `[seconds, fraction]`.
232    #[wasm_bindgen(js_name = "setReferenceTimestamp")]
233    pub fn set_reference_timestamp(&mut self, seconds: u32, fraction: u32) {
234        self.inner.reference_timestamp = protocol::TimestampFormat { seconds, fraction };
235    }
236
237    /// Set the leap indicator (0-3).
238    #[wasm_bindgen(js_name = "setLeapIndicator")]
239    pub fn set_leap_indicator(&mut self, li: u8) -> Result<(), JsError> {
240        self.inner.leap_indicator = match li {
241            0 => LeapIndicator::NoWarning,
242            1 => LeapIndicator::AddOne,
243            2 => LeapIndicator::SubOne,
244            3 => LeapIndicator::Unknown,
245            _ => return Err(JsError::new(&format!("invalid leap indicator: {li}"))),
246        };
247        Ok(())
248    }
249}
250
251/// Build a minimal NTPv4 client request packet (48 bytes).
252#[wasm_bindgen(js_name = "buildClientRequest")]
253pub fn build_client_request() -> Result<Vec<u8>, JsError> {
254    let request = Packet::default();
255    let mut buf = vec![0u8; Packet::PACKED_SIZE_BYTES];
256    request
257        .to_bytes(&mut buf)
258        .map_err(|e| JsError::new(&format!("{e}")))?;
259    Ok(buf)
260}
261
262/// Convert an NTP timestamp to Unix seconds (with fractional part).
263///
264/// `ntp_seconds` and `ntp_fraction` are the two 32-bit components of the
265/// NTP timestamp. `pivot_unix_seconds` is a reference time (e.g., `Date.now() / 1000`)
266/// used for era disambiguation (needed because NTP timestamps wrap every ~136 years).
267#[wasm_bindgen(js_name = "ntpTimestampToUnixSeconds")]
268pub fn ntp_timestamp_to_unix_seconds(
269    ntp_seconds: u32,
270    ntp_fraction: u32,
271    pivot_unix_seconds: f64,
272) -> f64 {
273    let pivot = unix_time::Instant::new(pivot_unix_seconds as i64, 0)
274        .expect("pivot with zero nanos is always valid");
275    let ts = protocol::TimestampFormat {
276        seconds: ntp_seconds,
277        fraction: ntp_fraction,
278    };
279    let instant = unix_time::timestamp_to_instant(ts, &pivot);
280    instant.secs() as f64 + (instant.subsec_nanos() as f64 / 1e9)
281}
282
283/// Convert Unix seconds to an NTP timestamp.
284///
285/// Returns `[seconds, fraction]` as a two-element array.
286#[wasm_bindgen(js_name = "unixSecondsToNtpTimestamp")]
287pub fn unix_seconds_to_ntp_timestamp(unix_seconds: f64) -> Vec<u32> {
288    let secs = unix_seconds.trunc() as i64;
289    let nanos = ((unix_seconds.fract()) * 1e9) as i32;
290    let instant = unix_time::Instant::new(secs, nanos)
291        .expect("trunc/fract decomposition produces same-sign components");
292    let ts: protocol::TimestampFormat = instant.into();
293    vec![ts.seconds, ts.fraction]
294}
295
296/// Parse extension fields from bytes following the 48-byte NTP header.
297///
298/// Returns a JavaScript array of objects: `[{ fieldType: number, value: Uint8Array }, ...]`
299#[wasm_bindgen(js_name = "parseExtensionFields")]
300pub fn parse_extension_fields_js(data: &[u8]) -> Result<JsValue, JsError> {
301    let arr = js_sys::Array::new();
302    for result in iter_extension_fields(data) {
303        let ef: ntp_proto::extension::ExtensionFieldRef<'_> =
304            result.map_err(|e| JsError::new(&format!("{e}")))?;
305        let obj = js_sys::Object::new();
306        js_sys::Reflect::set(&obj, &"fieldType".into(), &ef.field_type.into())
307            .map_err(|e| JsError::new(&format!("{e:?}")))?;
308        let value = js_sys::Uint8Array::from(ef.value);
309        js_sys::Reflect::set(&obj, &"value".into(), &value.into())
310            .map_err(|e| JsError::new(&format!("{e:?}")))?;
311        arr.push(&obj.into());
312    }
313    Ok(arr.into())
314}
315
316/// Compute clock offset and round-trip delay from four NTP timestamps.
317///
318/// Uses the RFC 5905 formulas:
319/// - `offset = ((t2 - t1) + (t3 - t4)) / 2`
320/// - `delay  = (t4 - t1) - (t3 - t2)`
321///
322/// Each timestamp is passed as `(seconds, fraction)` pairs in NTP format.
323/// `pivot_unix_seconds` is used for era disambiguation (e.g., `Date.now() / 1000`).
324///
325/// Returns `{ offset: number, delay: number }` in seconds (floating-point).
326#[wasm_bindgen(js_name = "computeOffsetDelay")]
327#[allow(clippy::too_many_arguments)]
328pub fn compute_offset_delay(
329    t1_seconds: u32,
330    t1_fraction: u32,
331    t2_seconds: u32,
332    t2_fraction: u32,
333    t3_seconds: u32,
334    t3_fraction: u32,
335    t4_seconds: u32,
336    t4_fraction: u32,
337    pivot_unix_seconds: f64,
338) -> Result<JsValue, JsError> {
339    let pivot = unix_time::Instant::new(pivot_unix_seconds as i64, 0)
340        .expect("pivot with zero nanos is always valid");
341
342    let t1 = unix_time::timestamp_to_instant(
343        protocol::TimestampFormat {
344            seconds: t1_seconds,
345            fraction: t1_fraction,
346        },
347        &pivot,
348    );
349    let t2 = unix_time::timestamp_to_instant(
350        protocol::TimestampFormat {
351            seconds: t2_seconds,
352            fraction: t2_fraction,
353        },
354        &pivot,
355    );
356    let t3 = unix_time::timestamp_to_instant(
357        protocol::TimestampFormat {
358            seconds: t3_seconds,
359            fraction: t3_fraction,
360        },
361        &pivot,
362    );
363    let t4 = unix_time::timestamp_to_instant(
364        protocol::TimestampFormat {
365            seconds: t4_seconds,
366            fraction: t4_fraction,
367        },
368        &pivot,
369    );
370
371    // Convert to f64 seconds for arithmetic.
372    let t1f = t1.secs() as f64 + t1.subsec_nanos() as f64 / 1e9;
373    let t2f = t2.secs() as f64 + t2.subsec_nanos() as f64 / 1e9;
374    let t3f = t3.secs() as f64 + t3.subsec_nanos() as f64 / 1e9;
375    let t4f = t4.secs() as f64 + t4.subsec_nanos() as f64 / 1e9;
376
377    let offset = ((t2f - t1f) + (t3f - t4f)) / 2.0;
378    let delay = (t4f - t1f) - (t3f - t2f);
379
380    let obj = js_sys::Object::new();
381    js_sys::Reflect::set(&obj, &"offset".into(), &offset.into())
382        .map_err(|e| JsError::new(&format!("{e:?}")))?;
383    js_sys::Reflect::set(&obj, &"delay".into(), &delay.into())
384        .map_err(|e| JsError::new(&format!("{e:?}")))?;
385    Ok(obj.into())
386}
387
388/// Validate an NTP server response per RFC 5905 packet sanity checks.
389///
390/// Returns `null` if the response is valid, or an error string describing the
391/// first validation failure found.
392///
393/// Checks performed:
394/// 1. Packet is at least 48 bytes and parses successfully.
395/// 2. Mode is Server (4).
396/// 3. Stratum is not 0 (unless it's a Kiss-o'-Death).
397/// 4. Transmit timestamp is not zero.
398/// 5. If `origin_t1_seconds` and `origin_t1_fraction` are provided, the response's
399///    origin timestamp must match (echo of client's transmit timestamp).
400///
401/// Kiss-o'-Death (KoD) packets (stratum=0) are flagged with the reference ID
402/// as the kiss code (e.g., "DENY", "RATE", "RSTR").
403#[wasm_bindgen(js_name = "validateResponse")]
404pub fn validate_response(
405    bytes: &[u8],
406    origin_t1_seconds: Option<u32>,
407    origin_t1_fraction: Option<u32>,
408) -> Result<JsValue, JsError> {
409    // 1. Parse
410    if bytes.len() < Packet::PACKED_SIZE_BYTES {
411        return Ok(JsValue::from_str(&format!(
412            "packet too short: {} bytes (need {})",
413            bytes.len(),
414            Packet::PACKED_SIZE_BYTES
415        )));
416    }
417    let (packet, _) = match Packet::from_bytes(bytes) {
418        Ok(p) => p,
419        Err(e) => return Ok(JsValue::from_str(&format!("parse error: {e}"))),
420    };
421
422    // 2. Mode check
423    if packet.mode != Mode::Server {
424        return Ok(JsValue::from_str(&format!(
425            "unexpected mode: {} (expected 4/Server)",
426            packet.mode as u8
427        )));
428    }
429
430    // 3. Stratum / KoD check
431    if packet.stratum.0 == 0 {
432        let code = packet.reference_id.as_bytes();
433        let kiss_code = core::str::from_utf8(&code)
434            .unwrap_or("????")
435            .trim_end_matches('\0');
436        return Ok(JsValue::from_str(&format!("Kiss-o'-Death: {kiss_code}")));
437    }
438
439    // 4. Transmit timestamp not zero
440    if packet.transmit_timestamp.seconds == 0 && packet.transmit_timestamp.fraction == 0 {
441        return Ok(JsValue::from_str("transmit timestamp is zero"));
442    }
443
444    // 5. Origin timestamp match (if provided)
445    if let (Some(t1s), Some(t1f)) = (origin_t1_seconds, origin_t1_fraction)
446        && (packet.origin_timestamp.seconds != t1s || packet.origin_timestamp.fraction != t1f)
447    {
448        return Ok(JsValue::from_str(&format!(
449            "origin timestamp mismatch: expected [{t1s}, {t1f}], got [{}, {}]",
450            packet.origin_timestamp.seconds, packet.origin_timestamp.fraction
451        )));
452    }
453
454    // Valid
455    Ok(JsValue::NULL)
456}