From 29943e536f2be420a9d4042cd26d7b7eddf71438 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Tue, 28 Jul 2026 17:38:06 -0500 Subject: Address remaining audit findings: input hardening and cleanup Security: - SNTP replies must echo a random nonce in their originate timestamp, and unsynchronized (LI=3) or invalid-stratum servers are rejected, so an off-path spoofer cannot defeat the clock-trust gate - The storage directory is created 0700 but an existing one is tightened only when it is provably ours (holds our artifacts or is empty); a misconfigured path like /tmp under root is left alone with a warning - Device-supplied vendor/product names are stripped of control characters at ingestion so a device cannot inject terminal escapes Cleanup: - ensure_fabric splits into decision logic plus bootstrap_fabric - read_device_names merges the two name readers; a From impl replaces the hand-mapped IdentityReport; run_sync uses map/transpose with an extracted manual_instant; pick_interface runs once; print_json escapes its fallback; device_name dedups the vendor/product join; identity_status ignores leftover .tmp blobs; dead parameters and repeated random-id code removed 36 unit tests, clippy clean. --- src/host.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) (limited to 'src/host.rs') diff --git a/src/host.rs b/src/host.rs index feb5a34..d3e871e 100644 --- a/src/host.rs +++ b/src/host.rs @@ -42,6 +42,14 @@ pub fn clock_is_ntp_synchronized() -> bool { /// clock relative to the server (positive = local clock ahead). Uses the /// request/response midpoint, so the error is bounded by half the round /// trip, which is milliseconds against a threshold of a second. +/// +/// The request carries a random transmit timestamp that the reply must echo +/// in its originate field (RFC 4330 ยง5); this binds the answer to our +/// request so an off-path packet or a stale reply is rejected. It cannot +/// stop an on-path attacker, but the gate only decides whether to trust the +/// *host's own* clock (the device is never set from the NTP value), so the +/// worst an attacker gains is suppressing sync or blessing an already-wrong +/// host clock. fn sntp_offset(server: &str) -> Option { let socket = UdpSocket::bind(("0.0.0.0", 0)).ok()?; socket.set_read_timeout(Some(Duration::from_secs(2))).ok()?; @@ -49,14 +57,29 @@ fn sntp_offset(server: &str) -> Option { let mut request = [0u8; 48]; request[0] = 0b00_100_011; // LI 0, version 4, mode 3 (client) + // Random transmit timestamp (bytes 40..48) used as an anti-spoof nonce. + let mut nonce = [0u8; 8]; + getrandom(&mut nonce); + request[40..48].copy_from_slice(&nonce); let sent_at = unix_now(); socket.send(&request).ok()?; let mut response = [0u8; 48]; let len = socket.recv(&mut response).ok()?; let received_at = unix_now(); - if len < 48 || response[0] & 0x07 != 4 { - // Not a server-mode reply. + if len < 48 { + return None; + } + let leap = response[0] >> 6; + let mode = response[0] & 0x07; + let stratum = response[1]; + // Reject non-server replies, unsynchronized servers (LI=3), and + // kiss-of-death / invalid stratum (0, or 16+ = unsynchronized). + if mode != 4 || leap == 3 || !(1..=15).contains(&stratum) { + return None; + } + // The reply's originate timestamp (bytes 24..32) must echo our nonce. + if response[24..32] != nonce { return None; } @@ -71,6 +94,13 @@ fn sntp_offset(server: &str) -> Option { Some((sent_at + received_at) / 2.0 - server_time) } +/// Fills `buf` with random bytes via rs-matter's crypto RNG (already a +/// dependency); a nonce only needs unpredictability, not the full clock. +fn getrandom(buf: &mut [u8]) { + use rand::RngCore as _; + rand::thread_rng().fill_bytes(buf); +} + fn unix_now() -> f64 { SystemTime::now() .duration_since(UNIX_EPOCH) -- cgit v1.2.3