diff --git a/docs/services/ntpd.md b/docs/services/ntpd.md index 38390543..bdeac7bf 100644 --- a/docs/services/ntpd.md +++ b/docs/services/ntpd.md @@ -69,6 +69,25 @@ clock-setting call. | `Servers` | REG_SZ | `pool.ntp.org` | `;`-separated list of hostnames or dotted IPv4 addresses, tried in order. | | `Interval` | REG_DWORD | `3600` | Seconds between successful synchronizations (minimum 16). | | `Timeout` | REG_DWORD | `3000` | Per-server response timeout in milliseconds. | +| `KeyId` | REG_DWORD | `0` | RFC 5905 symmetric-key identifier (used with `Key`). | +| `Key` | REG_SZ | *(empty)* | Pre-shared key in hex (e.g. `0x0102…` or `01:02:…`). Non-empty enables authentication. | +| `Mac` | REG_SZ | *(unset = md5)* | MAC algorithm. `off` disables authentication even if `Key` is set. | + +### Authentication (RFC 5905 symmetric-key MAC, #361) + +When `Key` is configured (and `Mac` is not `off`), `ntpd`: + +- appends a **key id + MD5 MAC** to each request + (`libntp::build_request_authed` and `sign_request_md5`), and +- **rejects any reply without a matching authenticator** before parsing the + header (`libntp::parse_reply_authed`): a plain 48-byte reply is rejected as + unauthenticated, a wrong key id as a mismatch, and a bad MAC as a failure. + +The MAC is `MD5( key || packet-through-key-id )`, matching the ntpd convention. +MD5 is the RFC 5905 default and is **not** collision-resistant; **NTS (RFC 8915) +is not implemented** (there is no TLS stack in the userland network stack). +Authentication is off by default (no `Key`), so an unconfigured system behaves +exactly as before. The Registry is the **single source of truth**. `ntpd` never writes configuration; a future `neocfg` NTP module (see ADM-5 #30 / TOOL-NEOCFG #98) @@ -158,7 +177,7 @@ admin-only, matching `SetHostname`. | Timezone/DST: NTP time is UTC and is written to the RTC directly; no local-time offset is applied. | New Issue: timezone support | | RTC resolution is 1 second, bounding offset accuracy; `libntp` still computes sub-second offsets. | This document | | No timed sleep in userland; periodic waits use an RDTSC budget. | #283, #307 | -| No NTP authentication (NTS/MAC); only unauthenticated SNTP unicast. | Out of scope (see Follow-up) | +| Symmetric-key MD5 MAC supported (RFC 5905, #361); **NTS (RFC 8915) is not** — no TLS in the userland stack. MD5 is not collision-resistant. | #361 (MAC done), NTS out of scope | | Service shutdown is now graceful (notification + bounded timeout). | #358 (resolved) | ## 7.1 Build/packaging dependency @@ -171,6 +190,8 @@ permanent fix (auto-discovery) is tracked in NeoDOS-Project/NeoDev#5. ## 8. Testing - `libntp` unit tests (host): request encoding, reply validation, offset/delay - math, NTP↔Unix conversion, civil-time round trips, config parsing. + math, NTP↔Unix conversion, civil-time round trips, config parsing, **MD5 + (RFC 1321) vectors and MAC authentication** (accept a valid MAC; reject + missing/key-id-mismatch/bad-MAC/tampered replies). - Kernel tests: `ObSetInfoClass::DateTime` field validation. - E2E (VirtualBox/QEMU): see `docs/development/testing.md`. diff --git a/libntp/src/lib.rs b/libntp/src/lib.rs index 4de2c4b2..9419ef6c 100644 --- a/libntp/src/lib.rs +++ b/libntp/src/lib.rs @@ -6,9 +6,10 @@ //! userland daemon provides the transport (UDP sockets via `net.nxl`), the //! Registry-backed configuration and the clock-setting call on top of it. //! -//! Only SNTP unicast client mode (NTPv4, mode 3/4) is implemented. There is no -//! authentication (NTS/MAC), no broadcast/multicast mode and no clock -//! discipline (slew/drift) — the daemon applies a direct step. +//! Only SNTP unicast client mode (NTPv4, mode 3/4) is implemented. Symmetric-key +//! authentication (RFC 5905 Appendix A, key id + MD5 MAC) is supported; NTS +//! (RFC 8915) is not — there is no TLS stack. No broadcast/multicast mode and no +//! clock discipline (slew/drift) — the daemon applies a direct step. //! //! When built for NeoDOS it is `no_std`; under `cargo test` it uses the host //! `std` test harness. @@ -41,6 +42,13 @@ pub const NTP_STRATUM_KOD: u8 = 0; /// Valid server strata are 1..=15. pub const NTP_STRATUM_MAX: u8 = 15; +/// Authenticator key identifier length (RFC 5905 Appendix A). +pub const NTP_KEYID_LEN: usize = 4; +/// MD5 MAC length (the RFC 5905 symmetric-key default). +pub const NTP_MAC_MD5_LEN: usize = 16; +/// Authenticated packet size: 48-byte header + key id + MD5 MAC. +pub const NTP_AUTH_PACKET_SIZE: usize = NTP_PACKET_SIZE + NTP_KEYID_LEN + NTP_MAC_MD5_LEN; + // ── Errors ── #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,6 +65,13 @@ pub enum NtpError { ZeroTransmit, /// Originate timestamp does not match the request we sent. OriginateMismatch, + /// Authentication is configured but the reply carries no authenticator + /// (RFC 5905 MAC) — untrusted, rejected. + AuthMissing, + /// Reply key identifier does not match the configured key id. + AuthKeyMismatch, + /// Reply MAC does not verify against the configured key. + AuthFailed, } // ── Packet ── @@ -294,6 +309,178 @@ pub fn parse_servers_owned(raw: &str) -> Vec { parse_servers(raw).into_iter().map(String::from).collect() } +/// Decode a hexadecimal pre-shared key (RFC 5905 symmetric key). Ignores +/// spaces, colons, dashes and an optional `0x` prefix. Returns `None` for an +/// odd number of digits or a non-hex character. +pub fn parse_key_hex(raw: &str) -> Option> { + let s = raw.trim(); + let s = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); + let mut nibbles = Vec::new(); + for c in s.chars() { + if c == ':' || c == ' ' || c == '-' || c == '\t' { + continue; + } + nibbles.push(c.to_digit(16)? as u8); + } + if nibbles.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(nibbles.len() / 2); + for pair in nibbles.chunks_exact(2) { + out.push((pair[0] << 4) | pair[1]); + } + Some(out) +} + +// ── RFC 5905 symmetric-key authentication (MD5 MAC) ── + +/// Minimal MD5 (RFC 1321) — the RFC 5905 symmetric-key MAC primitive. +/// +/// MD5 is not collision-resistant and this is **not** a substitute for NTS; +/// it only matches the RFC 5905 Appendix A default so replies can be verified +/// against a configured pre-shared key. +pub fn md5(data: &[u8]) -> [u8; 16] { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, + 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, + 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, + 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, + 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, + 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, + 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, + 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, + 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, + 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, + ]; + let mut msg: Vec = Vec::with_capacity(data.len() + 72); + msg.extend_from_slice(data); + let bit_len = (data.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_le_bytes()); + + let mut a0: u32 = 0x6745_2301; + let mut b0: u32 = 0xefcd_ab89; + let mut c0: u32 = 0x98ba_dcfe; + let mut d0: u32 = 0x1032_5476; + for chunk in msg.chunks_exact(64) { + let mut m = [0u32; 16]; + for (i, w) in m.iter_mut().enumerate() { + *w = u32::from_le_bytes([chunk[i * 4], chunk[i * 4 + 1], chunk[i * 4 + 2], chunk[i * 4 + 3]]); + } + let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); + for i in 0..64 { + let (f, g) = match i { + 0..=15 => ((b & c) | ((!b) & d), i), + 16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16), + 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | (!d)), (7 * i) % 16), + }; + let tmp = d; + d = c; + c = b; + let x = a + .wrapping_add(f) + .wrapping_add(K[i]) + .wrapping_add(m[g]); + b = b.wrapping_add(x.rotate_left(S[i])); + a = tmp; + } + a0 = a0.wrapping_add(a); + b0 = b0.wrapping_add(b); + c0 = c0.wrapping_add(c); + d0 = d0.wrapping_add(d); + } + let mut out = [0u8; 16]; + out[0..4].copy_from_slice(&a0.to_le_bytes()); + out[4..8].copy_from_slice(&b0.to_le_bytes()); + out[8..12].copy_from_slice(&c0.to_le_bytes()); + out[12..16].copy_from_slice(&d0.to_le_bytes()); + out +} + +/// RFC 5905 MAC: `MD5( key || packet-through-key-id )`. +fn mac_md5(key: &[u8], packet_through_keyid: &[u8]) -> [u8; 16] { + let mut buf = Vec::with_capacity(key.len() + packet_through_keyid.len()); + buf.extend_from_slice(key); + buf.extend_from_slice(packet_through_keyid); + md5(&buf) +} + +/// Build a 68-byte authenticated request (header + key id + zero MAC slot). +pub fn build_request_authed(transmit_ts: u64, key_id: u32) -> [u8; NTP_AUTH_PACKET_SIZE] { + let base = build_request(transmit_ts); + let mut pkt = [0u8; NTP_AUTH_PACKET_SIZE]; + pkt[..NTP_PACKET_SIZE].copy_from_slice(&base); + pkt[NTP_PACKET_SIZE..NTP_PACKET_SIZE + NTP_KEYID_LEN].copy_from_slice(&key_id.to_be_bytes()); + pkt +} + +/// Fill the MAC of an authenticated request in place. +pub fn sign_request_md5(pkt: &mut [u8; NTP_AUTH_PACKET_SIZE], key: &[u8]) { + let mac = mac_md5(key, &pkt[..NTP_PACKET_SIZE + NTP_KEYID_LEN]); + pkt[NTP_PACKET_SIZE + NTP_KEYID_LEN..].copy_from_slice(&mac); +} + +/// Constant-time compare of a computed MAC against 16 received bytes. +fn ct_eq16(a: &[u8; 16], b: &[u8]) -> bool { + if b.len() != 16 { + return false; + } + let mut diff = 0u8; + for i in 0..16 { + diff |= a[i] ^ b[i]; + } + diff == 0 +} + +/// Verify and parse an authenticated reply. +/// +/// Rejects a reply with no authenticator ([`NtpError::AuthMissing`]), a key id +/// mismatch ([`NtpError::AuthKeyMismatch`]) or a bad MAC +/// ([`NtpError::AuthFailed`]) before parsing the header. +pub fn parse_reply_authed( + buf: &[u8], + expected_originate: u64, + key_id: u32, + key: &[u8], +) -> Result { + if buf.len() < NTP_AUTH_PACKET_SIZE { + return Err(NtpError::AuthMissing); + } + let pkt_keyid = u32::from_be_bytes([ + buf[NTP_PACKET_SIZE], + buf[NTP_PACKET_SIZE + 1], + buf[NTP_PACKET_SIZE + 2], + buf[NTP_PACKET_SIZE + 3], + ]); + if pkt_keyid != key_id { + return Err(NtpError::AuthKeyMismatch); + } + let expected = mac_md5(key, &buf[..NTP_PACKET_SIZE + NTP_KEYID_LEN]); + if !ct_eq16(&expected, &buf[NTP_PACKET_SIZE + NTP_KEYID_LEN..NTP_AUTH_PACKET_SIZE]) { + return Err(NtpError::AuthFailed); + } + parse_reply(&buf[..NTP_PACKET_SIZE], expected_originate) +} + // ── Date/time formatting ── fn put_02(v: u8, buf: &mut [u8], pos: &mut usize) { @@ -520,4 +707,76 @@ mod tests { let n = format_datetime(&dt, &mut b); assert_eq!(&b[..n], b"03/04/2024 09:07:05"); } + + fn hex16(s: &str) -> [u8; 16] { + let mut out = [0u8; 16]; + let h = s.as_bytes(); + for i in 0..16 { + let hi = (h[i * 2] as char).to_digit(16).unwrap() as u8; + let lo = (h[i * 2 + 1] as char).to_digit(16).unwrap() as u8; + out[i] = (hi << 4) | lo; + } + out + } + + #[test] + fn md5_rfc1321_vectors() { + // RFC 1321 test suite (the MAC primitive itself). + assert_eq!(md5(b""), hex16("d41d8cd98f00b204e9800998ecf8427e")); + assert_eq!(md5(b"abc"), hex16("900150983cd24fb0d6963f7d28e17f72")); + assert_eq!(md5(b"message digest"), hex16("f96b697d7cb7938d525a2f31aaf161d0")); + assert_eq!( + md5(b"The quick brown fox jumps over the lazy dog"), + hex16("9e107d9d372bb6826bd81d3542a419d6") + ); + } + + #[test] + fn auth_request_carries_keyid() { + let req = build_request_authed(0, 1234); + assert_eq!(&req[NTP_PACKET_SIZE..NTP_PACKET_SIZE + 4], &1234u32.to_be_bytes()); + assert_eq!(req[0], (NTP_VERSION << 3) | NTP_MODE_CLIENT); + } + + fn signed_reply(key_id: u32, key: &[u8], transmit: u64, originate: u64) -> [u8; NTP_AUTH_PACKET_SIZE] { + let mut b = [0u8; NTP_AUTH_PACKET_SIZE]; + b[..NTP_PACKET_SIZE].copy_from_slice(&reply_with(transmit, transmit, originate)); + b[NTP_PACKET_SIZE..NTP_PACKET_SIZE + 4].copy_from_slice(&key_id.to_be_bytes()); + let mac = mac_md5(key, &b[..NTP_PACKET_SIZE + 4]); + b[NTP_PACKET_SIZE + 4..].copy_from_slice(&mac); + b + } + + #[test] + fn auth_rejects_unauthenticated_reply() { + // Configured key + a plain 48-byte reply (no authenticator) -> rejected. + let key = b"topsecret"; + let plain = reply_with(0x1122, 0x1122, 0); + assert_eq!(parse_reply_authed(&plain, 0, 1, key), Err(NtpError::AuthMissing)); + } + + #[test] + fn auth_accepts_valid_and_rejects_tampering() { + let key = b"topsecret"; + let key_id = 7u32; + let reply = signed_reply(key_id, key, 0x0102_0304_0506_0708, 0); + let r = parse_reply_authed(&reply, 0, key_id, key).expect("valid MAC accepted"); + assert_eq!(r.transmit_ts, 0x0102_0304_0506_0708); + + // Wrong key id, wrong key, and a tampered header are all rejected. + assert_eq!(parse_reply_authed(&reply, 0, key_id + 1, key), Err(NtpError::AuthKeyMismatch)); + assert_eq!(parse_reply_authed(&reply, 0, key_id, b"wrong"), Err(NtpError::AuthFailed)); + let mut tampered = reply; + tampered[40] ^= 0x01; + assert_eq!(parse_reply_authed(&tampered, 0, key_id, key), Err(NtpError::AuthFailed)); + } + + #[test] + fn parse_key_hex_forms() { + assert_eq!(parse_key_hex("0x01020304").unwrap(), alloc::vec![1, 2, 3, 4]); + assert_eq!(parse_key_hex("01:02:03:04").unwrap(), alloc::vec![1, 2, 3, 4]); + assert_eq!(parse_key_hex(" 0102 0304 ").unwrap(), alloc::vec![1, 2, 3, 4]); + assert!(parse_key_hex("abc").is_none()); + assert!(parse_key_hex("zz").is_none()); + } } diff --git a/userbin/ntpd/src/main.rs b/userbin/ntpd/src/main.rs index dfa47d10..0b692ed5 100644 --- a/userbin/ntpd/src/main.rs +++ b/userbin/ntpd/src/main.rs @@ -236,6 +236,10 @@ struct NtpConfig { servers: Vec, interval: u32, timeout_ms: u32, + /// RFC 5905 symmetric-key authentication: key id + pre-shared key (empty = + /// unauthenticated). + key_id: u32, + key: Vec, } fn load_config() -> NtpConfig { @@ -244,6 +248,8 @@ fn load_config() -> NtpConfig { servers: alloc::vec![String::from(DEFAULT_SERVER)], interval: DEFAULT_INTERVAL, timeout_ms: DEFAULT_TIMEOUT_MS, + key_id: 0, + key: Vec::new(), }; if let Ok(fd) = syscall::sys_cm_open_key(REG_PARAM) { @@ -262,6 +268,21 @@ fn load_config() -> NtpConfig { if let Some(v) = read_reg_dword(fd, "Timeout") { cfg.timeout_ms = v.max(100); } + // RFC 5905 symmetric-key authentication (#361). `Key` is hex; `Mac=off` + // disables it even when a key is present. + if let Some(v) = read_reg_dword(fd, "KeyId") { + cfg.key_id = v; + } + if let Some(s) = read_reg_string(fd, "Key") { + if let Some(k) = libntp::parse_key_hex(&s) { + cfg.key = k; + } + } + if let Some(m) = read_reg_string(fd, "Mac") { + if m.eq_ignore_ascii_case("off") { + cfg.key.clear(); + } + } let _ = syscall::sys_close(fd); } @@ -345,19 +366,32 @@ struct SyncResult { unix_secs: i64, } -fn ntp_sync(ip: [u8; 4], timeout_ms: u32) -> Result { +fn ntp_sync(ip: [u8; 4], timeout_ms: u32, key_id: u32, key: &[u8]) -> Result { + let authed = !key.is_empty(); let t1 = rtc_now_ns().unwrap_or(0); let t1_secs = (t1 / 1_000_000_000) as i64; - let request = libntp::build_request(libntp::unix_secs_to_ntp(t1_secs)); + let ntp_ts = libntp::unix_secs_to_ntp(t1_secs); + + // Request: plain 48 bytes, or header + key id + MD5 MAC (#361). + let mut request = [0u8; libntp::NTP_AUTH_PACKET_SIZE]; + let req_len = if authed { + let pkt = libntp::build_request_authed(ntp_ts, key_id); + request.copy_from_slice(&pkt); + libntp::sign_request_md5(&mut request, key); + libntp::NTP_AUTH_PACKET_SIZE + } else { + request[..libntp::NTP_PACKET_SIZE].copy_from_slice(&libntp::build_request(ntp_ts)); + libntp::NTP_PACKET_SIZE + }; // Shared UDP exchange (#593): create → bind → connect → send with ARP retry // → timed receive → close. - let mut buf = [0u8; libntp::NTP_PACKET_SIZE]; + let mut buf = [0u8; libntp::NTP_AUTH_PACKET_SIZE]; let budget = timeout_ms as u64 * TICKS_PER_MS; let received = match libnet::udp::exchange( u32::from_be_bytes(ip), libntp::NTP_PORT, - &request, + &request[..req_len], &mut buf, budget, ) { @@ -366,8 +400,16 @@ fn ntp_sync(ip: [u8; 4], timeout_ms: u32) -> Result { Err(_) => return Err("send failed"), }; - let reply = libntp::parse_reply(&buf[..received], libntp::unix_secs_to_ntp(t1_secs)) - .map_err(|_| "invalid NTP response")?; + let reply = if authed { + libntp::parse_reply_authed(&buf[..received], ntp_ts, key_id, key).map_err(|e| match e { + libntp::NtpError::AuthMissing => "unauthenticated reply", + libntp::NtpError::AuthKeyMismatch => "key id mismatch", + libntp::NtpError::AuthFailed => "bad MAC", + _ => "invalid NTP response", + })? + } else { + libntp::parse_reply(&buf[..received], ntp_ts).map_err(|_| "invalid NTP response")? + }; let t2 = libntp::ntp_to_unix_ns(reply.receive_ts); let t3 = libntp::ntp_to_unix_ns(reply.transmit_ts); let t4 = rtc_now_ns().unwrap_or(t1); @@ -426,6 +468,11 @@ pub extern "C" fn _start() -> ! { let once = arg_once(); let cfg = load_config(); + if !cfg.key.is_empty() { + write_str(b"[ntpd] authentication enabled (MD5, key id "); + write_u32(cfg.key_id); + write_str(b")\r\n"); + } if !cfg.enabled { write_str(b"[ntpd] NTP disabled by configuration; idling\r\n"); if once { @@ -478,7 +525,7 @@ pub extern "C" fn _start() -> ! { write_ip(ip); write_str(b")\r\n"); - match ntp_sync(ip, cfg.timeout_ms) { + match ntp_sync(ip, cfg.timeout_ms, cfg.key_id, &cfg.key) { Ok(r) => { let offset_ms = (r.offset_ns / 1_000_000) as i64; let delay_ms = (r.delay_ns / 1_000_000) as i64;