Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions docs/services/ntpd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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`.
265 changes: 262 additions & 3 deletions libntp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)]
Expand All @@ -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 ──
Expand Down Expand Up @@ -294,6 +309,178 @@ pub fn parse_servers_owned(raw: &str) -> Vec<String> {
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<Vec<u8>> {
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<u8> = 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<NtpReply, NtpError> {
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) {
Expand Down Expand Up @@ -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());
}
}
Loading
Loading