Files
HaloKeymind/test/test_ntp_validation/test_ntp_validation.cpp
T
agessaman 4c7b6450d7 fix(mqtt): validate NTP replies before trusting them
NTPClient::forceUpdate() treated any non-empty datagram arriving on its fixed
local port 1337 as a time response: it ignored the read length, the version,
the mode, the stratum, the leap indicator and any request/response correlation,
then handed the bytes at offset 40 to the bridge. The bridge only checked that
the derived epoch was at least 2026-01-01 before calling settimeofday() and
writing the RTC. A host probe against the installed library source accepted a
one-byte non-NTP datagram and produced epoch 2085978496. Anyone able to land a
UDP datagram during a query window could set a bogus clock, which then feeds JWT
issuance, certificate validity and packet timestamps.

Replace that path with probeNtpServer(): one exchange per server on a fresh
ephemeral socket, closed again on every exit. A reply is accepted only if it is
a full 48 bytes, from the address and port queried, NTPv3/v4 mode 4, from a
synchronised server (no leap alarm, stratum 1-15), echoing the random transmit
timestamp of the request, with an epoch inside a plausible range. Rejected
datagrams leave the clock alone and do not end the wait, so an early bogus
packet cannot pre-empt the real answer. Era-1 (post-2036) timestamps convert
forward instead of wrapping into 1900.

The diagnostic now runs the same validated probe, so `get mqtt.ntp.diag`
answers the question it is asked — would this server be trusted? — instead of
reporting a datagram nothing checked. It also no longer leaves its UDP socket
open after the probe, and each server's per-probe result carries the reason it
failed ("DNS failed", "unsolicited reply", "server unsynced", ...). The
diagnostic still blocks its caller; making it asynchronous is a separate change.

The acceptance rules live in NtpValidation.h (pure, host-tested), including the
review's one-byte-datagram reproduction.
2026-09-09 14:45:22 -07:00

185 lines
5.6 KiB
C++

#include "helpers/NtpValidation.h"
#include <gtest/gtest.h>
#include <string.h>
using namespace NtpValidation;
namespace {
const uint32_t MIN_EPOCH = 1767225600UL; // 2026-01-01, the bridge's floor
const uint32_t MAX_EPOCH = 4102444800UL; // 2100-01-01
const Nonce NONCE = { 0xA1B2C3D4UL, 0x0F1E2D3CUL };
// A well-formed reply to buildRequest(): stratum 2, mode 4, our nonce echoed.
void makeReply(uint8_t out[48], uint32_t unix_epoch = 1789000000UL) {
memset(out, 0, 48);
out[0] = (0 << 6) | (4 << 3) | 4; // LI=0, VN=4, mode=4 (server)
out[1] = 2; // stratum
writeU32(out + 24, NONCE.seconds); // originate = our transmit timestamp
writeU32(out + 28, NONCE.fraction);
writeU32(out + 32, unix_epoch + kUnixEpochOffset); // receive
writeU32(out + 40, unix_epoch + kUnixEpochOffset); // transmit
}
Result check(const uint8_t* data, size_t len, bool source_matches = true) {
return validate(data, len, source_matches, NONCE, MIN_EPOCH, MAX_EPOCH);
}
} // namespace
TEST(NtpValidation, RequestIsAClientPacketCarryingTheNonce) {
uint8_t req[48];
memset(req, 0xFF, sizeof(req));
buildRequest(req, NONCE);
EXPECT_EQ(0u, (req[0] >> 6) & 0x03); // LI = 0, not the library's bogus alarm
EXPECT_EQ(4u, (req[0] >> 3) & 0x07); // version 4
EXPECT_EQ(3u, req[0] & 0x07); // mode 3 = client
EXPECT_EQ(NONCE.seconds, readU32(req + 40));
EXPECT_EQ(NONCE.fraction, readU32(req + 44));
}
TEST(NtpValidation, ValidReplyIsAccepted) {
uint8_t pkt[48];
makeReply(pkt, 1789000000UL);
Result r = check(pkt, sizeof(pkt));
EXPECT_EQ(kAccepted, r.reject);
EXPECT_EQ(1789000000UL, r.epoch);
}
// F07's reproduction: the installed NTPClient accepted a one-byte non-NTP
// datagram and derived epoch 2085978496 from the uninitialised buffer.
TEST(NtpValidation, OneByteNonNtpDatagramIsRejected) {
const uint8_t junk[1] = { 0x00 };
Result r = check(junk, sizeof(junk));
EXPECT_EQ(kShortPacket, r.reject);
EXPECT_EQ(0u, r.epoch);
}
TEST(NtpValidation, ShortAndEmptyDatagramsAreRejected) {
uint8_t pkt[48];
makeReply(pkt);
for (size_t len = 0; len < 48; len++) {
EXPECT_EQ(kShortPacket, check(pkt, len).reject) << "len " << len;
}
EXPECT_EQ(kShortPacket, check(nullptr, 48).reject);
}
TEST(NtpValidation, ReplyFromAnotherSourceIsRejected) {
uint8_t pkt[48];
makeReply(pkt);
EXPECT_EQ(kWrongSource, check(pkt, sizeof(pkt), /*source_matches=*/false).reject);
}
// An off-path sender that never saw the query cannot echo the nonce.
TEST(NtpValidation, UnsolicitedReplyIsRejected) {
uint8_t pkt[48];
makeReply(pkt);
writeU32(pkt + 24, 0);
writeU32(pkt + 28, 0);
EXPECT_EQ(kOriginMismatch, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
writeU32(pkt + 28, NONCE.fraction ^ 1u); // one bit off is still not ours
EXPECT_EQ(kOriginMismatch, check(pkt, sizeof(pkt)).reject);
}
TEST(NtpValidation, ClientModeAndWrongVersionAreRejected) {
uint8_t pkt[48];
makeReply(pkt);
pkt[0] = (0 << 6) | (4 << 3) | 3; // our own request echoed back
EXPECT_EQ(kBadMode, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[0] = (0 << 6) | (5 << 3) | 4; // version 5 does not exist
EXPECT_EQ(kBadVersion, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[0] = (0 << 6) | (3 << 3) | 4; // NTPv3 servers are fine
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
}
TEST(NtpValidation, UnsynchronisedServersAreRejected) {
uint8_t pkt[48];
makeReply(pkt);
pkt[0] |= (3 << 6); // LI = 3: alarm, clock not set
EXPECT_EQ(kLeapAlarm, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[1] = 0; // stratum 0: kiss-of-death
EXPECT_EQ(kBadStratum, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[1] = 16; // unsynchronised
EXPECT_EQ(kBadStratum, check(pkt, sizeof(pkt)).reject);
makeReply(pkt);
pkt[1] = 15; // last usable stratum
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
}
TEST(NtpValidation, MissingTransmitTimestampIsRejected) {
uint8_t pkt[48];
makeReply(pkt);
writeU32(pkt + 40, 0);
EXPECT_EQ(kZeroTransmit, check(pkt, sizeof(pkt)).reject);
}
// The bridge's floor is the only defence against a stale or rolled-back clock,
// and nothing previously stopped an absurd forward jump.
TEST(NtpValidation, ImplausibleTimesAreRejected) {
uint8_t pkt[48];
makeReply(pkt, MIN_EPOCH - 1);
EXPECT_EQ(kEpochTooEarly, check(pkt, sizeof(pkt)).reject);
makeReply(pkt, MIN_EPOCH);
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
makeReply(pkt, MAX_EPOCH + 1);
EXPECT_EQ(kEpochTooLate, check(pkt, sizeof(pkt)).reject);
makeReply(pkt, MAX_EPOCH);
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
}
// Era 1 begins 2036-02-07; a naive subtraction turns those seconds into 1900.
TEST(NtpValidation, Era1TimestampsConvertForward) {
EXPECT_EQ(0u, unixFromNtpSeconds(kUnixEpochOffset));
EXPECT_EQ(2085978496UL, unixFromNtpSeconds(0)); // 2036-02-07
EXPECT_GT(unixFromNtpSeconds(1000), unixFromNtpSeconds(0xFFFFFFFFu));
uint8_t pkt[48];
makeReply(pkt);
writeU32(pkt + 40, 1000); // 1000 s into era 1
Result r = check(pkt, sizeof(pkt));
EXPECT_EQ(kAccepted, r.reject);
EXPECT_EQ(2085979496UL, r.epoch);
}
TEST(NtpValidation, EveryRejectionHasAShortReason) {
for (int i = 0; i <= (int)kEpochTooLate; i++) {
const char* reason = rejectReason((Reject)i);
ASSERT_NE(nullptr, reason);
EXPECT_GT(strlen(reason), 0u);
EXPECT_LE(strlen(reason), 20u) << "reject " << i;
}
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}