From 0a2c4c9c139e81c5c63c75f2ef5def2c996c1c62 Mon Sep 17 00:00:00 2001 From: yg-ht Date: Sun, 12 Jul 2026 16:05:41 +0100 Subject: [PATCH 1/5] Add authenticated repeater time sync --- docs/cli_commands.md | 27 ++ docs/number_allocations.md | 1 + docs/payloads.md | 83 ++++ examples/simple_repeater/MyMesh.cpp | 393 ++++++++++++++++++ examples/simple_repeater/MyMesh.h | 34 ++ platformio.ini | 9 + src/Identity.cpp | 11 +- src/helpers/CommonCLI.cpp | 29 +- src/helpers/CommonCLI.h | 13 + src/helpers/TimeSyncAuth.cpp | 201 +++++++++ src/helpers/TimeSyncAuth.h | 82 ++++ test/mocks/Stream.h | 14 + .../test_time_sync_auth.cpp | 216 ++++++++++ 13 files changed, 1109 insertions(+), 4 deletions(-) create mode 100644 src/helpers/TimeSyncAuth.cpp create mode 100644 src/helpers/TimeSyncAuth.h create mode 100644 test/test_time_sync_auth/test_time_sync_auth.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c06f5e12b3..67aebf4ea9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -403,6 +403,33 @@ This document provides an overview of CLI commands that can be sent to MeshCore #### View this node's public key **Usage:** `get public.key` +### Repeater authenticated time sync + +Repeater builds support an opt-in authenticated time-sync consumer. + +Configuration: + +- `set time_sync.channel public` +- `set time_sync.channel #time` +- `set time_sync.display_name ` +- `set time_sync.public_key <64-hex-ed25519-public-key>` +- `set time_sync.max_forward_step ` +- `set time_sync.enabled on|off` + +Status: + +- `time_sync.status` +- `time_sync.counters` +- `get time_sync.enabled` +- `get time_sync.channel` +- `get time_sync.display_name` +- `get time_sync.public_key` +- `get time_sync.max_forward_step` + +`set time_sync.enabled on` fails unless the channel, exact display name and complete public key are already configured. The public channel is transport only: the channel MAC and displayed sender name do not prove sender identity. The repeater verifies each accepted `Tv1` message with the pinned 32-byte Ed25519 public key. + +Status output shows whether the consumer is enabled, configured channel, display name, a short public-key fingerprint, and last accepted timestamp and sequence. `time_sync.counters` exposes received, accepted, display-name mismatch, malformed, invalid signature, stale timestamp, replayed sequence, excessive forward step and clock-update counters. It does not echo private key material. + --- #### View this node's firmware version diff --git a/docs/number_allocations.md b/docs/number_allocations.md index 2ccf950f4f..7fd1959d2d 100644 --- a/docs/number_allocations.md +++ b/docs/number_allocations.md @@ -18,6 +18,7 @@ Once you have a working app/project, you need to be able to demonstrate it exist | 0100 | MeshCore Open | zsylvester@monitormx.com — https://github.com/zjs81/meshcore-open | | 0110 - 011F | Ripple | ripple_biz@protonmail.com — https://buymeacoffee.com/ripplebiz | | 0120 | MCO Advanced | most.original.address@gmail.com — https://hdden.ru/MCOa/ | +| 0121 | Repeater time sync | helpme@yg.ht — https://github.com/yg-ht/ | | FF00 - FFFF | -reserved for testing/dev- | | (add rows, inside the range 0100 - FEFF for custom apps) diff --git a/docs/payloads.md b/docs/payloads.md index 21cb94696c..487b619b8c 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -252,6 +252,89 @@ The data contained in the ciphertext uses the format below: | data len | 1 | byte length of data | | data | rest of payload | (depends on data type) | +## Repeater authenticated time sync + +Repeater firmware can optionally consume authenticated time announcements from one configured group channel. Public/group channel authentication only proves knowledge of the channel secret; it does not prove which station wrote the displayed sender name. Time-sync messages therefore require an Ed25519 signature from the pinned authority public key configured on the repeater. + +`Tv1` is carried only as a binary group datagram. The earlier text-message shape is not used because a visible text message plus an Ed25519 signature does not fit the practical group-channel payload budget. + +### Binary datagram + +The group datagram `data type` is `0x0121`. Its data bytes are: + +| Field | Size | Description | +|--------------|-------------------|----------------------------------------------| +| marker | 3 | ASCII `Tv1`, exact case-sensitive marker | +| timestamp | 4 | little-endian unsigned Unix timestamp | +| sequence | 2 | little-endian unsigned 16-bit sequence | +| name length | 1 | byte length of display name | +| display name | variable | exact configured display name | +| signature | 64 | raw Ed25519 signature | + +The configured time-sync display name is limited to 20 bytes. With that limit, the `Tv1` data field is at most 94 bytes before the group-datagram type and length header. + +### Canonical signed bytes + +The signature covers this ASCII byte string, including the final newline: + +```text +MeshCore-Time-v1 + + + + +``` + +`channel-hash-hex` is the lowercase hexadecimal representation of the configured MeshCore group channel hash bytes. The display name is an exact case-sensitive filter and is not an authentication control. + +### Clock and replay policy + +Repeaters only move the clock forwards after signature verification succeeds. Timestamps below the firmware fallback epoch or above the supported upper bound are rejected. Once the clock is initialised, a forward jump greater than `time_sync.max_forward_step` is rejected. Replay state is kept in RAM as the last accepted timestamp and sequence; it is not written to flash for every received message. After reboot, the forward-only clock check still limits replay when a valid RTC value is retained. + +### Threat model + +The configured public channel is only a transport. A valid channel MAC, matching display name, or matching diagnostic fingerprint is not sender authentication. The Ed25519 signature made by the configured full 32-byte public key is the authentication control. The receiver never trusts a public key supplied in a time message. + +### Test vector + +This vector uses a test-only keypair and the hashtag channel `#time`. + +- Channel secret: first 16 bytes of `sha256("#time")`, `5d13043d9a5e61bc61aeb63208f5c64e` +- Channel hash: `e4` +- Display name: `TimeBot` +- Timestamp: `1783862400` +- Sequence: `12345` +- Public key: `1ec77175b0918ed206f9ae04ec136d6d5d4315bb26305427f645b492e9350c10` +- Private key: `7065e18fd9fabb70c1ed90dca19907de698c88b709ea146eafd93d9b830c7b60c4681193c79bbc39945ba8064104bb618f8fd7a84a0af6f57033d6e8ddcd6471` + +Canonical signed bytes: + +```text +MeshCore-Time-v1 +e4 +TimeBot +1783862400 +12345 +``` + +Expected signature, lowercase hex: + +```text +cd5adc2232022eddd70babf0c02a68c50d7b56aab5d60a81dd5dbb77b22ddfb6e406454aa6131381f90de50be08dc7fa1884894646d6e8326043c94b59cab000 +``` + +Expected `Tv1` data bytes, lowercase hex: + +```text +5476318094536a39300754696d65426f74cd5adc2232022eddd70babf0c02a68c50d7b56aab5d60a81dd5dbb77b22ddfb6e406454aa6131381f90de50be08dc7fa1884894646d6e8326043c94b59cab000 +``` + +Expected complete group-datagram data field, including little-endian `0x0121` type and one-byte data length: + +```text +2101515476318094536a39300754696d65426f74cd5adc2232022eddd70babf0c02a68c50d7b56aab5d60a81dd5dbb77b22ddfb6e406454aa6131381f90de50be08dc7fa1884894646d6e8326043c94b59cab000 +``` + # Control data diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b66e19522a..ff226b9e3d 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -60,6 +60,321 @@ #define LAZY_CONTACTS_WRITE_DELAY 5000 +static bool parseStrictHex(uint8_t* dest, size_t dest_len, const char* text) { + // Require the CLI value to be exactly one complete byte string. + if (strlen(text) != dest_len * 2) return false; + + // Validate every character before decoding so partial writes cannot occur. + for (size_t i = 0; i < dest_len; i++) { + char hi = text[i * 2]; + char lo = text[i * 2 + 1]; + if (!mesh::Utils::isHexChar(hi) || !mesh::Utils::isHexChar(lo)) return false; + } + + // Decode only after the length and character set checks have passed. + return mesh::Utils::fromHex(dest, dest_len, text); +} + +static bool parseUint32Config(const char* text, uint32_t& value) { + // Empty CLI values are rejected rather than being treated as zero. + if (text == NULL || *text == 0) return false; + + // Accumulate in uint32_t with an explicit overflow check for each digit. + uint32_t acc = 0; + while (*text) { + // Accept decimal digits only; signs and whitespace are not valid here. + if (*text < '0' || *text > '9') return false; + uint32_t digit = (uint32_t)(*text++ - '0'); + if (acc > (UINT32_MAX - digit) / 10UL) return false; + acc = acc * 10UL + digit; + } + + value = acc; + return true; +} + +bool MyMesh::isTimeSyncConfigured() const { + // The consumer needs all three trust inputs before it can be enabled. + return _prefs.time_sync_channel_name[0] != 0 + && TimeSyncAuth::isValidDisplayName(_prefs.time_sync_display_name) + && TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key); +} + +TimeSyncConfigView MyMesh::getTimeSyncConfig() const { + // Build a read-only view of persisted settings for the parser. + TimeSyncConfigView config; + + // Treat incomplete configuration as disabled even if the stored flag is set. + config.enabled = _prefs.time_sync_enabled && isTimeSyncConfigured(); + config.channel = _prefs.time_sync_channel; + config.display_name = _prefs.time_sync_display_name; + config.public_key = _prefs.time_sync_public_key; + return config; +} + +void MyMesh::resetTimeSyncReplay() { + // Replay state is deliberately RAM-only to avoid flash wear per packet. + time_sync_last_timestamp = 0; + time_sync_last_sequence = 0; + + // This flag also marks whether the fallback-clock exception has been used. + time_sync_accepted_this_boot = false; +} + +bool MyMesh::applyTimeSyncClock(uint32_t timestamp) { + // Reject timestamps outside the firmware-supported Unix range. + if (timestamp < TIME_SYNC_MIN_UNIX_TIME || timestamp > TIME_SYNC_MAX_UNIX_TIME) { + time_sync_stats.stale_timestamp++; + return false; + } + + // Read the current RTC value only after authentication has succeeded. + uint32_t current = getRTCClock()->getCurrentTime(); + + // The time-sync consumer is forward-only and never moves the clock back. + if (timestamp <= current) { + time_sync_stats.stale_timestamp++; + return false; + } + + // VolatileRTCClock and ESP32 power-on fallback both start at this epoch. + // Treat only the early fallback window as uninitialised, so a signed packet + // cannot push an already-running repeater decades forward after long uptime. + bool fallback_clock = !time_sync_accepted_this_boot + && current >= TIME_SYNC_MIN_UNIX_TIME + && current <= TIME_SYNC_MIN_UNIX_TIME + 86400UL; + + // A stored zero is repaired to the default so policy remains fail-closed. + uint32_t max_step = _prefs.time_sync_max_forward_step; + if (max_step == 0) max_step = TIME_SYNC_DEFAULT_MAX_FORWARD_STEP; + + // Once the clock is past the fallback window, limit authenticated jumps. + if (!fallback_clock && timestamp - current > max_step) { + time_sync_stats.excessive_forward_step++; + return false; + } + + // Apply the authenticated, policy-accepted timestamp to the RTC abstraction. + getRTCClock()->setCurrentTime(timestamp); + time_sync_stats.clock_updates++; + return true; +} + +void MyMesh::handleTimeSyncResult(TimeSyncResult result, const TimeSyncMessage& msg) { + // Convert parser outcomes into diagnostics before any clock policy runs. + switch (result) { + case TIME_SYNC_OK: + break; + case TIME_SYNC_DISABLED: + return; + case TIME_SYNC_DISPLAY_NAME_MISMATCH: + time_sync_stats.display_name_mismatch++; + return; + case TIME_SYNC_SIGNATURE_INVALID: + time_sync_stats.signature_invalid++; + return; + case TIME_SYNC_MALFORMED: + default: + time_sync_stats.malformed++; + return; + } + + // Sequence replay protection is scoped to this boot and configured authority. + if (time_sync_accepted_this_boot && msg.sequence <= time_sync_last_sequence) { + time_sync_stats.replayed_sequence++; + return; + } + + // Timestamp replay protection complements the forward-only RTC policy. + if (time_sync_accepted_this_boot && msg.timestamp <= time_sync_last_timestamp) { + time_sync_stats.stale_timestamp++; + return; + } + + // Update replay state only after the RTC policy accepts the message. + if (!applyTimeSyncClock(msg.timestamp)) return; + + // Record the last accepted authority message for later replay checks. + time_sync_last_timestamp = msg.timestamp; + time_sync_last_sequence = msg.sequence; + time_sync_accepted_this_boot = true; + time_sync_stats.accepted++; +} + +void MyMesh::formatTimeSyncStatus(char* reply) { + // Prefer a short fingerprint in status output over echoing the public key. + char fp[7]; + if (TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key)) { + TimeSyncAuth::fingerprintLast3Hex(fp, _prefs.time_sync_public_key); + } else { + strcpy(fp, "none"); + } + + // Keep the reply within the existing 160-byte command response buffer. + snprintf(reply, 160, + "%s ch=%s name=%s fp=%s ts=%lu seq=%u rx=%lu ok=%lu bad=%lu sig=%lu stale=%lu replay=%lu step=%lu clk=%lu", + _prefs.time_sync_enabled ? "on" : "off", + _prefs.time_sync_channel_name[0] ? _prefs.time_sync_channel_name : "-", + _prefs.time_sync_display_name[0] ? _prefs.time_sync_display_name : "-", + fp, + (unsigned long)time_sync_last_timestamp, + (unsigned int)time_sync_last_sequence, + (unsigned long)time_sync_stats.received, + (unsigned long)time_sync_stats.accepted, + (unsigned long)(time_sync_stats.malformed + time_sync_stats.display_name_mismatch), + (unsigned long)time_sync_stats.signature_invalid, + (unsigned long)time_sync_stats.stale_timestamp, + (unsigned long)time_sync_stats.replayed_sequence, + (unsigned long)time_sync_stats.excessive_forward_step, + (unsigned long)time_sync_stats.clock_updates); +} + +void MyMesh::formatTimeSyncCounters(char* reply) { + // Counters are compact so they remain usable over remote command links. + snprintf(reply, 160, + "rx=%lu ok=%lu dn=%lu mal=%lu sig=%lu stale=%lu replay=%lu step=%lu clk=%lu", + (unsigned long)time_sync_stats.received, + (unsigned long)time_sync_stats.accepted, + (unsigned long)time_sync_stats.display_name_mismatch, + (unsigned long)time_sync_stats.malformed, + (unsigned long)time_sync_stats.signature_invalid, + (unsigned long)time_sync_stats.stale_timestamp, + (unsigned long)time_sync_stats.replayed_sequence, + (unsigned long)time_sync_stats.excessive_forward_step, + (unsigned long)time_sync_stats.clock_updates); +} + +void MyMesh::handleTimeSyncCommand(char* command, char* reply) { + // Status commands do not mutate preferences or replay state. + if (strcmp(command, "time_sync.status") == 0 || strcmp(command, "get time_sync.status") == 0) { + formatTimeSyncStatus(reply); + return; + } + + // Counter output is separate from status so diagnostics can be polled cheaply. + if (strcmp(command, "time_sync.counters") == 0 || strcmp(command, "get time_sync.counters") == 0) { + formatTimeSyncCounters(reply); + return; + } + + // Read-only configuration queries follow the existing `get` command style. + if (memcmp(command, "get time_sync.", 14) == 0) { + const char* key = command + 14; + if (strcmp(key, "enabled") == 0) { + sprintf(reply, "> %s", _prefs.time_sync_enabled ? "on" : "off"); + } else if (strcmp(key, "channel") == 0) { + sprintf(reply, "> %s", _prefs.time_sync_channel_name[0] ? _prefs.time_sync_channel_name : "-"); + } else if (strcmp(key, "display_name") == 0) { + sprintf(reply, "> %s", _prefs.time_sync_display_name); + } else if (strcmp(key, "public_key") == 0) { + char fp[7]; + TimeSyncAuth::fingerprintLast3Hex(fp, _prefs.time_sync_public_key); + sprintf(reply, "> fingerprint:%s", TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key) ? fp : "none"); + } else if (strcmp(key, "max_forward_step") == 0) { + sprintf(reply, "> %lu", (unsigned long)_prefs.time_sync_max_forward_step); + } else { + strcpy(reply, "Err - unknown time_sync setting"); + } + return; + } + + // All mutating commands must use the explicit `set time_sync.` prefix. + if (memcmp(command, "set time_sync.", 14) != 0) { + strcpy(reply, "Err - bad time_sync command"); + return; + } + + // Split the command in place, matching the surrounding CLI parser style. + char* key = command + 14; + char* value = strchr(key, ' '); + if (value == NULL) { + strcpy(reply, "Err - missing value"); + return; + } + *value++ = 0; + + if (strcmp(key, "channel") == 0) { + // `public` uses MeshCore's documented default public channel secret. + if (strcmp(value, "public") == 0) { + TimeSyncAuth::configureDefaultPublicChannel(_prefs.time_sync_channel); + + // Hashtag channels are derived with the repository's existing convention. + } else if (value[0] == '#' && value[1] != 0 && strlen(value) < sizeof(_prefs.time_sync_channel_name)) { + TimeSyncAuth::configureHashtagChannel(_prefs.time_sync_channel, value); + } else { + strcpy(reply, "Err - channel must be public or #name"); + return; + } + + // Persist the operator-facing channel name alongside the derived channel. + StrHelper::strncpy(_prefs.time_sync_channel_name, value, sizeof(_prefs.time_sync_channel_name)); + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(key, "display_name") == 0) { + // Empty names and CR/LF are rejected so there is no wildcard-like sender. + if (!TimeSyncAuth::isValidDisplayName(value) || strlen(value) >= sizeof(_prefs.time_sync_display_name)) { + strcpy(reply, "Err - invalid display_name"); + return; + } + + // Authority changes reset replay state because old sequence values no longer apply. + StrHelper::strncpy(_prefs.time_sync_display_name, value, sizeof(_prefs.time_sync_display_name)); + resetTimeSyncReplay(); + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(key, "public_key") == 0) { + // Require exactly one complete 32-byte Ed25519 public key in hex. + uint8_t pubkey[PUB_KEY_SIZE]; + if (!parseStrictHex(pubkey, sizeof(pubkey), value) || !TimeSyncAuth::isValidPublicKey(pubkey)) { + strcpy(reply, "Err - invalid public_key"); + return; + } + + // Store only the public key; private signing material is never accepted. + memcpy(_prefs.time_sync_public_key, pubkey, sizeof(_prefs.time_sync_public_key)); + resetTimeSyncReplay(); + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(key, "max_forward_step") == 0) { + // Limit configured jumps to one day so mistakes cannot authorise decades. + uint32_t step; + if (!parseUint32Config(value, step) || step == 0 || step > 86400UL) { + strcpy(reply, "Err - invalid max_forward_step"); + return; + } + + // Persist the accepted policy value immediately. + _prefs.time_sync_max_forward_step = step; + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(key, "enabled") == 0) { + // Accept the same boolean spelling used by nearby repeater commands. + bool enable; + if (strcmp(value, "on") == 0) { + enable = true; + } else if (strcmp(value, "off") == 0) { + enable = false; + } else { + strcpy(reply, "Err - enabled must be on or off"); + return; + } + + // Enabling is refused unless the full pinned authority config is present. + if (enable && !isTimeSyncConfigured()) { + strcpy(reply, "Err - configure channel, display_name and public_key first"); + return; + } + + // Reset replay state whenever the consumer is toggled. + _prefs.time_sync_enabled = enable ? 1 : 0; + resetTimeSyncReplay(); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - unknown time_sync setting"); + } +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -827,6 +1142,65 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { } } +int MyMesh::searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel channels[], int max_matches) { + // Do not expose a candidate channel unless time sync is enabled and complete. + if (max_matches <= 0 || !_prefs.time_sync_enabled || !isTimeSyncConfigured()) return 0; + + // Match only the configured time-sync channel hash. + if (memcmp(hash, _prefs.time_sync_channel.hash, PATH_HASH_SIZE) != 0) return 0; + + // Return the exact configured channel so Mesh.cpp can decrypt normally. + channels[0] = _prefs.time_sync_channel; + return 1; +} + +void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) { + // Time sync does not need the packet metadata after Mesh.cpp has decrypted it. + (void)packet; + (void)channel; + + // Disabled or incomplete configuration means no local consumption attempt. + if (!_prefs.time_sync_enabled || !isTimeSyncConfigured()) return; + + // Snapshot the current authority config for this packet. + TimeSyncConfigView config = getTimeSyncConfig(); + TimeSyncMessage msg; + TimeSyncResult result = TIME_SYNC_MALFORMED; + + // Time sync is binary-only; group text messages are not parsed. + if (type != PAYLOAD_TYPE_GRP_DATA) { + time_sync_stats.received++; + time_sync_stats.malformed++; + return; + } + + // Group datagrams require a 2-byte type and 1-byte data length prefix. + if (len < 3) { + time_sync_stats.received++; + time_sync_stats.malformed++; + return; + } + + // Decode the group-datagram application type and embedded data length. + uint16_t data_type = ((uint16_t)data[0]) | (((uint16_t)data[1]) << 8); + uint8_t data_len = data[2]; + size_t available_len = len - 3; + + // Ignore every application type except the allocated repeater time sync type. + if (data_len > available_len || data_type != TIME_SYNC_BINARY_DATA_TYPE) { + time_sync_stats.received++; + time_sync_stats.malformed++; + return; + } + + // Parse and verify the inner Tv1 payload before considering clock policy. + time_sync_stats.received++; + result = TimeSyncAuth::parseAndVerifyBinary(config, &data[3], data_len, msg); + + // Applying the clock is separated so authentication and policy stay distinct. + handleTimeSyncResult(result, msg); +} + void MyMesh::sendNodeDiscoverReq() { uint8_t data[10]; data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 @@ -866,6 +1240,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _logging = false; region_load_active = false; recv_pkt_region = NULL; + // Initialise volatile diagnostics and replay state before any packets arrive. + memset(&time_sync_stats, 0, sizeof(time_sync_stats)); + resetTimeSyncReplay(); #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -893,6 +1270,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') + // Time sync is opt-in and remains disabled until all authority fields are set. + _prefs.time_sync_enabled = 0; // opt-in authenticated time sync + // Default to a conservative one-hour forward step after the clock is initialised. + _prefs.time_sync_max_forward_step = TIME_SYNC_DEFAULT_MAX_FORWARD_STEP; // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -930,6 +1311,10 @@ void MyMesh::begin(FILESYSTEM *fs) { _fs = fs; // load persisted prefs _cli.loadPrefs(_fs); + // Preserve old preference files by disabling impossible partial configs. + if (_prefs.time_sync_enabled && !isTimeSyncConfigured()) { + _prefs.time_sync_enabled = 0; + } acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); @@ -1214,6 +1599,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply command += 3; } + if (strcmp(command, "time_sync.status") == 0 || strcmp(command, "time_sync.counters") == 0 + || memcmp(command, "get time_sync.", 14) == 0 + || memcmp(command, "set time_sync.", 14) == 0) { + // Route time-sync commands before generic ACL handling consumes them. + handleTimeSyncCommand(command, reply); + return; + } + // handle ACL related commands if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8} char* hex = &command[8]; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0b2e7491b7..e8c1c0ea07 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include "RateLimiter.h" @@ -68,6 +69,20 @@ struct NeighbourInfo { int8_t snr; // multiplied by 4, user should divide to get float value }; +// Runtime-only counters for the authenticated time-sync consumer. These are +// not persisted, so receiving time beacons does not create flash wear. +struct TimeSyncStats { + uint32_t received; + uint32_t accepted; + uint32_t display_name_mismatch; + uint32_t malformed; + uint32_t signature_invalid; + uint32_t stale_timestamp; + uint32_t replayed_sequence; + uint32_t excessive_forward_step; + uint32_t clock_updates; +}; + #ifndef FIRMWARE_BUILD_DATE #define FIRMWARE_BUILD_DATE "6 Jun 2026" #endif @@ -103,6 +118,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + // Time-sync diagnostics and replay state are local to this repeater boot. + TimeSyncStats time_sync_stats; + uint32_t time_sync_last_timestamp; + uint16_t time_sync_last_sequence; + bool time_sync_accepted_this_boot; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -129,6 +149,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); + // Time-sync helpers keep configuration, parsing result handling and clock + // policy local to repeater firmware. + bool isTimeSyncConfigured() const; + TimeSyncConfigView getTimeSyncConfig() const; + void resetTimeSyncReplay(); + void handleTimeSyncResult(TimeSyncResult result, const TimeSyncMessage& msg); + bool applyTimeSyncClock(uint32_t timestamp); + void handleTimeSyncCommand(char* command, char* reply); + void formatTimeSyncStatus(char* reply); + void formatTimeSyncCounters(char* reply); protected: float getAirtimeBudgetFactor() const override { @@ -175,6 +205,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; + // These overrides add the configured time-sync channel to group lookup and + // consume only authenticated binary time-sync datagrams. + int searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel channels[], int max_matches) override; + void onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) override; void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); diff --git a/platformio.ini b/platformio.ini index 47cc0ab8c3..8112f5a59b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -159,12 +159,21 @@ lib_deps = platform = native test_framework = googletest build_flags = -std=c++17 +; UNIT_TEST selects the host-safe Ed25519 verifier path in Identity.cpp. + -DUNIT_TEST -I src -I test/mocks +; Include the vendored Ed25519 headers for deterministic time-sync vectors. + -I lib/ed25519 test_build_src = yes build_src_filter = -<*> +<../src/Utils.cpp> +<../src/Packet.cpp> +; Time-sync host tests need Identity verification and the helper under test. + +<../src/Identity.cpp> + +<../src/helpers/TimeSyncAuth.cpp> +; Link the vendored C Ed25519 implementation for native verification. + +<../lib/ed25519/*.c> lib_deps = google/googletest @ 1.17.0 diff --git a/src/Identity.cpp b/src/Identity.cpp index ea546274da..b0bd1b2079 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -2,7 +2,11 @@ #include #define ED25519_NO_SEED 1 #include +// Native unit tests do not link the Arduino Ed25519 wrapper, so keep that +// include on firmware builds only. +#ifndef UNIT_TEST #include +#endif namespace mesh { @@ -15,7 +19,10 @@ Identity::Identity(const char* pub_hex) { } bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { -#if 0 +#ifdef UNIT_TEST + // Host tests verify time-sync signatures through the vendored C Ed25519 code. + return ed25519_verify(sig, message, msg_len, pub_key); +#elif 0 // NOTE: memory corruption bug was found in this function!! return ed25519_verify(sig, message, msg_len, pub_key); #else @@ -140,4 +147,4 @@ void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_k ed25519_key_exchange(secret, other_pub_key, prv_key); } -} \ No newline at end of file +} diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c95e3e34b0..c5e13c7c44 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3,6 +3,7 @@ #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" +#include "TimeSyncAuth.h" #include #ifndef BRIDGE_MAX_BAUD @@ -93,7 +94,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + // Time-sync fields are appended so older preference files retain their + // existing offsets. Short reads leave constructor defaults in place. + file.read((uint8_t *)&_prefs->time_sync_enabled, sizeof(_prefs->time_sync_enabled)); // 295 + file.read((uint8_t *)&_prefs->time_sync_channel, sizeof(_prefs->time_sync_channel)); // 296 + file.read((uint8_t *)_prefs->time_sync_channel_name, sizeof(_prefs->time_sync_channel_name)); // 329 + file.read((uint8_t *)_prefs->time_sync_display_name, sizeof(_prefs->time_sync_display_name)); // 361 + file.read((uint8_t *)_prefs->time_sync_public_key, sizeof(_prefs->time_sync_public_key)); // 393 + file.read((uint8_t *)&_prefs->time_sync_max_forward_step, sizeof(_prefs->time_sync_max_forward_step)); // 425 + // next: 429 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -125,6 +134,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean + // Time sync remains opt-in after load; corrupt booleans are clamped. + _prefs->time_sync_enabled = constrain(_prefs->time_sync_enabled, 0, 1); // boolean + // Repair unusable clock-step policy values to the conservative default. + if (_prefs->time_sync_max_forward_step == 0 || _prefs->time_sync_max_forward_step > 86400UL) { + _prefs->time_sync_max_forward_step = TIME_SYNC_DEFAULT_MAX_FORWARD_STEP; + } + // Force C-string termination in case persisted data filled the buffers. + _prefs->time_sync_channel_name[sizeof(_prefs->time_sync_channel_name) - 1] = 0; + _prefs->time_sync_display_name[sizeof(_prefs->time_sync_display_name) - 1] = 0; file.close(); } @@ -190,7 +208,14 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + // Persist new time-sync settings only after the old preference layout. + file.write((uint8_t *)&_prefs->time_sync_enabled, sizeof(_prefs->time_sync_enabled)); // 295 + file.write((uint8_t *)&_prefs->time_sync_channel, sizeof(_prefs->time_sync_channel)); // 296 + file.write((uint8_t *)_prefs->time_sync_channel_name, sizeof(_prefs->time_sync_channel_name)); // 329 + file.write((uint8_t *)_prefs->time_sync_display_name, sizeof(_prefs->time_sync_display_name)); // 361 + file.write((uint8_t *)_prefs->time_sync_public_key, sizeof(_prefs->time_sync_public_key)); // 393 + file.write((uint8_t *)&_prefs->time_sync_max_forward_step, sizeof(_prefs->time_sync_max_forward_step)); // 425 + // next: 429 file.close(); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f3abcf4772..8d666e264c 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -65,6 +65,19 @@ struct NodePrefs { // persisted to file uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean) + // Repeater authenticated time-sync settings. These fields are appended for + // stored-preference compatibility; do not insert new persisted fields above. + uint8_t time_sync_enabled; // boolean + // Derived MeshCore group channel used only by the repeater time-sync consumer. + mesh::GroupChannel time_sync_channel; + // Operator-facing channel label retained for CLI status and persistence. + char time_sync_channel_name[32]; + // Exact case-sensitive sender display name used as a pre-filter. + char time_sync_display_name[32]; + // Pinned Ed25519 authority public key; no private key material is stored. + uint8_t time_sync_public_key[PUB_KEY_SIZE]; + // Maximum accepted forward clock step once the RTC is initialised. + uint32_t time_sync_max_forward_step; }; class CommonCLICallbacks { diff --git a/src/helpers/TimeSyncAuth.cpp b/src/helpers/TimeSyncAuth.cpp new file mode 100644 index 0000000000..6f94f7824d --- /dev/null +++ b/src/helpers/TimeSyncAuth.cpp @@ -0,0 +1,201 @@ +#include "TimeSyncAuth.h" + +#include +#include +#include +#include + +// This is the documented MeshCore default public-channel 128-bit group key. +// It is the same public key listed in docs/faq.md for the default public +// channel, decoded from base64 `izOH6cXN6mrJ5e26oRXNcg==`. +static const uint8_t DEFAULT_PUBLIC_CHANNEL_SECRET[16] = { + 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, + 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 +}; + +static char lowerHexNibble(uint8_t value) { + // Keep only the low nibble so callers can pass either half of a byte. + value &= 0x0F; + + // Return canonical lowercase ASCII hex for the nibble. + return value < 10 ? (char)('0' + value) : (char)('a' + value - 10); +} + +static void bytesToLowerHex(char* dest, const uint8_t* src, size_t len) { + // Encode every byte as two lowercase hex characters without allocating. + while (len--) { + uint8_t value = *src++; + *dest++ = lowerHexNibble(value >> 4); + *dest++ = lowerHexNibble(value); + } + + // Null terminate because all current callers use the result as C text. + *dest = 0; +} + +bool TimeSyncAuth::parseUint32Strict(const char* text, size_t len, uint32_t& value) { + // Empty numeric fields are not valid decimal numbers. + if (len == 0) return false; + + uint32_t acc = 0; + for (size_t i = 0; i < len; i++) { + // Accept decimal digits only; signs, prefixes and whitespace are rejected. + char ch = text[i]; + if (ch < '0' || ch > '9') return false; + + // Check overflow before multiplying by ten and adding the next digit. + uint32_t digit = (uint32_t)(ch - '0'); + if (acc > (UINT32_MAX - digit) / 10UL) return false; + acc = acc * 10UL + digit; + } + + // Return the parsed value only after the whole field has been validated. + value = acc; + return true; +} + +bool TimeSyncAuth::parseUint16Strict(const char* text, size_t len, uint16_t& value) { + // Parse with the wider helper first so overflow handling is centralised. + uint32_t tmp; + if (!parseUint32Strict(text, len, tmp) || tmp > UINT16_MAX) return false; + + // Narrow only after proving the value fits the wire sequence field. + value = (uint16_t)tmp; + return true; +} + +bool TimeSyncAuth::buildCanonical(char* dest, size_t dest_len, const mesh::GroupChannel& channel, + const char* display_name, uint32_t timestamp, uint16_t sequence) { + // Bind the signature to the actual configured channel hash, not only text. + char channel_hash_hex[PATH_HASH_SIZE * 2 + 1]; + bytesToLowerHex(channel_hash_hex, channel.hash, PATH_HASH_SIZE); + + // Build the exact ASCII bytes that both sender and receiver must sign. + int written = snprintf(dest, dest_len, "%s\n%s\n%s\n%lu\n%u\n", + TIME_SYNC_CANONICAL_DOMAIN, + channel_hash_hex, + display_name, + (unsigned long)timestamp, + (unsigned int)sequence); + + // Reject truncation so a shortened canonical string is never verified. + return written > 0 && (size_t)written < dest_len; +} + +bool TimeSyncAuth::verifyParsed(const TimeSyncConfigView& config, uint32_t timestamp, uint16_t sequence, + const uint8_t signature[SIGNATURE_SIZE]) { + // Reconstruct the canonical message from local configuration and parsed fields. + char canonical[TIME_SYNC_CANONICAL_MAX_LEN]; + if (!buildCanonical(canonical, sizeof(canonical), config.channel, config.display_name, timestamp, sequence)) { + return false; + } + + // Verify against the exact pinned authority public key from configuration. + mesh::Identity authority(config.public_key); + return authority.verify(signature, (const uint8_t*)canonical, strlen(canonical)); +} + +bool TimeSyncAuth::isValidDisplayName(const char* name) { + // Require one explicit display name; empty names would act like wildcards. + if (name == NULL || name[0] == 0) return false; + + // Enforce signed-byte safety and the binary payload length budget. + size_t len = 0; + while (name[len]) { + if (name[len] == '\r' || name[len] == '\n') return false; + len++; + if (len > TIME_SYNC_MAX_DISPLAY_NAME_LEN) return false; + } + + return true; +} + +bool TimeSyncAuth::isValidPublicKey(const uint8_t key[PUB_KEY_SIZE]) { + // Track the two wildcard-like public keys that must never be accepted. + bool any_non_zero = false; + bool any_not_ff = false; + + // Inspect the full 32-byte key; no prefix matching is allowed here. + for (size_t i = 0; i < PUB_KEY_SIZE; i++) { + if (key[i] != 0x00) any_non_zero = true; + if (key[i] != 0xFF) any_not_ff = true; + } + + // Accept only keys that are neither all-zero nor all-0xFF. + return any_non_zero && any_not_ff; +} + +void TimeSyncAuth::fingerprintLast3Hex(char dest[7], const uint8_t key[PUB_KEY_SIZE]) { + // Hash the full public key before deriving the short diagnostic fingerprint. + uint8_t digest[32]; + mesh::Utils::sha256(digest, sizeof(digest), key, PUB_KEY_SIZE); + + // Expose only the last three digest bytes, encoded as six lowercase hex chars. + bytesToLowerHex(dest, &digest[sizeof(digest) - 3], 3); +} + +void TimeSyncAuth::configureHashtagChannel(mesh::GroupChannel& dest, const char* name) { + // Start from a known empty channel so unused secret bytes are zeroed. + memset(&dest, 0, sizeof(dest)); + + // MeshCore hashtag channels use the first 16 bytes of SHA-256("#name"). + uint8_t digest[32]; + mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)name, strlen(name)); + memcpy(dest.secret, digest, 16); + + // MeshCore channel matching uses the hash of the shared channel secret. + mesh::Utils::sha256(dest.hash, sizeof(dest.hash), dest.secret, 16); +} + +void TimeSyncAuth::configureDefaultPublicChannel(mesh::GroupChannel& dest) { + // Start from a known empty channel so unused secret bytes are zeroed. + memset(&dest, 0, sizeof(dest)); + + // Copy the documented default public-channel secret into the group channel. + memcpy(dest.secret, DEFAULT_PUBLIC_CHANNEL_SECRET, sizeof(DEFAULT_PUBLIC_CHANNEL_SECRET)); + + // MeshCore channel matching uses the hash of the shared channel secret. + mesh::Utils::sha256(dest.hash, sizeof(dest.hash), dest.secret, sizeof(DEFAULT_PUBLIC_CHANNEL_SECRET)); +} + +TimeSyncResult TimeSyncAuth::parseAndVerifyBinary(const TimeSyncConfigView& config, const uint8_t* data, + size_t data_len, TimeSyncMessage& msg) { + // Fail closed before parsing if the feature or required authority config is absent. + if (!config.enabled) return TIME_SYNC_DISABLED; + if (!isValidDisplayName(config.display_name) || !isValidPublicKey(config.public_key)) return TIME_SYNC_DISABLED; + + // Enforce the minimum fixed fields plus the 64-byte raw signature. + if (data_len < 3 + 4 + 2 + 1 + SIGNATURE_SIZE) return TIME_SYNC_MALFORMED; + + // Require the exact binary marker before consuming binary numeric fields. + if (memcmp(data, TIME_SYNC_BINARY_MARKER, 3) != 0) return TIME_SYNC_MALFORMED; + + // Decode the little-endian Unix timestamp from the binary payload. + size_t pos = 3; + uint32_t timestamp = ((uint32_t)data[pos]) | + ((uint32_t)data[pos + 1] << 8) | + ((uint32_t)data[pos + 2] << 16) | + ((uint32_t)data[pos + 3] << 24); + pos += 4; + + // Decode the little-endian uint16 monotonic sequence number. + uint16_t sequence = ((uint16_t)data[pos]) | ((uint16_t)data[pos + 1] << 8); + pos += 2; + + // Validate and compare the exact display-name field before signature work. + uint8_t display_len = data[pos++]; + if (display_len == 0 || display_len > TIME_SYNC_MAX_DISPLAY_NAME_LEN) return TIME_SYNC_MALFORMED; + if (pos + display_len + SIGNATURE_SIZE != data_len) return TIME_SYNC_MALFORMED; + if (strlen(config.display_name) != display_len || memcmp(&data[pos], config.display_name, display_len) != 0) { + return TIME_SYNC_DISPLAY_NAME_MISMATCH; + } + pos += display_len; + + // Authenticate the canonical fields using the raw signature at the tail. + if (!verifyParsed(config, timestamp, sequence, &data[pos])) return TIME_SYNC_SIGNATURE_INVALID; + + // Return the authenticated timestamp and sequence to the repeater policy code. + msg.timestamp = timestamp; + msg.sequence = sequence; + return TIME_SYNC_OK; +} diff --git a/src/helpers/TimeSyncAuth.h b/src/helpers/TimeSyncAuth.h new file mode 100644 index 0000000000..fd8db5481d --- /dev/null +++ b/src/helpers/TimeSyncAuth.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include + +// Wire marker and signature domain are deliberately separate: the marker +// identifies the binary payload, while the domain prevents cross-protocol +// signature reuse. +#define TIME_SYNC_BINARY_MARKER "Tv1" +#define TIME_SYNC_CANONICAL_DOMAIN "MeshCore-Time-v1" + +// Group datagram application type reserved in docs/number_allocations.md. +#define TIME_SYNC_BINARY_DATA_TYPE 0x0121 + +// Clock policy bounds keep malformed or far-future authenticated packets from +// placing unattended repeaters outside the firmware-supported Unix range. +#define TIME_SYNC_MIN_UNIX_TIME 1715770351UL +#define TIME_SYNC_MAX_UNIX_TIME 2147483647UL +#define TIME_SYNC_DEFAULT_MAX_FORWARD_STEP 3600UL + +// Display names are included in the signed bytes and on the binary wire. The +// limit keeps the datagram comfortably below the practical group payload size. +#define TIME_SYNC_MAX_DISPLAY_NAME_LEN 20 +#define TIME_SYNC_CANONICAL_MAX_LEN 128 + +// Lightweight view over persisted repeater settings. It avoids copying the +// pinned public key into packet-processing code. +struct TimeSyncConfigView { + bool enabled; + mesh::GroupChannel channel; + const char* display_name; + const uint8_t* public_key; +}; + +// Authenticated fields returned to the repeater clock policy after signature +// verification succeeds. +struct TimeSyncMessage { + uint32_t timestamp; + uint16_t sequence; +}; + +// Parser outcomes are intentionally coarse so the repeater can count failures +// without logging sensitive packet material. +enum TimeSyncResult { + TIME_SYNC_OK, + TIME_SYNC_DISABLED, + TIME_SYNC_DISPLAY_NAME_MISMATCH, + TIME_SYNC_MALFORMED, + TIME_SYNC_SIGNATURE_INVALID +}; + +class TimeSyncAuth { + // Strict decimal helpers are retained for canonical rendering symmetry and + // any future CLI/test parsing, but packet parsing itself is binary-only. + static bool parseUint32Strict(const char* text, size_t len, uint32_t& value); + static bool parseUint16Strict(const char* text, size_t len, uint16_t& value); + + // Canonical signing helper: this is the only byte string accepted by the + // verifier for a parsed timestamp/sequence/display-name/channel tuple. + static bool buildCanonical(char* dest, size_t dest_len, const mesh::GroupChannel& channel, + const char* display_name, uint32_t timestamp, uint16_t sequence); + static bool verifyParsed(const TimeSyncConfigView& config, uint32_t timestamp, uint16_t sequence, + const uint8_t signature[SIGNATURE_SIZE]); + +public: + // Configuration validators fail closed so wildcard-like setup cannot enable + // the consumer. + static bool isValidDisplayName(const char* name); + static bool isValidPublicKey(const uint8_t key[PUB_KEY_SIZE]); + + // Status helpers expose only short diagnostics, never private key material. + static void fingerprintLast3Hex(char dest[7], const uint8_t key[PUB_KEY_SIZE]); + + // Channel construction mirrors existing MeshCore group-channel conventions. + static void configureHashtagChannel(mesh::GroupChannel& dest, const char* name); + static void configureDefaultPublicChannel(mesh::GroupChannel& dest); + + // Binary-only parser for PAYLOAD_TYPE_GRP_DATA application type 0x0121. + static TimeSyncResult parseAndVerifyBinary(const TimeSyncConfigView& config, const uint8_t* data, + size_t data_len, TimeSyncMessage& msg); +}; diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 195a302973..769fcad82d 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -7,4 +7,18 @@ class Stream { public: virtual void print(char c) {} virtual void print(const char* str) {} + // Identity.cpp includes stream serialisation helpers, so native tests need + // no-op Stream methods even though time-sync tests do not exercise I/O. + virtual void println() {} + virtual unsigned int readBytes(unsigned char* buffer, unsigned int length) { + // Return zero bytes read to model an empty mock stream. + (void)buffer; + (void)length; + return 0; + } + virtual unsigned int write(const unsigned char* buffer, unsigned int length) { + // Report a successful write so Identity::writeTo can compile and link. + (void)buffer; + return length; + } }; diff --git a/test/test_time_sync_auth/test_time_sync_auth.cpp b/test/test_time_sync_auth/test_time_sync_auth.cpp new file mode 100644 index 0000000000..9fada1345b --- /dev/null +++ b/test/test_time_sync_auth/test_time_sync_auth.cpp @@ -0,0 +1,216 @@ +#include + +#include +#include +#include +#include + +// Fixed test-only Ed25519 keypair used for deterministic vectors. This is not +// production signing material. +static const uint8_t TEST_PRIVATE_KEY[64] = { + 0x70, 0x65, 0xe1, 0x8f, 0xd9, 0xfa, 0xbb, 0x70, + 0xc1, 0xed, 0x90, 0xdc, 0xa1, 0x99, 0x07, 0xde, + 0x69, 0x8c, 0x88, 0xb7, 0x09, 0xea, 0x14, 0x6e, + 0xaf, 0xd9, 0x3d, 0x9b, 0x83, 0x0c, 0x7b, 0x60, + 0xc4, 0x68, 0x11, 0x93, 0xc7, 0x9b, 0xbc, 0x39, + 0x94, 0x5b, 0xa8, 0x06, 0x41, 0x04, 0xbb, 0x61, + 0x8f, 0x8f, 0xd7, 0xa8, 0x4a, 0x0a, 0xf6, 0xf5, + 0x70, 0x33, 0xd6, 0xe8, 0xdd, 0xcd, 0x64, 0x71 +}; + +static const uint8_t TEST_PUBLIC_KEY[32] = { + 0x1e, 0xc7, 0x71, 0x75, 0xb0, 0x91, 0x8e, 0xd2, + 0x06, 0xf9, 0xae, 0x04, 0xec, 0x13, 0x6d, 0x6d, + 0x5d, 0x43, 0x15, 0xbb, 0x26, 0x30, 0x54, 0x27, + 0xf6, 0x45, 0xb4, 0x92, 0xe9, 0x35, 0x0c, 0x10 +}; + +static void bytesToLowerHex(char* dest, const uint8_t* src, size_t len) { + // Keep vector generation independent from firmware helper visibility. + static const char hex[] = "0123456789abcdef"; + + // Encode each byte into two lowercase hexadecimal characters. + for (size_t i = 0; i < len; i++) { + dest[i * 2] = hex[src[i] >> 4]; + dest[i * 2 + 1] = hex[src[i] & 0x0F]; + } + + // Null terminate because the canonical builder treats the hash as C text. + dest[len * 2] = 0; +} + +static void signMessage(const mesh::GroupChannel& channel, const char* display_name, + uint32_t timestamp, uint16_t sequence, uint8_t signature[SIGNATURE_SIZE]) { + // Convert the configured channel hash to the canonical ASCII field. + char channel_hash[PATH_HASH_SIZE * 2 + 1]; + char canonical[TIME_SYNC_CANONICAL_MAX_LEN]; + bytesToLowerHex(channel_hash, channel.hash, PATH_HASH_SIZE); + + // Build the exact byte string documented in docs/payloads.md. + snprintf(canonical, sizeof(canonical), "%s\n%s\n%s\n%lu\n%u\n", + TIME_SYNC_CANONICAL_DOMAIN, + channel_hash, + display_name, + (unsigned long)timestamp, + (unsigned int)sequence); + + // Sign with the fixed test key so expected vectors remain stable. + ed25519_sign(signature, (const uint8_t*)canonical, strlen(canonical), TEST_PUBLIC_KEY, TEST_PRIVATE_KEY); +} + +static TimeSyncConfigView makeConfig(mesh::GroupChannel& channel) { + // Use the documented hashtag channel derivation for the shared test channel. + TimeSyncAuth::configureHashtagChannel(channel, "#time"); + + // Return the minimum complete authority configuration required by the parser. + TimeSyncConfigView config; + config.enabled = true; + config.channel = channel; + config.display_name = "TimeBot"; + config.public_key = TEST_PUBLIC_KEY; + return config; +} + +static size_t makeBinary(uint8_t* dest, const mesh::GroupChannel& channel, uint32_t timestamp, + uint16_t sequence, const char* payload_name, const char* signed_name) { + // The caller can intentionally make payload_name and signed_name differ to + // prove that the signature binds the display-name field. + uint8_t signature[SIGNATURE_SIZE]; + size_t pos = 0; + size_t name_len = strlen(payload_name); + + // Sign before serialising so mutation tests can alter individual wire bytes. + signMessage(channel, signed_name, timestamp, sequence, signature); + + // Write the fixed binary marker at the start of the Tv1 data field. + memcpy(&dest[pos], TIME_SYNC_BINARY_MARKER, 3); pos += 3; + + // Encode the timestamp in little-endian wire order. + dest[pos++] = timestamp & 0xFF; + dest[pos++] = (timestamp >> 8) & 0xFF; + dest[pos++] = (timestamp >> 16) & 0xFF; + dest[pos++] = (timestamp >> 24) & 0xFF; + + // Encode the 16-bit monotonic sequence in little-endian wire order. + dest[pos++] = sequence & 0xFF; + dest[pos++] = (sequence >> 8) & 0xFF; + + // Store the exact display-name bytes that the receiver must compare. + dest[pos++] = (uint8_t)name_len; + memcpy(&dest[pos], payload_name, name_len); pos += name_len; + + // Append the raw Ed25519 signature; binary transport avoids Base64 overhead. + memcpy(&dest[pos], signature, sizeof(signature)); pos += sizeof(signature); + + // Return the data-field length for parseAndVerifyBinary(). + return pos; +} + +TEST(TimeSyncAuth, RejectsInvalidDisplayNames) { + // Empty and newline-bearing display names cannot be configured safely. + EXPECT_FALSE(TimeSyncAuth::isValidDisplayName("")); + EXPECT_FALSE(TimeSyncAuth::isValidDisplayName("Time\nBot")); + EXPECT_TRUE(TimeSyncAuth::isValidDisplayName("TimeBot")); +} + +TEST(TimeSyncAuth, RejectsWildcardLikePublicKeys) { + // All-zero keys would act like a placeholder or wildcard and are refused. + uint8_t key[PUB_KEY_SIZE]; + memset(key, 0, sizeof(key)); + EXPECT_FALSE(TimeSyncAuth::isValidPublicKey(key)); + + // All-0xFF keys are also refused for the same wildcard-like reason. + memset(key, 0xFF, sizeof(key)); + EXPECT_FALSE(TimeSyncAuth::isValidPublicKey(key)); + + // A real fixed test public key is accepted. + EXPECT_TRUE(TimeSyncAuth::isValidPublicKey(TEST_PUBLIC_KEY)); +} + +TEST(TimeSyncAuth, AcceptsValidBinaryMessage) { + // A payload signed by the configured authority should authenticate cleanly. + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_OK, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); + EXPECT_EQ(1783862400UL, msg.timestamp); + EXPECT_EQ(12345, msg.sequence); +} + +TEST(TimeSyncAuth, RejectsWrongDisplayNameAndPrefix) { + // Display-name matching is exact; a shorter prefix must not pass. + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + uint8_t data[96]; + + TimeSyncMessage msg; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "Time", "Time"); + EXPECT_EQ(TIME_SYNC_DISPLAY_NAME_MISMATCH, + TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); + + // A longer name with the configured name as a prefix must not pass either. + data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBotX", "TimeBotX"); + EXPECT_EQ(TIME_SYNC_DISPLAY_NAME_MISMATCH, + TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); +} + +TEST(TimeSyncAuth, RejectsChangedSignedField) { + // Mutating a signed field after signing must invalidate the signature. + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + data[3] ^= 0x01; + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); +} + +TEST(TimeSyncAuth, RejectsMalformedBinaryPayloads) { + // Malformed payload tests cover truncation, wrong marker and bad length. + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + TimeSyncMessage msg; + + EXPECT_EQ(TIME_SYNC_MALFORMED, TimeSyncAuth::parseAndVerifyBinary(config, data, 2, msg)); + + data[0] = 'X'; + EXPECT_EQ(TIME_SYNC_MALFORMED, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); + + data[0] = 'T'; + EXPECT_EQ(TIME_SYNC_MALFORMED, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len - 1, msg)); +} + +TEST(TimeSyncAuth, RejectsForgedSignature) { + // Flipping a signature byte proves matching structure alone is insufficient. + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + data[data_len - 1] ^= 0x01; + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); +} + +TEST(TimeSyncAuth, RejectsSignatureFromAnotherDisplayName) { + // The display name is included in the canonical signed bytes, so a signature + // over a different name cannot authenticate the configured sender. + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "OtherBot"); + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 5e690dd3bfe926a6630d14ba5a687ae3ce86993e Mon Sep 17 00:00:00 2001 From: yg-ht Date: Sun, 12 Jul 2026 16:48:27 +0100 Subject: [PATCH 2/5] Fix time sync replay reconfiguration --- docs/payloads.md | 2 +- examples/simple_repeater/MyMesh.cpp | 36 +++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index 487b619b8c..991d0416af 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -289,7 +289,7 @@ MeshCore-Time-v1 ### Clock and replay policy -Repeaters only move the clock forwards after signature verification succeeds. Timestamps below the firmware fallback epoch or above the supported upper bound are rejected. Once the clock is initialised, a forward jump greater than `time_sync.max_forward_step` is rejected. Replay state is kept in RAM as the last accepted timestamp and sequence; it is not written to flash for every received message. After reboot, the forward-only clock check still limits replay when a valid RTC value is retained. +Repeaters only move the clock forwards after signature verification succeeds. Timestamps below the firmware fallback epoch or above the supported upper bound are rejected. Once the clock is initialised, a forward jump greater than `time_sync.max_forward_step` is rejected. Replay state is kept in RAM as the last accepted timestamp and sequence; it is not written to flash for every received message. A newer timestamp is allowed even when the 16-bit sequence has wrapped or restarted, while equal timestamps are rejected and non-advancing equal-timestamp sequences are counted as replay. After reboot, the forward-only clock check still limits replay when a valid RTC value is retained. ### Threat model diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ff226b9e3d..7f4db24ca8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -93,6 +93,14 @@ static bool parseUint32Config(const char* text, uint32_t& value) { return true; } +static bool isWrappedSequenceNewer(uint16_t previous, uint16_t current) { + // Treat sequence numbers as a wrapping uint16 space. + uint16_t delta = (uint16_t)(current - previous); + + // Equal is not newer, and deltas in the upper half are older values. + return delta != 0 && delta < 0x8000; +} + bool MyMesh::isTimeSyncConfigured() const { // The consumer needs all three trust inputs before it can be enabled. return _prefs.time_sync_channel_name[0] != 0 @@ -179,16 +187,23 @@ void MyMesh::handleTimeSyncResult(TimeSyncResult result, const TimeSyncMessage& return; } - // Sequence replay protection is scoped to this boot and configured authority. - if (time_sync_accepted_this_boot && msg.sequence <= time_sync_last_sequence) { - time_sync_stats.replayed_sequence++; - return; - } + if (time_sync_accepted_this_boot) { + // Older timestamps are stale regardless of sequence value. + if (msg.timestamp < time_sync_last_timestamp) { + time_sync_stats.stale_timestamp++; + return; + } - // Timestamp replay protection complements the forward-only RTC policy. - if (time_sync_accepted_this_boot && msg.timestamp <= time_sync_last_timestamp) { - time_sync_stats.stale_timestamp++; - return; + // Equal timestamps cannot move the RTC forward, but classify non-advancing + // sequence values as replay diagnostics using uint16 wrap-aware ordering. + if (msg.timestamp == time_sync_last_timestamp) { + if (!isWrappedSequenceNewer(time_sync_last_sequence, msg.sequence)) { + time_sync_stats.replayed_sequence++; + } else { + time_sync_stats.stale_timestamp++; + } + return; + } } // Update replay state only after the RTC policy accepts the message. @@ -308,6 +323,9 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { // Persist the operator-facing channel name alongside the derived channel. StrHelper::strncpy(_prefs.time_sync_channel_name, value, sizeof(_prefs.time_sync_channel_name)); + // Channel changes alter the signed authority scope, so old replay values + // must not block a valid feed on the newly configured channel. + resetTimeSyncReplay(); savePrefs(); strcpy(reply, "OK"); } else if (strcmp(key, "display_name") == 0) { From 8fb1c835bbdea542e31fa9634ce4e6cd8cb25368 Mon Sep 17 00:00:00 2001 From: yg-ht Date: Sun, 12 Jul 2026 17:31:22 +0100 Subject: [PATCH 3/5] Bind time sync signatures to channel identity --- docs/payloads.md | 13 +++++---- src/helpers/TimeSyncAuth.cpp | 12 +++++--- .../test_time_sync_auth.cpp | 29 +++++++++++++++---- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index 991d0416af..bb9cebaaff 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -279,13 +279,13 @@ The signature covers this ASCII byte string, including the final newline: ```text MeshCore-Time-v1 - + ``` -`channel-hash-hex` is the lowercase hexadecimal representation of the configured MeshCore group channel hash bytes. The display name is an exact case-sensitive filter and is not an authentication control. +`channel-id-hex` is the lowercase hexadecimal SHA-256 digest of the configured raw 16-byte MeshCore group channel secret. It is used instead of the one-byte MeshCore channel hash so signatures are bound to a collision-resistant channel identity. The display name is an exact case-sensitive filter and is not an authentication control. ### Clock and replay policy @@ -301,6 +301,7 @@ This vector uses a test-only keypair and the hashtag channel `#time`. - Channel secret: first 16 bytes of `sha256("#time")`, `5d13043d9a5e61bc61aeb63208f5c64e` - Channel hash: `e4` +- Channel ID: `e4235ac672871e72d2aeaf8654e0a39893de9fca7c06ccb5c3a788f95a6aeada` - Display name: `TimeBot` - Timestamp: `1783862400` - Sequence: `12345` @@ -311,7 +312,7 @@ Canonical signed bytes: ```text MeshCore-Time-v1 -e4 +e4235ac672871e72d2aeaf8654e0a39893de9fca7c06ccb5c3a788f95a6aeada TimeBot 1783862400 12345 @@ -320,19 +321,19 @@ TimeBot Expected signature, lowercase hex: ```text -cd5adc2232022eddd70babf0c02a68c50d7b56aab5d60a81dd5dbb77b22ddfb6e406454aa6131381f90de50be08dc7fa1884894646d6e8326043c94b59cab000 +c9e0785bc99e910c813b8984df76c45cab7b1b6157594cb710b457eefedd0ede66f568cbdfc117fb57266283b9d5bbe3d4ac5d331bbd44b190c14e35bd712108 ``` Expected `Tv1` data bytes, lowercase hex: ```text -5476318094536a39300754696d65426f74cd5adc2232022eddd70babf0c02a68c50d7b56aab5d60a81dd5dbb77b22ddfb6e406454aa6131381f90de50be08dc7fa1884894646d6e8326043c94b59cab000 +5476318094536a39300754696d65426f74c9e0785bc99e910c813b8984df76c45cab7b1b6157594cb710b457eefedd0ede66f568cbdfc117fb57266283b9d5bbe3d4ac5d331bbd44b190c14e35bd712108 ``` Expected complete group-datagram data field, including little-endian `0x0121` type and one-byte data length: ```text -2101515476318094536a39300754696d65426f74cd5adc2232022eddd70babf0c02a68c50d7b56aab5d60a81dd5dbb77b22ddfb6e406454aa6131381f90de50be08dc7fa1884894646d6e8326043c94b59cab000 +2101515476318094536a39300754696d65426f74c9e0785bc99e910c813b8984df76c45cab7b1b6157594cb710b457eefedd0ede66f568cbdfc117fb57266283b9d5bbe3d4ac5d331bbd44b190c14e35bd712108 ``` diff --git a/src/helpers/TimeSyncAuth.cpp b/src/helpers/TimeSyncAuth.cpp index 6f94f7824d..ab3b55ef51 100644 --- a/src/helpers/TimeSyncAuth.cpp +++ b/src/helpers/TimeSyncAuth.cpp @@ -66,14 +66,18 @@ bool TimeSyncAuth::parseUint16Strict(const char* text, size_t len, uint16_t& val bool TimeSyncAuth::buildCanonical(char* dest, size_t dest_len, const mesh::GroupChannel& channel, const char* display_name, uint32_t timestamp, uint16_t sequence) { - // Bind the signature to the actual configured channel hash, not only text. - char channel_hash_hex[PATH_HASH_SIZE * 2 + 1]; - bytesToLowerHex(channel_hash_hex, channel.hash, PATH_HASH_SIZE); + // Bind the signature to a collision-resistant identifier for the configured + // channel. PATH_HASH_SIZE is only one byte, so it is not strong enough for + // signature scope; use SHA-256 over the full raw channel secret instead. + uint8_t channel_id[32]; + char channel_id_hex[sizeof(channel_id) * 2 + 1]; + mesh::Utils::sha256(channel_id, sizeof(channel_id), channel.secret, 16); + bytesToLowerHex(channel_id_hex, channel_id, sizeof(channel_id)); // Build the exact ASCII bytes that both sender and receiver must sign. int written = snprintf(dest, dest_len, "%s\n%s\n%s\n%lu\n%u\n", TIME_SYNC_CANONICAL_DOMAIN, - channel_hash_hex, + channel_id_hex, display_name, (unsigned long)timestamp, (unsigned int)sequence); diff --git a/test/test_time_sync_auth/test_time_sync_auth.cpp b/test/test_time_sync_auth/test_time_sync_auth.cpp index 9fada1345b..bee43f9a8b 100644 --- a/test/test_time_sync_auth/test_time_sync_auth.cpp +++ b/test/test_time_sync_auth/test_time_sync_auth.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -35,21 +36,23 @@ static void bytesToLowerHex(char* dest, const uint8_t* src, size_t len) { dest[i * 2 + 1] = hex[src[i] & 0x0F]; } - // Null terminate because the canonical builder treats the hash as C text. + // Null terminate because the canonical builder treats the channel ID as C text. dest[len * 2] = 0; } static void signMessage(const mesh::GroupChannel& channel, const char* display_name, uint32_t timestamp, uint16_t sequence, uint8_t signature[SIGNATURE_SIZE]) { - // Convert the configured channel hash to the canonical ASCII field. - char channel_hash[PATH_HASH_SIZE * 2 + 1]; + // Convert the full configured channel identity to the canonical ASCII field. + uint8_t channel_id[32]; + char channel_id_hex[sizeof(channel_id) * 2 + 1]; char canonical[TIME_SYNC_CANONICAL_MAX_LEN]; - bytesToLowerHex(channel_hash, channel.hash, PATH_HASH_SIZE); + mesh::Utils::sha256(channel_id, sizeof(channel_id), channel.secret, 16); + bytesToLowerHex(channel_id_hex, channel_id, sizeof(channel_id)); // Build the exact byte string documented in docs/payloads.md. snprintf(canonical, sizeof(canonical), "%s\n%s\n%s\n%lu\n%u\n", TIME_SYNC_CANONICAL_DOMAIN, - channel_hash, + channel_id_hex, display_name, (unsigned long)timestamp, (unsigned int)sequence); @@ -210,6 +213,22 @@ TEST(TimeSyncAuth, RejectsSignatureFromAnotherDisplayName) { EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); } +TEST(TimeSyncAuth, RejectsSignatureFromAnotherChannel) { + // The signature is scoped to the configured channel identity, not only the + // display name, timestamp and sequence. + mesh::GroupChannel configured_channel; + TimeSyncConfigView config = makeConfig(configured_channel); + + // Sign the wire fields for a different hashtag channel. + mesh::GroupChannel other_channel; + TimeSyncAuth::configureHashtagChannel(other_channel, "#other-time"); + uint8_t data[96]; + size_t data_len = makeBinary(data, other_channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 0a0217258bec834049e64d3245734f2c93bb3506 Mon Sep 17 00:00:00 2001 From: yg-ht Date: Mon, 13 Jul 2026 02:02:34 +0100 Subject: [PATCH 4/5] Allow time sync max forward step reset to default --- docs/cli_commands.md | 1 + examples/simple_repeater/MyMesh.cpp | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 67aebf4ea9..de6c73072b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -414,6 +414,7 @@ Configuration: - `set time_sync.display_name ` - `set time_sync.public_key <64-hex-ed25519-public-key>` - `set time_sync.max_forward_step ` +- `set time_sync.max_forward_step` to restore the default 3600-second limit - `set time_sync.enabled on|off` Status: diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7f4db24ca8..335197072b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -302,11 +302,15 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { // Split the command in place, matching the surrounding CLI parser style. char* key = command + 14; char* value = strchr(key, ' '); - if (value == NULL) { + if (value != NULL) { + // Terminate the key in place so each setting branch can inspect only its + // own value. Commands without a value are handled per setting below. + *value++ = 0; + } + if ((value == NULL || *value == 0) && strcmp(key, "max_forward_step") != 0) { strcpy(reply, "Err - missing value"); return; } - *value++ = 0; if (strcmp(key, "channel") == 0) { // `public` uses MeshCore's documented default public channel secret. @@ -354,9 +358,16 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { savePrefs(); strcpy(reply, "OK"); } else if (strcmp(key, "max_forward_step") == 0) { + // Omitting the value restores the firmware default, currently one hour. + // This gives operators a safe recovery path without remembering the number. + uint32_t step = TIME_SYNC_DEFAULT_MAX_FORWARD_STEP; + if (value != NULL && *value != 0 && !parseUint32Config(value, step)) { + strcpy(reply, "Err - invalid max_forward_step"); + return; + } + // Limit configured jumps to one day so mistakes cannot authorise decades. - uint32_t step; - if (!parseUint32Config(value, step) || step == 0 || step > 86400UL) { + if (step == 0 || step > 86400UL) { strcpy(reply, "Err - invalid max_forward_step"); return; } @@ -366,6 +377,11 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { savePrefs(); strcpy(reply, "OK"); } else if (strcmp(key, "enabled") == 0) { + if (value == NULL || *value == 0) { + strcpy(reply, "Err - missing value"); + return; + } + // Accept the same boolean spelling used by nearby repeater commands. bool enable; if (strcmp(value, "on") == 0) { From 20aef62575ac605efc964f7f215062aefa7f28a7 Mon Sep 17 00:00:00 2001 From: yg-ht Date: Mon, 13 Jul 2026 16:17:30 +0100 Subject: [PATCH 5/5] Add multi-source repeater time-sync validation - support multiple configured time-sync authorities - persist additional authority slots after the legacy preference fields - move repeater receive policy into a host-testable consumer helper - ignore non-time-sync group datagrams without counting them malformed - add source-slot CLI configuration and validation helpers - remove unused strict numeric parsing helpers - expand native tests for parser, replay, clock policy, counters, persistence, channel lookup and CLI source settings - update repeater time-sync documentation --- docs/cli_commands.md | 9 +- docs/payloads.md | 8 +- examples/simple_repeater/MyMesh.cpp | 316 ++++++------ examples/simple_repeater/MyMesh.h | 26 +- platformio.ini | 1 + src/helpers/CommonCLI.cpp | 19 +- src/helpers/CommonCLI.h | 5 + src/helpers/TimeSyncAuth.cpp | 158 ++++-- src/helpers/TimeSyncAuth.h | 52 +- src/helpers/TimeSyncConsumer.cpp | 186 +++++++ src/helpers/TimeSyncConsumer.h | 51 ++ src/helpers/TimeSyncPrefsLayout.h | 22 + .../test_time_sync_auth.cpp | 482 ++++++++++++++++++ 13 files changed, 1124 insertions(+), 211 deletions(-) create mode 100644 src/helpers/TimeSyncConsumer.cpp create mode 100644 src/helpers/TimeSyncConsumer.h create mode 100644 src/helpers/TimeSyncPrefsLayout.h diff --git a/docs/cli_commands.md b/docs/cli_commands.md index de6c73072b..38e9ef3be5 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -413,6 +413,9 @@ Configuration: - `set time_sync.channel #time` - `set time_sync.display_name ` - `set time_sync.public_key <64-hex-ed25519-public-key>` +- `set time_sync.source..display_name ` +- `set time_sync.source..public_key <64-hex-ed25519-public-key>` +- `set time_sync.source..clear yes` - `set time_sync.max_forward_step ` - `set time_sync.max_forward_step` to restore the default 3600-second limit - `set time_sync.enabled on|off` @@ -425,11 +428,13 @@ Status: - `get time_sync.channel` - `get time_sync.display_name` - `get time_sync.public_key` +- `get time_sync.source.` +- `get time_sync.sources` - `get time_sync.max_forward_step` -`set time_sync.enabled on` fails unless the channel, exact display name and complete public key are already configured. The public channel is transport only: the channel MAC and displayed sender name do not prove sender identity. The repeater verifies each accepted `Tv1` message with the pinned 32-byte Ed25519 public key. +`set time_sync.enabled on` fails unless the channel and at least one complete source are configured. The original `time_sync.display_name` and `time_sync.public_key` commands configure source `0`; source slots `1` to `3` are configured through `time_sync.source..*`. The public channel is transport only: the channel MAC and displayed sender name do not prove sender identity. The repeater verifies each accepted `Tv1` message with the pinned 32-byte Ed25519 public key for the matching source slot. -Status output shows whether the consumer is enabled, configured channel, display name, a short public-key fingerprint, and last accepted timestamp and sequence. `time_sync.counters` exposes received, accepted, display-name mismatch, malformed, invalid signature, stale timestamp, replayed sequence, excessive forward step and clock-update counters. It does not echo private key material. +Status output shows whether the consumer is enabled, configured channel, configured source count, last accepted source, and last accepted timestamp and sequence. `time_sync.counters` exposes received, accepted, display-name mismatch, malformed, invalid signature, stale timestamp, replayed sequence, excessive forward step and clock-update counters. It does not echo private key material. --- diff --git a/docs/payloads.md b/docs/payloads.md index bb9cebaaff..89e4e3ebb2 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -254,7 +254,7 @@ The data contained in the ciphertext uses the format below: ## Repeater authenticated time sync -Repeater firmware can optionally consume authenticated time announcements from one configured group channel. Public/group channel authentication only proves knowledge of the channel secret; it does not prove which station wrote the displayed sender name. Time-sync messages therefore require an Ed25519 signature from the pinned authority public key configured on the repeater. +Repeater firmware can optionally consume authenticated time announcements from one configured group channel. Public/group channel authentication only proves knowledge of the channel secret; it does not prove which station wrote the displayed sender name. Time-sync messages therefore require an Ed25519 signature from one of the pinned authority public keys configured on the repeater. `Tv1` is carried only as a binary group datagram. The earlier text-message shape is not used because a visible text message plus an Ed25519 signature does not fit the practical group-channel payload budget. @@ -273,6 +273,10 @@ The group datagram `data type` is `0x0121`. Its data bytes are: The configured time-sync display name is limited to 20 bytes. With that limit, the `Tv1` data field is at most 94 bytes before the group-datagram type and length header. +Repeaters may be configured with multiple time authorities. The `display name` +field selects the configured authority slot, but it is not itself trusted; the +signature must verify against that slot's pinned public key. + ### Canonical signed bytes The signature covers this ASCII byte string, including the final newline: @@ -289,7 +293,7 @@ MeshCore-Time-v1 ### Clock and replay policy -Repeaters only move the clock forwards after signature verification succeeds. Timestamps below the firmware fallback epoch or above the supported upper bound are rejected. Once the clock is initialised, a forward jump greater than `time_sync.max_forward_step` is rejected. Replay state is kept in RAM as the last accepted timestamp and sequence; it is not written to flash for every received message. A newer timestamp is allowed even when the 16-bit sequence has wrapped or restarted, while equal timestamps are rejected and non-advancing equal-timestamp sequences are counted as replay. After reboot, the forward-only clock check still limits replay when a valid RTC value is retained. +Repeaters only move the clock forwards after signature verification succeeds. Timestamps below the firmware fallback epoch or above the supported upper bound are rejected. Once the clock is initialised, a forward jump greater than `time_sync.max_forward_step` is rejected. Replay state is kept in RAM as the last accepted timestamp and sequence per configured authority; it is not written to flash for every received message. A newer timestamp is allowed even when the 16-bit sequence has wrapped or restarted, while equal timestamps are rejected and non-advancing equal-timestamp sequences are counted as replay. After reboot, the forward-only clock check still limits replay when a valid RTC value is retained. ### Threat model diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 335197072b..3bba3f0548 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -93,147 +93,124 @@ static bool parseUint32Config(const char* text, uint32_t& value) { return true; } -static bool isWrappedSequenceNewer(uint16_t previous, uint16_t current) { - // Treat sequence numbers as a wrapping uint16 space. - uint16_t delta = (uint16_t)(current - previous); +bool MyMesh::isTimeSyncSourceConfigured(uint8_t source_idx) const { + if (source_idx == 0) { + // Slot 0 is the original single-source preference layout. + return TimeSyncAuth::isValidDisplayName(_prefs.time_sync_display_name) + && TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key); + } + + if (source_idx >= TIME_SYNC_MAX_SOURCES) return false; + + // Extra slots are stored in compact arrays after the legacy fields. + uint8_t extra_idx = source_idx - 1; + return TimeSyncAuth::isValidDisplayName(_prefs.time_sync_extra_display_names[extra_idx]) + && TimeSyncAuth::isValidPublicKey(_prefs.time_sync_extra_public_keys[extra_idx]); +} + +uint8_t MyMesh::populateTimeSyncSources(TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]) const { + uint8_t count = 0; + + // Preserve slot numbers in the returned entries so replay state can remain + // indexed by configured authority, even though invalid slots are skipped. + for (uint8_t slot = 0; slot < TIME_SYNC_MAX_SOURCES; slot++) { + if (!isTimeSyncSourceConfigured(slot)) continue; + + sources[count].slot_index = slot; + if (slot == 0) { + sources[count].display_name = _prefs.time_sync_display_name; + sources[count].public_key = _prefs.time_sync_public_key; + } else { + uint8_t extra_idx = slot - 1; + sources[count].display_name = _prefs.time_sync_extra_display_names[extra_idx]; + sources[count].public_key = _prefs.time_sync_extra_public_keys[extra_idx]; + } + count++; + } - // Equal is not newer, and deltas in the upper half are older values. - return delta != 0 && delta < 0x8000; + return count; } bool MyMesh::isTimeSyncConfigured() const { - // The consumer needs all three trust inputs before it can be enabled. - return _prefs.time_sync_channel_name[0] != 0 - && TimeSyncAuth::isValidDisplayName(_prefs.time_sync_display_name) - && TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key); + // The consumer needs a channel plus at least one complete pinned authority. + TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]; + return _prefs.time_sync_channel_name[0] != 0 && populateTimeSyncSources(sources) > 0; } -TimeSyncConfigView MyMesh::getTimeSyncConfig() const { +TimeSyncConfigView MyMesh::getTimeSyncConfig(TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]) const { // Build a read-only view of persisted settings for the parser. TimeSyncConfigView config; // Treat incomplete configuration as disabled even if the stored flag is set. config.enabled = _prefs.time_sync_enabled && isTimeSyncConfigured(); config.channel = _prefs.time_sync_channel; + config.source_count = populateTimeSyncSources(sources); + config.sources = sources; + // Populate legacy fields with slot 0 for callers that inspect the view while + // keeping the actual parser on the explicit source array. config.display_name = _prefs.time_sync_display_name; config.public_key = _prefs.time_sync_public_key; return config; } void MyMesh::resetTimeSyncReplay() { - // Replay state is deliberately RAM-only to avoid flash wear per packet. - time_sync_last_timestamp = 0; - time_sync_last_sequence = 0; + TimeSyncConsumer::resetReplay(time_sync_replay, time_sync_last_source, time_sync_clock_accepted_this_boot); +} - // This flag also marks whether the fallback-clock exception has been used. - time_sync_accepted_this_boot = false; +void MyMesh::resetTimeSyncReplay(uint8_t source_idx) { + TimeSyncConsumer::resetReplay(time_sync_replay, source_idx); } bool MyMesh::applyTimeSyncClock(uint32_t timestamp) { - // Reject timestamps outside the firmware-supported Unix range. - if (timestamp < TIME_SYNC_MIN_UNIX_TIME || timestamp > TIME_SYNC_MAX_UNIX_TIME) { - time_sync_stats.stale_timestamp++; - return false; - } - // Read the current RTC value only after authentication has succeeded. uint32_t current = getRTCClock()->getCurrentTime(); + uint32_t new_time = 0; - // The time-sync consumer is forward-only and never moves the clock back. - if (timestamp <= current) { - time_sync_stats.stale_timestamp++; - return false; - } - - // VolatileRTCClock and ESP32 power-on fallback both start at this epoch. - // Treat only the early fallback window as uninitialised, so a signed packet - // cannot push an already-running repeater decades forward after long uptime. - bool fallback_clock = !time_sync_accepted_this_boot - && current >= TIME_SYNC_MIN_UNIX_TIME - && current <= TIME_SYNC_MIN_UNIX_TIME + 86400UL; - - // A stored zero is repaired to the default so policy remains fail-closed. - uint32_t max_step = _prefs.time_sync_max_forward_step; - if (max_step == 0) max_step = TIME_SYNC_DEFAULT_MAX_FORWARD_STEP; - - // Once the clock is past the fallback window, limit authenticated jumps. - if (!fallback_clock && timestamp - current > max_step) { - time_sync_stats.excessive_forward_step++; + if (!TimeSyncConsumer::applyClock(time_sync_stats, time_sync_clock_accepted_this_boot, current, timestamp, + _prefs.time_sync_max_forward_step, new_time)) { return false; } // Apply the authenticated, policy-accepted timestamp to the RTC abstraction. - getRTCClock()->setCurrentTime(timestamp); - time_sync_stats.clock_updates++; + getRTCClock()->setCurrentTime(new_time); return true; } void MyMesh::handleTimeSyncResult(TimeSyncResult result, const TimeSyncMessage& msg) { - // Convert parser outcomes into diagnostics before any clock policy runs. - switch (result) { - case TIME_SYNC_OK: - break; - case TIME_SYNC_DISABLED: - return; - case TIME_SYNC_DISPLAY_NAME_MISMATCH: - time_sync_stats.display_name_mismatch++; - return; - case TIME_SYNC_SIGNATURE_INVALID: - time_sync_stats.signature_invalid++; - return; - case TIME_SYNC_MALFORMED: - default: - time_sync_stats.malformed++; - return; - } - - if (time_sync_accepted_this_boot) { - // Older timestamps are stale regardless of sequence value. - if (msg.timestamp < time_sync_last_timestamp) { - time_sync_stats.stale_timestamp++; - return; - } - - // Equal timestamps cannot move the RTC forward, but classify non-advancing - // sequence values as replay diagnostics using uint16 wrap-aware ordering. - if (msg.timestamp == time_sync_last_timestamp) { - if (!isWrappedSequenceNewer(time_sync_last_sequence, msg.sequence)) { - time_sync_stats.replayed_sequence++; - } else { - time_sync_stats.stale_timestamp++; - } - return; - } + uint32_t current = getRTCClock()->getCurrentTime(); + uint32_t new_time = 0; + if (TimeSyncConsumer::handleResult(time_sync_stats, time_sync_replay, time_sync_last_source, + time_sync_clock_accepted_this_boot, result, msg, current, + _prefs.time_sync_max_forward_step, new_time)) { + getRTCClock()->setCurrentTime(new_time); } - - // Update replay state only after the RTC policy accepts the message. - if (!applyTimeSyncClock(msg.timestamp)) return; - - // Record the last accepted authority message for later replay checks. - time_sync_last_timestamp = msg.timestamp; - time_sync_last_sequence = msg.sequence; - time_sync_accepted_this_boot = true; - time_sync_stats.accepted++; } void MyMesh::formatTimeSyncStatus(char* reply) { - // Prefer a short fingerprint in status output over echoing the public key. - char fp[7]; - if (TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key)) { - TimeSyncAuth::fingerprintLast3Hex(fp, _prefs.time_sync_public_key); + TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]; + uint8_t source_count = populateTimeSyncSources(sources); + uint32_t last_timestamp = 0; + uint16_t last_sequence = 0; + if (time_sync_last_source < TIME_SYNC_MAX_SOURCES) { + last_timestamp = time_sync_replay[time_sync_last_source].last_timestamp; + last_sequence = time_sync_replay[time_sync_last_source].last_sequence; + } + char last_source[4]; + if (time_sync_last_source < TIME_SYNC_MAX_SOURCES) { + snprintf(last_source, sizeof(last_source), "%u", (unsigned int)time_sync_last_source); } else { - strcpy(fp, "none"); + strcpy(last_source, "-"); } // Keep the reply within the existing 160-byte command response buffer. snprintf(reply, 160, - "%s ch=%s name=%s fp=%s ts=%lu seq=%u rx=%lu ok=%lu bad=%lu sig=%lu stale=%lu replay=%lu step=%lu clk=%lu", + "%s ch=%s src=%u last=%s ts=%lu seq=%u rx=%lu ok=%lu bad=%lu sig=%lu stale=%lu replay=%lu step=%lu clk=%lu", _prefs.time_sync_enabled ? "on" : "off", _prefs.time_sync_channel_name[0] ? _prefs.time_sync_channel_name : "-", - _prefs.time_sync_display_name[0] ? _prefs.time_sync_display_name : "-", - fp, - (unsigned long)time_sync_last_timestamp, - (unsigned int)time_sync_last_sequence, + (unsigned int)source_count, + last_source, + (unsigned long)last_timestamp, + (unsigned int)last_sequence, (unsigned long)time_sync_stats.received, (unsigned long)time_sync_stats.accepted, (unsigned long)(time_sync_stats.malformed + time_sync_stats.display_name_mismatch), @@ -275,6 +252,7 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { // Read-only configuration queries follow the existing `get` command style. if (memcmp(command, "get time_sync.", 14) == 0) { const char* key = command + 14; + TimeSyncSourceSettingKey source_key; if (strcmp(key, "enabled") == 0) { sprintf(reply, "> %s", _prefs.time_sync_enabled ? "on" : "off"); } else if (strcmp(key, "channel") == 0) { @@ -287,6 +265,41 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { sprintf(reply, "> fingerprint:%s", TimeSyncAuth::isValidPublicKey(_prefs.time_sync_public_key) ? fp : "none"); } else if (strcmp(key, "max_forward_step") == 0) { sprintf(reply, "> %lu", (unsigned long)_prefs.time_sync_max_forward_step); + } else if (strcmp(key, "sources") == 0) { + TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]; + uint8_t count = populateTimeSyncSources(sources); + int pos = snprintf(reply, 160, "> count=%u", (unsigned int)count); + for (uint8_t i = 0; i < count && pos > 0 && pos < 150; i++) { + char fp[7]; + TimeSyncAuth::fingerprintLast3Hex(fp, sources[i].public_key); + pos += snprintf(&reply[pos], 160 - pos, " %u:%s/%s", + (unsigned int)sources[i].slot_index, + sources[i].display_name, + fp); + } + } else if (memcmp(key, "source.", 7) == 0) { + if (!TimeSyncAuth::parseSourceSettingKey(key, source_key) || *source_key.field != 0) { + strcpy(reply, "Err - unknown time_sync setting"); + } else { + uint8_t source_idx = source_key.source_index; + if (!isTimeSyncSourceConfigured(source_idx)) { + sprintf(reply, "> %u empty", (unsigned int)source_idx); + } else { + const char* name; + const uint8_t* pubkey; + if (source_idx == 0) { + name = _prefs.time_sync_display_name; + pubkey = _prefs.time_sync_public_key; + } else { + uint8_t extra_idx = source_idx - 1; + name = _prefs.time_sync_extra_display_names[extra_idx]; + pubkey = _prefs.time_sync_extra_public_keys[extra_idx]; + } + char fp[7]; + TimeSyncAuth::fingerprintLast3Hex(fp, pubkey); + sprintf(reply, "> %u name=%s fingerprint:%s", (unsigned int)source_idx, name, fp); + } + } } else { strcpy(reply, "Err - unknown time_sync setting"); } @@ -341,7 +354,7 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { // Authority changes reset replay state because old sequence values no longer apply. StrHelper::strncpy(_prefs.time_sync_display_name, value, sizeof(_prefs.time_sync_display_name)); - resetTimeSyncReplay(); + resetTimeSyncReplay(0); savePrefs(); strcpy(reply, "OK"); } else if (strcmp(key, "public_key") == 0) { @@ -354,7 +367,57 @@ void MyMesh::handleTimeSyncCommand(char* command, char* reply) { // Store only the public key; private signing material is never accepted. memcpy(_prefs.time_sync_public_key, pubkey, sizeof(_prefs.time_sync_public_key)); - resetTimeSyncReplay(); + resetTimeSyncReplay(0); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(key, "source.", 7) == 0) { + uint8_t source_idx = 0; + const char* source_field = NULL; + TimeSyncSourceSettingKey source_key; + if (!TimeSyncAuth::parseSourceSettingKey(key, source_key)) { + strcpy(reply, "Err - unknown time_sync setting"); + return; + } + source_idx = source_key.source_index; + source_field = source_key.field; + + // Select the storage for the requested authority slot. Slot 0 maps to the + // original single-source fields for backwards compatibility. + char* display_name = _prefs.time_sync_display_name; + uint8_t* public_key = _prefs.time_sync_public_key; + if (source_idx > 0) { + uint8_t extra_idx = source_idx - 1; + display_name = _prefs.time_sync_extra_display_names[extra_idx]; + public_key = _prefs.time_sync_extra_public_keys[extra_idx]; + } + + TimeSyncSourceSettingResult result = TimeSyncAuth::applySourceSetting(display_name, 32, public_key, + source_field, value); + if (result != TIME_SYNC_SOURCE_SETTING_OK) { + switch (result) { + case TIME_SYNC_SOURCE_SETTING_INVALID_DISPLAY_NAME: + strcpy(reply, "Err - invalid display_name"); + break; + case TIME_SYNC_SOURCE_SETTING_INVALID_PUBLIC_KEY: + strcpy(reply, "Err - invalid public_key"); + break; + case TIME_SYNC_SOURCE_SETTING_CLEAR_REQUIRES_YES: + strcpy(reply, "Err - clear requires yes"); + break; + case TIME_SYNC_SOURCE_SETTING_UNKNOWN_FIELD: + default: + strcpy(reply, "Err - unknown time_sync setting"); + break; + } + return; + } + + // Reset only this source's replay cursor because other authorities keep + // their own independent sequence streams. + resetTimeSyncReplay(source_idx); + if (strcmp(source_field, "clear") == 0 && _prefs.time_sync_enabled && !isTimeSyncConfigured()) { + _prefs.time_sync_enabled = 0; + } savePrefs(); strcpy(reply, "OK"); } else if (strcmp(key, "max_forward_step") == 0) { @@ -1177,15 +1240,9 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { } int MyMesh::searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel channels[], int max_matches) { - // Do not expose a candidate channel unless time sync is enabled and complete. - if (max_matches <= 0 || !_prefs.time_sync_enabled || !isTimeSyncConfigured()) return 0; - - // Match only the configured time-sync channel hash. - if (memcmp(hash, _prefs.time_sync_channel.hash, PATH_HASH_SIZE) != 0) return 0; - - // Return the exact configured channel so Mesh.cpp can decrypt normally. - channels[0] = _prefs.time_sync_channel; - return 1; + TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]; + TimeSyncConfigView config = getTimeSyncConfig(sources); + return TimeSyncConsumer::searchChannelByHash(config, hash, channels, max_matches); } void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) { @@ -1197,42 +1254,15 @@ void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::Gro if (!_prefs.time_sync_enabled || !isTimeSyncConfigured()) return; // Snapshot the current authority config for this packet. - TimeSyncConfigView config = getTimeSyncConfig(); - TimeSyncMessage msg; - TimeSyncResult result = TIME_SYNC_MALFORMED; - - // Time sync is binary-only; group text messages are not parsed. - if (type != PAYLOAD_TYPE_GRP_DATA) { - time_sync_stats.received++; - time_sync_stats.malformed++; - return; - } - - // Group datagrams require a 2-byte type and 1-byte data length prefix. - if (len < 3) { - time_sync_stats.received++; - time_sync_stats.malformed++; - return; - } - - // Decode the group-datagram application type and embedded data length. - uint16_t data_type = ((uint16_t)data[0]) | (((uint16_t)data[1]) << 8); - uint8_t data_len = data[2]; - size_t available_len = len - 3; - - // Ignore every application type except the allocated repeater time sync type. - if (data_len > available_len || data_type != TIME_SYNC_BINARY_DATA_TYPE) { - time_sync_stats.received++; - time_sync_stats.malformed++; - return; + TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]; + TimeSyncConfigView config = getTimeSyncConfig(sources); + uint32_t current = getRTCClock()->getCurrentTime(); + uint32_t new_time = 0; + if (TimeSyncConsumer::consumeGroupData(time_sync_stats, time_sync_replay, time_sync_last_source, + time_sync_clock_accepted_this_boot, config, type, data, len, + current, _prefs.time_sync_max_forward_step, new_time)) { + getRTCClock()->setCurrentTime(new_time); } - - // Parse and verify the inner Tv1 payload before considering clock policy. - time_sync_stats.received++; - result = TimeSyncAuth::parseAndVerifyBinary(config, &data[3], data_len, msg); - - // Applying the clock is separated so authentication and policy stay distinct. - handleTimeSyncResult(result, msg); } void MyMesh::sendNodeDiscoverReq() { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index e8c1c0ea07..3cccc8a965 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include "RateLimiter.h" @@ -69,20 +70,6 @@ struct NeighbourInfo { int8_t snr; // multiplied by 4, user should divide to get float value }; -// Runtime-only counters for the authenticated time-sync consumer. These are -// not persisted, so receiving time beacons does not create flash wear. -struct TimeSyncStats { - uint32_t received; - uint32_t accepted; - uint32_t display_name_mismatch; - uint32_t malformed; - uint32_t signature_invalid; - uint32_t stale_timestamp; - uint32_t replayed_sequence; - uint32_t excessive_forward_step; - uint32_t clock_updates; -}; - #ifndef FIRMWARE_BUILD_DATE #define FIRMWARE_BUILD_DATE "6 Jun 2026" #endif @@ -120,9 +107,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long dirty_contacts_expiry; // Time-sync diagnostics and replay state are local to this repeater boot. TimeSyncStats time_sync_stats; - uint32_t time_sync_last_timestamp; - uint16_t time_sync_last_sequence; - bool time_sync_accepted_this_boot; + TimeSyncReplayState time_sync_replay[TIME_SYNC_MAX_SOURCES]; + uint8_t time_sync_last_source; + bool time_sync_clock_accepted_this_boot; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -152,8 +139,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { // Time-sync helpers keep configuration, parsing result handling and clock // policy local to repeater firmware. bool isTimeSyncConfigured() const; - TimeSyncConfigView getTimeSyncConfig() const; + bool isTimeSyncSourceConfigured(uint8_t source_idx) const; + uint8_t populateTimeSyncSources(TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]) const; + TimeSyncConfigView getTimeSyncConfig(TimeSyncAuthorityConfig sources[TIME_SYNC_MAX_SOURCES]) const; void resetTimeSyncReplay(); + void resetTimeSyncReplay(uint8_t source_idx); void handleTimeSyncResult(TimeSyncResult result, const TimeSyncMessage& msg); bool applyTimeSyncClock(uint32_t timestamp); void handleTimeSyncCommand(char* command, char* reply); diff --git a/platformio.ini b/platformio.ini index 8112f5a59b..38ba926ee8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -173,6 +173,7 @@ build_src_filter = ; Time-sync host tests need Identity verification and the helper under test. +<../src/Identity.cpp> +<../src/helpers/TimeSyncAuth.cpp> + +<../src/helpers/TimeSyncConsumer.cpp> ; Link the vendored C Ed25519 implementation for native verification. +<../lib/ed25519/*.c> lib_deps = diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c5e13c7c44..90b642a1d9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,12 +4,20 @@ #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" #include "TimeSyncAuth.h" +#include "TimeSyncPrefsLayout.h" #include #ifndef BRIDGE_MAX_BAUD #define BRIDGE_MAX_BAUD 115200 #endif +static_assert(TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_OFFSET == + TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_OFFSET + TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_SIZE, + "time-sync extra public keys must follow extra display names in persisted prefs"); +static_assert(TIME_SYNC_PREFS_NEXT_OFFSET == + TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_OFFSET + TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_SIZE, + "time-sync preference record length must include all extra sources"); + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -102,7 +110,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)_prefs->time_sync_display_name, sizeof(_prefs->time_sync_display_name)); // 361 file.read((uint8_t *)_prefs->time_sync_public_key, sizeof(_prefs->time_sync_public_key)); // 393 file.read((uint8_t *)&_prefs->time_sync_max_forward_step, sizeof(_prefs->time_sync_max_forward_step)); // 425 - // next: 429 + file.read((uint8_t *)_prefs->time_sync_extra_display_names, sizeof(_prefs->time_sync_extra_display_names)); // 429 + file.read((uint8_t *)_prefs->time_sync_extra_public_keys, sizeof(_prefs->time_sync_extra_public_keys)); // 525 + // next: 621 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -143,6 +153,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Force C-string termination in case persisted data filled the buffers. _prefs->time_sync_channel_name[sizeof(_prefs->time_sync_channel_name) - 1] = 0; _prefs->time_sync_display_name[sizeof(_prefs->time_sync_display_name) - 1] = 0; + for (uint8_t i = 0; i < TIME_SYNC_MAX_SOURCES - 1; i++) { + _prefs->time_sync_extra_display_names[i][sizeof(_prefs->time_sync_extra_display_names[i]) - 1] = 0; + } file.close(); } @@ -215,7 +228,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)_prefs->time_sync_display_name, sizeof(_prefs->time_sync_display_name)); // 361 file.write((uint8_t *)_prefs->time_sync_public_key, sizeof(_prefs->time_sync_public_key)); // 393 file.write((uint8_t *)&_prefs->time_sync_max_forward_step, sizeof(_prefs->time_sync_max_forward_step)); // 425 - // next: 429 + file.write((uint8_t *)_prefs->time_sync_extra_display_names, sizeof(_prefs->time_sync_extra_display_names)); // 429 + file.write((uint8_t *)_prefs->time_sync_extra_public_keys, sizeof(_prefs->time_sync_extra_public_keys)); // 525 + // next: 621 file.close(); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8d666e264c..35e9102f65 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -5,6 +5,7 @@ #include #include #include +#include #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) #define WITH_BRIDGE @@ -78,6 +79,10 @@ struct NodePrefs { // persisted to file uint8_t time_sync_public_key[PUB_KEY_SIZE]; // Maximum accepted forward clock step once the RTC is initialised. uint32_t time_sync_max_forward_step; + // Additional authorities are appended after the original single-source + // layout. Slot 0 remains time_sync_display_name/time_sync_public_key. + char time_sync_extra_display_names[TIME_SYNC_MAX_SOURCES - 1][32]; + uint8_t time_sync_extra_public_keys[TIME_SYNC_MAX_SOURCES - 1][PUB_KEY_SIZE]; }; class CommonCLICallbacks { diff --git a/src/helpers/TimeSyncAuth.cpp b/src/helpers/TimeSyncAuth.cpp index ab3b55ef51..ee960fcca4 100644 --- a/src/helpers/TimeSyncAuth.cpp +++ b/src/helpers/TimeSyncAuth.cpp @@ -33,35 +33,19 @@ static void bytesToLowerHex(char* dest, const uint8_t* src, size_t len) { *dest = 0; } -bool TimeSyncAuth::parseUint32Strict(const char* text, size_t len, uint32_t& value) { - // Empty numeric fields are not valid decimal numbers. - if (len == 0) return false; - - uint32_t acc = 0; - for (size_t i = 0; i < len; i++) { - // Accept decimal digits only; signs, prefixes and whitespace are rejected. - char ch = text[i]; - if (ch < '0' || ch > '9') return false; - - // Check overflow before multiplying by ten and adding the next digit. - uint32_t digit = (uint32_t)(ch - '0'); - if (acc > (UINT32_MAX - digit) / 10UL) return false; - acc = acc * 10UL + digit; +static bool parseStrictHex(uint8_t* dest, size_t dest_len, const char* text) { + // Require the CLI value to be exactly one complete byte string. + if (text == NULL || strlen(text) != dest_len * 2) return false; + + // Validate every character before decoding so partial writes cannot occur. + for (size_t i = 0; i < dest_len; i++) { + char hi = text[i * 2]; + char lo = text[i * 2 + 1]; + if (!mesh::Utils::isHexChar(hi) || !mesh::Utils::isHexChar(lo)) return false; } - // Return the parsed value only after the whole field has been validated. - value = acc; - return true; -} - -bool TimeSyncAuth::parseUint16Strict(const char* text, size_t len, uint16_t& value) { - // Parse with the wider helper first so overflow handling is centralised. - uint32_t tmp; - if (!parseUint32Strict(text, len, tmp) || tmp > UINT16_MAX) return false; - - // Narrow only after proving the value fits the wire sequence field. - value = (uint16_t)tmp; - return true; + // Decode only after the length and character set checks have passed. + return mesh::Utils::fromHex(dest, dest_len, text); } bool TimeSyncAuth::buildCanonical(char* dest, size_t dest_len, const mesh::GroupChannel& channel, @@ -86,16 +70,16 @@ bool TimeSyncAuth::buildCanonical(char* dest, size_t dest_len, const mesh::Group return written > 0 && (size_t)written < dest_len; } -bool TimeSyncAuth::verifyParsed(const TimeSyncConfigView& config, uint32_t timestamp, uint16_t sequence, - const uint8_t signature[SIGNATURE_SIZE]) { +bool TimeSyncAuth::verifyParsed(const mesh::GroupChannel& channel, const TimeSyncAuthorityConfig& source, + uint32_t timestamp, uint16_t sequence, const uint8_t signature[SIGNATURE_SIZE]) { // Reconstruct the canonical message from local configuration and parsed fields. char canonical[TIME_SYNC_CANONICAL_MAX_LEN]; - if (!buildCanonical(canonical, sizeof(canonical), config.channel, config.display_name, timestamp, sequence)) { + if (!buildCanonical(canonical, sizeof(canonical), channel, source.display_name, timestamp, sequence)) { return false; } // Verify against the exact pinned authority public key from configuration. - mesh::Identity authority(config.public_key); + mesh::Identity authority(source.public_key); return authority.verify(signature, (const uint8_t*)canonical, strlen(canonical)); } @@ -162,11 +146,85 @@ void TimeSyncAuth::configureDefaultPublicChannel(mesh::GroupChannel& dest) { mesh::Utils::sha256(dest.hash, sizeof(dest.hash), dest.secret, sizeof(DEFAULT_PUBLIC_CHANNEL_SECRET)); } -TimeSyncResult TimeSyncAuth::parseAndVerifyBinary(const TimeSyncConfigView& config, const uint8_t* data, - size_t data_len, TimeSyncMessage& msg) { +bool TimeSyncAuth::parseSourceSettingKey(const char* key, TimeSyncSourceSettingKey& parsed) { + // Source keys are deliberately small and explicit: source... + if (key == NULL || memcmp(key, "source.", 7) != 0) return false; + + // TIME_SYNC_MAX_SOURCES is 4, so one decimal digit is the full slot range. + char slot = key[7]; + if (slot < '0' || slot >= (char)('0' + TIME_SYNC_MAX_SOURCES)) return false; + + parsed.source_index = (uint8_t)(slot - '0'); + if (key[8] == 0) { + parsed.field = key + 8; + return true; + } + if (key[8] != '.') return false; + + parsed.field = key + 9; + return true; +} + +TimeSyncSourceSettingResult TimeSyncAuth::applySourceSetting(char* display_name, size_t display_name_len, + uint8_t public_key[PUB_KEY_SIZE], + const char* field, const char* value) { + // The caller passes storage selected from the configured source slot. + if (display_name == NULL || display_name_len == 0 || public_key == NULL || field == NULL) { + return TIME_SYNC_SOURCE_SETTING_UNKNOWN_FIELD; + } + + if (strcmp(field, "display_name") == 0) { + // Empty names, names with CR/LF, and oversized names are rejected. + if (!isValidDisplayName(value) || strlen(value) >= display_name_len) { + return TIME_SYNC_SOURCE_SETTING_INVALID_DISPLAY_NAME; + } + + // Copy without depending on Arduino String helpers in host tests. + size_t len = strlen(value); + memcpy(display_name, value, len); + display_name[len] = 0; + return TIME_SYNC_SOURCE_SETTING_OK; + } + + if (strcmp(field, "public_key") == 0) { + // Decode into a temporary so invalid hex cannot partially mutate prefs. + uint8_t parsed_key[PUB_KEY_SIZE]; + if (!parseStrictHex(parsed_key, sizeof(parsed_key), value) || !isValidPublicKey(parsed_key)) { + return TIME_SYNC_SOURCE_SETTING_INVALID_PUBLIC_KEY; + } + + memcpy(public_key, parsed_key, PUB_KEY_SIZE); + return TIME_SYNC_SOURCE_SETTING_OK; + } + + if (strcmp(field, "clear") == 0) { + if (value == NULL || strcmp(value, "yes") != 0) { + return TIME_SYNC_SOURCE_SETTING_CLEAR_REQUIRES_YES; + } + + memset(display_name, 0, display_name_len); + memset(public_key, 0, PUB_KEY_SIZE); + return TIME_SYNC_SOURCE_SETTING_OK; + } + + return TIME_SYNC_SOURCE_SETTING_UNKNOWN_FIELD; +} + +TimeSyncResult TimeSyncAuth::parseAndVerifyBinaryMulti(const TimeSyncConfigView& config, const uint8_t* data, + size_t data_len, TimeSyncMessage& msg) { // Fail closed before parsing if the feature or required authority config is absent. if (!config.enabled) return TIME_SYNC_DISABLED; - if (!isValidDisplayName(config.display_name) || !isValidPublicKey(config.public_key)) return TIME_SYNC_DISABLED; + if (config.source_count == 0 || config.sources == NULL) return TIME_SYNC_DISABLED; + uint8_t source_count = config.source_count; + if (source_count > TIME_SYNC_MAX_SOURCES) source_count = TIME_SYNC_MAX_SOURCES; + bool has_valid_source = false; + for (uint8_t i = 0; i < source_count; i++) { + if (isValidDisplayName(config.sources[i].display_name) && isValidPublicKey(config.sources[i].public_key)) { + has_valid_source = true; + break; + } + } + if (!has_valid_source) return TIME_SYNC_DISABLED; // Enforce the minimum fixed fields plus the 64-byte raw signature. if (data_len < 3 + 4 + 2 + 1 + SIGNATURE_SIZE) return TIME_SYNC_MALFORMED; @@ -190,16 +248,44 @@ TimeSyncResult TimeSyncAuth::parseAndVerifyBinary(const TimeSyncConfigView& conf uint8_t display_len = data[pos++]; if (display_len == 0 || display_len > TIME_SYNC_MAX_DISPLAY_NAME_LEN) return TIME_SYNC_MALFORMED; if (pos + display_len + SIGNATURE_SIZE != data_len) return TIME_SYNC_MALFORMED; - if (strlen(config.display_name) != display_len || memcmp(&data[pos], config.display_name, display_len) != 0) { + + // Find exactly one configured source by display name. The display name is + // only a selector; signature verification below is the trust decision. + const TimeSyncAuthorityConfig* matched_source = NULL; + for (uint8_t i = 0; i < source_count; i++) { + const TimeSyncAuthorityConfig& source = config.sources[i]; + if (!isValidDisplayName(source.display_name) || !isValidPublicKey(source.public_key)) continue; + if (strlen(source.display_name) == display_len && memcmp(&data[pos], source.display_name, display_len) == 0) { + matched_source = &source; + break; + } + } + if (matched_source == NULL) { return TIME_SYNC_DISPLAY_NAME_MISMATCH; } pos += display_len; // Authenticate the canonical fields using the raw signature at the tail. - if (!verifyParsed(config, timestamp, sequence, &data[pos])) return TIME_SYNC_SIGNATURE_INVALID; + if (!verifyParsed(config.channel, *matched_source, timestamp, sequence, &data[pos])) return TIME_SYNC_SIGNATURE_INVALID; // Return the authenticated timestamp and sequence to the repeater policy code. msg.timestamp = timestamp; msg.sequence = sequence; + msg.source_index = matched_source->slot_index; return TIME_SYNC_OK; } + +TimeSyncResult TimeSyncAuth::parseAndVerifyBinary(const TimeSyncConfigView& config, const uint8_t* data, + size_t data_len, TimeSyncMessage& msg) { + // Preserve the original single-source API by wrapping legacy fields in a + // one-entry authority list and then using the multi-source parser. + TimeSyncAuthorityConfig source; + source.slot_index = 0; + source.display_name = config.display_name; + source.public_key = config.public_key; + + TimeSyncConfigView wrapped = config; + wrapped.source_count = 1; + wrapped.sources = &source; + return parseAndVerifyBinaryMulti(wrapped, data, data_len, msg); +} diff --git a/src/helpers/TimeSyncAuth.h b/src/helpers/TimeSyncAuth.h index fd8db5481d..f302f2064f 100644 --- a/src/helpers/TimeSyncAuth.h +++ b/src/helpers/TimeSyncAuth.h @@ -23,12 +23,25 @@ // limit keeps the datagram comfortably below the practical group payload size. #define TIME_SYNC_MAX_DISPLAY_NAME_LEN 20 #define TIME_SYNC_CANONICAL_MAX_LEN 128 +#define TIME_SYNC_MAX_SOURCES 4 + +// One configured time authority. The display name is a selector; the public +// key is the actual authentication control. +struct TimeSyncAuthorityConfig { + uint8_t slot_index; + const char* display_name; + const uint8_t* public_key; +}; // Lightweight view over persisted repeater settings. It avoids copying the -// pinned public key into packet-processing code. +// pinned public keys into packet-processing code. struct TimeSyncConfigView { bool enabled; mesh::GroupChannel channel; + uint8_t source_count; + const TimeSyncAuthorityConfig* sources; + // Legacy single-source fields are kept so existing callers can continue to + // use the original parseAndVerifyBinary() API without building an array. const char* display_name; const uint8_t* public_key; }; @@ -38,6 +51,14 @@ struct TimeSyncConfigView { struct TimeSyncMessage { uint32_t timestamp; uint16_t sequence; + uint8_t source_index; +}; + +// Parsed form of a repeater CLI authority-slot setting such as +// `source.1.display_name`. The field pointer aliases the caller's input. +struct TimeSyncSourceSettingKey { + uint8_t source_index; + const char* field; }; // Parser outcomes are intentionally coarse so the repeater can count failures @@ -50,18 +71,22 @@ enum TimeSyncResult { TIME_SYNC_SIGNATURE_INVALID }; -class TimeSyncAuth { - // Strict decimal helpers are retained for canonical rendering symmetry and - // any future CLI/test parsing, but packet parsing itself is binary-only. - static bool parseUint32Strict(const char* text, size_t len, uint32_t& value); - static bool parseUint16Strict(const char* text, size_t len, uint16_t& value); +// Result of applying one CLI source-slot setting to plain preference buffers. +enum TimeSyncSourceSettingResult { + TIME_SYNC_SOURCE_SETTING_OK, + TIME_SYNC_SOURCE_SETTING_UNKNOWN_FIELD, + TIME_SYNC_SOURCE_SETTING_INVALID_DISPLAY_NAME, + TIME_SYNC_SOURCE_SETTING_INVALID_PUBLIC_KEY, + TIME_SYNC_SOURCE_SETTING_CLEAR_REQUIRES_YES +}; +class TimeSyncAuth { // Canonical signing helper: this is the only byte string accepted by the // verifier for a parsed timestamp/sequence/display-name/channel tuple. static bool buildCanonical(char* dest, size_t dest_len, const mesh::GroupChannel& channel, const char* display_name, uint32_t timestamp, uint16_t sequence); - static bool verifyParsed(const TimeSyncConfigView& config, uint32_t timestamp, uint16_t sequence, - const uint8_t signature[SIGNATURE_SIZE]); + static bool verifyParsed(const mesh::GroupChannel& channel, const TimeSyncAuthorityConfig& source, + uint32_t timestamp, uint16_t sequence, const uint8_t signature[SIGNATURE_SIZE]); public: // Configuration validators fail closed so wildcard-like setup cannot enable @@ -76,7 +101,18 @@ class TimeSyncAuth { static void configureHashtagChannel(mesh::GroupChannel& dest, const char* name); static void configureDefaultPublicChannel(mesh::GroupChannel& dest); + // Parse the shared source-slot CLI grammar used by get/set time_sync.source.N. + static bool parseSourceSettingKey(const char* key, TimeSyncSourceSettingKey& parsed); + + // Apply one parsed source-slot CLI setting. Persistence and replay reset stay + // with the firmware caller because those side effects are board-specific. + static TimeSyncSourceSettingResult applySourceSetting(char* display_name, size_t display_name_len, + uint8_t public_key[PUB_KEY_SIZE], + const char* field, const char* value); + // Binary-only parser for PAYLOAD_TYPE_GRP_DATA application type 0x0121. + static TimeSyncResult parseAndVerifyBinaryMulti(const TimeSyncConfigView& config, const uint8_t* data, + size_t data_len, TimeSyncMessage& msg); static TimeSyncResult parseAndVerifyBinary(const TimeSyncConfigView& config, const uint8_t* data, size_t data_len, TimeSyncMessage& msg); }; diff --git a/src/helpers/TimeSyncConsumer.cpp b/src/helpers/TimeSyncConsumer.cpp new file mode 100644 index 0000000000..2f413fcbb9 --- /dev/null +++ b/src/helpers/TimeSyncConsumer.cpp @@ -0,0 +1,186 @@ +#include "TimeSyncConsumer.h" + +#include + +bool TimeSyncConsumer::isWrappedSequenceNewer(uint16_t previous, uint16_t current) { + // Treat sequence numbers as a wrapping uint16 space. + uint16_t delta = (uint16_t)(current - previous); + + // Equal is not newer, and deltas in the upper half are older values. + return delta != 0 && delta < 0x8000; +} + +void TimeSyncConsumer::resetReplay(TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], uint8_t& last_source, + bool& clock_accepted_this_boot) { + // Replay state is deliberately RAM-only to avoid flash wear per packet. + for (uint8_t i = 0; i < TIME_SYNC_MAX_SOURCES; i++) { + resetReplay(replay, i); + } + + // The fallback-clock exception is global because only one RTC is updated. + clock_accepted_this_boot = false; + last_source = 0xFF; +} + +void TimeSyncConsumer::resetReplay(TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], uint8_t source_idx) { + if (source_idx >= TIME_SYNC_MAX_SOURCES) return; + + // Clear only the requested authority slot when one source changes. + replay[source_idx].last_timestamp = 0; + replay[source_idx].last_sequence = 0; + replay[source_idx].accepted_this_boot = false; +} + +bool TimeSyncConsumer::applyClock(TimeSyncStats& stats, bool& clock_accepted_this_boot, uint32_t current_time, + uint32_t candidate_timestamp, uint32_t max_forward_step, uint32_t& new_time) { + // Reject timestamps outside the firmware-supported Unix range. + if (candidate_timestamp < TIME_SYNC_MIN_UNIX_TIME || candidate_timestamp > TIME_SYNC_MAX_UNIX_TIME) { + stats.stale_timestamp++; + return false; + } + + // The time-sync consumer is forward-only and never moves the clock back. + if (candidate_timestamp <= current_time) { + stats.stale_timestamp++; + return false; + } + + // VolatileRTCClock and ESP32 power-on fallback both start at this epoch. + // Treat only the early fallback window as uninitialised, so a signed packet + // cannot push an already-running device decades forward after long uptime. + bool fallback_clock = !clock_accepted_this_boot + && current_time >= TIME_SYNC_MIN_UNIX_TIME + && current_time <= TIME_SYNC_MIN_UNIX_TIME + 86400UL; + + // A stored zero is repaired to the default so policy remains fail-closed. + uint32_t max_step = max_forward_step; + if (max_step == 0) max_step = TIME_SYNC_DEFAULT_MAX_FORWARD_STEP; + + // Once the clock is past the fallback window, limit authenticated jumps. + if (!fallback_clock && candidate_timestamp - current_time > max_step) { + stats.excessive_forward_step++; + return false; + } + + // Return the authenticated, policy-accepted timestamp to the caller. + new_time = candidate_timestamp; + clock_accepted_this_boot = true; + stats.clock_updates++; + return true; +} + +bool TimeSyncConsumer::handleResult(TimeSyncStats& stats, TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], + uint8_t& last_source, bool& clock_accepted_this_boot, TimeSyncResult result, + const TimeSyncMessage& msg, uint32_t current_time, uint32_t max_forward_step, + uint32_t& new_time) { + // Convert parser outcomes into diagnostics before any clock policy runs. + switch (result) { + case TIME_SYNC_OK: + break; + case TIME_SYNC_DISABLED: + return false; + case TIME_SYNC_DISPLAY_NAME_MISMATCH: + stats.display_name_mismatch++; + return false; + case TIME_SYNC_SIGNATURE_INVALID: + stats.signature_invalid++; + return false; + case TIME_SYNC_MALFORMED: + default: + stats.malformed++; + return false; + } + + // The parser returns the configured source slot, so guard against corrupted + // or future message structs before indexing the replay array. + if (msg.source_index >= TIME_SYNC_MAX_SOURCES) { + stats.malformed++; + return false; + } + TimeSyncReplayState& source_replay = replay[msg.source_index]; + + if (source_replay.accepted_this_boot) { + // Older timestamps are stale regardless of sequence value. + if (msg.timestamp < source_replay.last_timestamp) { + stats.stale_timestamp++; + return false; + } + + // Equal timestamps cannot move the RTC forward, but classify non-advancing + // sequence values as replay diagnostics using uint16 wrap-aware ordering. + if (msg.timestamp == source_replay.last_timestamp) { + if (!isWrappedSequenceNewer(source_replay.last_sequence, msg.sequence)) { + stats.replayed_sequence++; + } else { + stats.stale_timestamp++; + } + return false; + } + } + + // Update replay state only after the RTC policy accepts the message. + if (!applyClock(stats, clock_accepted_this_boot, current_time, msg.timestamp, max_forward_step, new_time)) { + return false; + } + + // Record the last accepted authority message for later replay checks. + source_replay.last_timestamp = msg.timestamp; + source_replay.last_sequence = msg.sequence; + source_replay.accepted_this_boot = true; + last_source = msg.source_index; + stats.accepted++; + return true; +} + +bool TimeSyncConsumer::consumeGroupData(TimeSyncStats& stats, TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], + uint8_t& last_source, bool& clock_accepted_this_boot, + const TimeSyncConfigView& config, uint8_t packet_type, + const uint8_t* data, size_t len, uint32_t current_time, + uint32_t max_forward_step, uint32_t& new_time) { + // Time sync is binary-only; group text messages on the same configured + // channel are display-only companion traffic and must not affect counters. + if (packet_type != PAYLOAD_TYPE_GRP_DATA) return false; + + // Group datagrams require a 2-byte type and 1-byte data length prefix. + if (len < 3) { + stats.received++; + stats.malformed++; + return false; + } + + // Decode the group-datagram application type and embedded data length. + uint16_t data_type = ((uint16_t)data[0]) | (((uint16_t)data[1]) << 8); + uint8_t data_len = data[2]; + size_t available_len = len - 3; + + // Other datagrams may legitimately share the configured channel. + if (data_type != TIME_SYNC_BINARY_DATA_TYPE) return false; + + // Count malformed only after the application type identifies a time-sync + // datagram; unrelated datagrams should not pollute time-sync diagnostics. + if (data_len > available_len) { + stats.received++; + stats.malformed++; + return false; + } + + // Parse and verify the inner Tv1 payload before considering clock policy. + stats.received++; + TimeSyncMessage msg; + TimeSyncResult result = TimeSyncAuth::parseAndVerifyBinaryMulti(config, &data[3], data_len, msg); + return handleResult(stats, replay, last_source, clock_accepted_this_boot, result, msg, + current_time, max_forward_step, new_time); +} + +int TimeSyncConsumer::searchChannelByHash(const TimeSyncConfigView& config, const uint8_t* hash, + mesh::GroupChannel channels[], int max_matches) { + // Do not expose a candidate channel unless time sync is enabled and complete. + if (max_matches <= 0 || !config.enabled || config.source_count == 0) return 0; + + // Match only the configured time-sync channel hash. + if (memcmp(hash, config.channel.hash, PATH_HASH_SIZE) != 0) return 0; + + // Return the exact configured channel so Mesh.cpp can decrypt normally. + channels[0] = config.channel; + return 1; +} diff --git a/src/helpers/TimeSyncConsumer.h b/src/helpers/TimeSyncConsumer.h new file mode 100644 index 0000000000..391711f107 --- /dev/null +++ b/src/helpers/TimeSyncConsumer.h @@ -0,0 +1,51 @@ +#pragma once + +#include "TimeSyncAuth.h" + +// Runtime-only counters for an authenticated time-sync consumer. These are not +// persisted, so receiving time beacons does not create flash wear. +struct TimeSyncStats { + uint32_t received; + uint32_t accepted; + uint32_t display_name_mismatch; + uint32_t malformed; + uint32_t signature_invalid; + uint32_t stale_timestamp; + uint32_t replayed_sequence; + uint32_t excessive_forward_step; + uint32_t clock_updates; +}; + +// Replay cursors are per authority slot so independent senders cannot block +// each other by using separate sequence streams. +struct TimeSyncReplayState { + uint32_t last_timestamp; + uint16_t last_sequence; + bool accepted_this_boot; +}; + +class TimeSyncConsumer { + static bool isWrappedSequenceNewer(uint16_t previous, uint16_t current); + +public: + static void resetReplay(TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], uint8_t& last_source, + bool& clock_accepted_this_boot); + static void resetReplay(TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], uint8_t source_idx); + + static bool applyClock(TimeSyncStats& stats, bool& clock_accepted_this_boot, uint32_t current_time, + uint32_t candidate_timestamp, uint32_t max_forward_step, uint32_t& new_time); + + static bool handleResult(TimeSyncStats& stats, TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], + uint8_t& last_source, bool& clock_accepted_this_boot, TimeSyncResult result, + const TimeSyncMessage& msg, uint32_t current_time, uint32_t max_forward_step, + uint32_t& new_time); + + static bool consumeGroupData(TimeSyncStats& stats, TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], + uint8_t& last_source, bool& clock_accepted_this_boot, + const TimeSyncConfigView& config, uint8_t packet_type, + const uint8_t* data, size_t len, uint32_t current_time, + uint32_t max_forward_step, uint32_t& new_time); + + static int searchChannelByHash(const TimeSyncConfigView& config, const uint8_t* hash, + mesh::GroupChannel channels[], int max_matches); +}; diff --git a/src/helpers/TimeSyncPrefsLayout.h b/src/helpers/TimeSyncPrefsLayout.h new file mode 100644 index 0000000000..0cff9a3544 --- /dev/null +++ b/src/helpers/TimeSyncPrefsLayout.h @@ -0,0 +1,22 @@ +#pragma once + +#include "TimeSyncAuth.h" + +// Time-sync preference offsets in /com_prefs. The original single-source +// fields occupy 295..428; additional sources are appended from 429 onward. +#define TIME_SYNC_PREFS_ENABLED_OFFSET 295 +#define TIME_SYNC_PREFS_CHANNEL_OFFSET 296 +#define TIME_SYNC_PREFS_CHANNEL_NAME_OFFSET 329 +#define TIME_SYNC_PREFS_DISPLAY_NAME_OFFSET 361 +#define TIME_SYNC_PREFS_PUBLIC_KEY_OFFSET 393 +#define TIME_SYNC_PREFS_MAX_FORWARD_STEP_OFFSET 425 +#define TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_OFFSET 429 +#define TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_OFFSET 525 +#define TIME_SYNC_PREFS_NEXT_OFFSET 621 + +#define TIME_SYNC_PREFS_EXTRA_SOURCE_COUNT (TIME_SYNC_MAX_SOURCES - 1) +#define TIME_SYNC_PREFS_DISPLAY_NAME_SIZE 32 +#define TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_SIZE \ + (TIME_SYNC_PREFS_EXTRA_SOURCE_COUNT * TIME_SYNC_PREFS_DISPLAY_NAME_SIZE) +#define TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_SIZE \ + (TIME_SYNC_PREFS_EXTRA_SOURCE_COUNT * PUB_KEY_SIZE) diff --git a/test/test_time_sync_auth/test_time_sync_auth.cpp b/test/test_time_sync_auth/test_time_sync_auth.cpp index bee43f9a8b..95495af96e 100644 --- a/test/test_time_sync_auth/test_time_sync_auth.cpp +++ b/test/test_time_sync_auth/test_time_sync_auth.cpp @@ -1,6 +1,9 @@ #include #include +#include +#include +#include #include #include #include @@ -26,6 +29,13 @@ static const uint8_t TEST_PUBLIC_KEY[32] = { 0xf6, 0x45, 0xb4, 0x92, 0xe9, 0x35, 0x0c, 0x10 }; +static const uint8_t OTHER_PUBLIC_KEY[32] = { + 0x7a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60, 0x71, + 0x82, 0x93, 0xa4, 0xb5, 0xc6, 0xd7, 0xe8, 0xf9, + 0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87, + 0x98, 0xa9, 0xba, 0xcb, 0xdc, 0xed, 0xfe, 0x0f +}; + static void bytesToLowerHex(char* dest, const uint8_t* src, size_t len) { // Keep vector generation independent from firmware helper visibility. static const char hex[] = "0123456789abcdef"; @@ -109,6 +119,23 @@ static size_t makeBinary(uint8_t* dest, const mesh::GroupChannel& channel, uint3 return pos; } +static size_t makeGroupDatagram(uint8_t* dest, const uint8_t* payload, size_t payload_len, + uint16_t data_type = TIME_SYNC_BINARY_DATA_TYPE) { + // Prefix the application payload exactly as BaseChatMesh::sendGroupData() + // does before Mesh encrypts the group datagram. + dest[0] = (uint8_t)(data_type & 0xFF); + dest[1] = (uint8_t)(data_type >> 8); + dest[2] = (uint8_t)payload_len; + memcpy(&dest[3], payload, payload_len); + return 3 + payload_len; +} + +static void resetConsumer(TimeSyncStats& stats, TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES], + uint8_t& last_source, bool& clock_accepted) { + memset(&stats, 0, sizeof(stats)); + TimeSyncConsumer::resetReplay(replay, last_source, clock_accepted); +} + TEST(TimeSyncAuth, RejectsInvalidDisplayNames) { // Empty and newline-bearing display names cannot be configured safely. EXPECT_FALSE(TimeSyncAuth::isValidDisplayName("")); @@ -141,6 +168,88 @@ TEST(TimeSyncAuth, AcceptsValidBinaryMessage) { EXPECT_EQ(TIME_SYNC_OK, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); EXPECT_EQ(1783862400UL, msg.timestamp); EXPECT_EQ(12345, msg.sequence); + EXPECT_EQ(0, msg.source_index); +} + +TEST(TimeSyncAuth, AcceptsConfiguredSecondarySource) { + // Multi-source parsing should return the configured slot that matched the + // display name and verified signature. + mesh::GroupChannel channel; + TimeSyncAuth::configureHashtagChannel(channel, "#time"); + TimeSyncAuthorityConfig sources[2]; + sources[0].slot_index = 0; + sources[0].display_name = "OtherBot"; + sources[0].public_key = OTHER_PUBLIC_KEY; + sources[1].slot_index = 1; + sources[1].display_name = "TimeBot"; + sources[1].public_key = TEST_PUBLIC_KEY; + + TimeSyncConfigView config; + config.enabled = true; + config.channel = channel; + config.source_count = 2; + config.sources = sources; + config.display_name = sources[0].display_name; + config.public_key = sources[0].public_key; + + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_OK, TimeSyncAuth::parseAndVerifyBinaryMulti(config, data, data_len, msg)); + EXPECT_EQ(1, msg.source_index); +} + +TEST(TimeSyncAuth, RejectsUnknownSourceDisplayName) { + // Unknown names should be counted once as a name mismatch, not tried as a + // wildcard against every configured key. + mesh::GroupChannel channel; + TimeSyncAuth::configureHashtagChannel(channel, "#time"); + TimeSyncAuthorityConfig sources[1]; + sources[0].slot_index = 0; + sources[0].display_name = "TimeBot"; + sources[0].public_key = TEST_PUBLIC_KEY; + + TimeSyncConfigView config; + config.enabled = true; + config.channel = channel; + config.source_count = 1; + config.sources = sources; + config.display_name = sources[0].display_name; + config.public_key = sources[0].public_key; + + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "UnknownBot", "UnknownBot"); + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_DISPLAY_NAME_MISMATCH, + TimeSyncAuth::parseAndVerifyBinaryMulti(config, data, data_len, msg)); +} + +TEST(TimeSyncAuth, RejectsKnownSourceSignedByWrongKey) { + // Matching a configured display name is not enough; the matching source's + // pinned public key must verify the signature. + mesh::GroupChannel channel; + TimeSyncAuth::configureHashtagChannel(channel, "#time"); + TimeSyncAuthorityConfig sources[1]; + sources[0].slot_index = 1; + sources[0].display_name = "TimeBot"; + sources[0].public_key = OTHER_PUBLIC_KEY; + + TimeSyncConfigView config; + config.enabled = true; + config.channel = channel; + config.source_count = 1; + config.sources = sources; + config.display_name = sources[0].display_name; + config.public_key = sources[0].public_key; + + uint8_t data[96]; + size_t data_len = makeBinary(data, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + + TimeSyncMessage msg; + EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, + TimeSyncAuth::parseAndVerifyBinaryMulti(config, data, data_len, msg)); } TEST(TimeSyncAuth, RejectsWrongDisplayNameAndPrefix) { @@ -229,6 +338,379 @@ TEST(TimeSyncAuth, RejectsSignatureFromAnotherChannel) { EXPECT_EQ(TIME_SYNC_SIGNATURE_INVALID, TimeSyncAuth::parseAndVerifyBinary(config, data, data_len, msg)); } +TEST(TimeSyncCli, ParsesSourceSlotCommandKeys) { + // The repeater CLI accepts get/set keys of the form source... + TimeSyncSourceSettingKey parsed; + + EXPECT_TRUE(TimeSyncAuth::parseSourceSettingKey("source.0", parsed)); + EXPECT_EQ(0, parsed.source_index); + EXPECT_STREQ("", parsed.field); + + EXPECT_TRUE(TimeSyncAuth::parseSourceSettingKey("source.1.display_name", parsed)); + EXPECT_EQ(1, parsed.source_index); + EXPECT_STREQ("display_name", parsed.field); + + EXPECT_TRUE(TimeSyncAuth::parseSourceSettingKey("source.3.public_key", parsed)); + EXPECT_EQ(3, parsed.source_index); + EXPECT_STREQ("public_key", parsed.field); + + EXPECT_TRUE(TimeSyncAuth::parseSourceSettingKey("source.2.clear", parsed)); + EXPECT_EQ(2, parsed.source_index); + EXPECT_STREQ("clear", parsed.field); + + EXPECT_FALSE(TimeSyncAuth::parseSourceSettingKey("source.4.display_name", parsed)); + EXPECT_FALSE(TimeSyncAuth::parseSourceSettingKey("source.10.display_name", parsed)); + EXPECT_FALSE(TimeSyncAuth::parseSourceSettingKey("source.x.public_key", parsed)); + EXPECT_FALSE(TimeSyncAuth::parseSourceSettingKey("sources", parsed)); +} + +TEST(TimeSyncCli, AppliesSourceDisplayNameCommand) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + strcpy(display_name, "OldName"); + memcpy(public_key, OTHER_PUBLIC_KEY, sizeof(public_key)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_OK, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "display_name", "TimeBot")); + EXPECT_STREQ("TimeBot", display_name); + EXPECT_EQ(0, memcmp(public_key, OTHER_PUBLIC_KEY, sizeof(public_key))); +} + +TEST(TimeSyncCli, RejectsInvalidSourceDisplayNameCommandWithoutMutation) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + strcpy(display_name, "OldName"); + memcpy(public_key, OTHER_PUBLIC_KEY, sizeof(public_key)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_INVALID_DISPLAY_NAME, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "display_name", "Bad\nName")); + EXPECT_STREQ("OldName", display_name); + EXPECT_EQ(0, memcmp(public_key, OTHER_PUBLIC_KEY, sizeof(public_key))); +} + +TEST(TimeSyncCli, AppliesSourcePublicKeyCommand) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + char public_key_hex[PUB_KEY_SIZE * 2 + 1]; + strcpy(display_name, "TimeBot"); + memset(public_key, 0xAA, sizeof(public_key)); + bytesToLowerHex(public_key_hex, TEST_PUBLIC_KEY, sizeof(TEST_PUBLIC_KEY)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_OK, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "public_key", public_key_hex)); + EXPECT_STREQ("TimeBot", display_name); + EXPECT_EQ(0, memcmp(public_key, TEST_PUBLIC_KEY, sizeof(public_key))); +} + +TEST(TimeSyncCli, RejectsInvalidSourcePublicKeyCommandWithoutMutation) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + strcpy(display_name, "TimeBot"); + memcpy(public_key, OTHER_PUBLIC_KEY, sizeof(public_key)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_INVALID_PUBLIC_KEY, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "public_key", "00")); + EXPECT_STREQ("TimeBot", display_name); + EXPECT_EQ(0, memcmp(public_key, OTHER_PUBLIC_KEY, sizeof(public_key))); +} + +TEST(TimeSyncCli, AppliesSourceClearCommand) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + strcpy(display_name, "TimeBot"); + memcpy(public_key, TEST_PUBLIC_KEY, sizeof(public_key)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_OK, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "clear", "yes")); + EXPECT_EQ(0, display_name[0]); + uint8_t zero_key[PUB_KEY_SIZE]; + memset(zero_key, 0, sizeof(zero_key)); + EXPECT_EQ(0, memcmp(public_key, zero_key, sizeof(public_key))); +} + +TEST(TimeSyncCli, RejectsSourceClearWithoutYes) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + strcpy(display_name, "TimeBot"); + memcpy(public_key, TEST_PUBLIC_KEY, sizeof(public_key)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_CLEAR_REQUIRES_YES, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "clear", "no")); + EXPECT_STREQ("TimeBot", display_name); + EXPECT_EQ(0, memcmp(public_key, TEST_PUBLIC_KEY, sizeof(public_key))); +} + +TEST(TimeSyncCli, RejectsUnknownSourceSettingCommand) { + char display_name[32]; + uint8_t public_key[PUB_KEY_SIZE]; + strcpy(display_name, "TimeBot"); + memcpy(public_key, TEST_PUBLIC_KEY, sizeof(public_key)); + + EXPECT_EQ(TIME_SYNC_SOURCE_SETTING_UNKNOWN_FIELD, + TimeSyncAuth::applySourceSetting(display_name, sizeof(display_name), public_key, + "unknown", "value")); + EXPECT_STREQ("TimeBot", display_name); + EXPECT_EQ(0, memcmp(public_key, TEST_PUBLIC_KEY, sizeof(public_key))); +} + +TEST(TimeSyncPersistence, KeepsAdditionalSourcesAfterLegacyFields) { + // Slot 0 remains the original single-source preference pair, while slots + // 1..3 are appended after the legacy time-sync fields for older pref files. + EXPECT_EQ(429, TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_OFFSET); + EXPECT_EQ(525, TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_OFFSET); + EXPECT_EQ(621, TIME_SYNC_PREFS_NEXT_OFFSET); + + EXPECT_EQ(TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_OFFSET, + TIME_SYNC_PREFS_MAX_FORWARD_STEP_OFFSET + (int)sizeof(uint32_t)); + EXPECT_EQ(TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_OFFSET, + TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_OFFSET + TIME_SYNC_PREFS_EXTRA_DISPLAY_NAMES_SIZE); + EXPECT_EQ(TIME_SYNC_PREFS_NEXT_OFFSET, + TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_OFFSET + TIME_SYNC_PREFS_EXTRA_PUBLIC_KEYS_SIZE); + EXPECT_EQ(TIME_SYNC_MAX_SOURCES - 1, TIME_SYNC_PREFS_EXTRA_SOURCE_COUNT); + EXPECT_EQ(32, TIME_SYNC_PREFS_DISPLAY_NAME_SIZE); +} + +TEST(TimeSyncConsumer, EnforcesClockRange) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + uint32_t new_time = 0; + + EXPECT_FALSE(TimeSyncConsumer::applyClock(stats, clock_accepted, TIME_SYNC_MIN_UNIX_TIME, + TIME_SYNC_MIN_UNIX_TIME - 1, TIME_SYNC_DEFAULT_MAX_FORWARD_STEP, + new_time)); + EXPECT_FALSE(TimeSyncConsumer::applyClock(stats, clock_accepted, TIME_SYNC_MIN_UNIX_TIME, + TIME_SYNC_MAX_UNIX_TIME + 1UL, TIME_SYNC_DEFAULT_MAX_FORWARD_STEP, + new_time)); + EXPECT_EQ(2UL, stats.stale_timestamp); + EXPECT_EQ(0UL, stats.clock_updates); +} + +TEST(TimeSyncConsumer, AllowsInitialFallbackException) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + uint32_t new_time = 0; + + EXPECT_TRUE(TimeSyncConsumer::applyClock(stats, clock_accepted, TIME_SYNC_MIN_UNIX_TIME, + TIME_SYNC_MIN_UNIX_TIME + 90000UL, + TIME_SYNC_DEFAULT_MAX_FORWARD_STEP, new_time)); + EXPECT_EQ(TIME_SYNC_MIN_UNIX_TIME + 90000UL, new_time); + EXPECT_TRUE(clock_accepted); + EXPECT_EQ(1UL, stats.clock_updates); +} + +TEST(TimeSyncConsumer, EnforcesMaximumForwardStepAfterInitialisation) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + clock_accepted = true; + uint32_t new_time = 0; + + EXPECT_FALSE(TimeSyncConsumer::applyClock(stats, clock_accepted, 1783862400UL, 1783869601UL, + 3600UL, new_time)); + EXPECT_EQ(1UL, stats.excessive_forward_step); + EXPECT_EQ(0UL, stats.clock_updates); +} + +TEST(TimeSyncConsumer, AcceptsFutureClockRecoveryWithinStep) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + clock_accepted = true; + uint32_t new_time = 0; + + EXPECT_TRUE(TimeSyncConsumer::applyClock(stats, clock_accepted, 1783862400UL, 1783862700UL, + 3600UL, new_time)); + EXPECT_EQ(1783862700UL, new_time); + EXPECT_EQ(1UL, stats.clock_updates); +} + +TEST(TimeSyncConsumer, ForwardOnlyClockLimitsReplayAfterReboot) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + + TimeSyncMessage msg; + msg.timestamp = 1783862400UL; + msg.sequence = 7; + msg.source_index = 0; + uint32_t new_time = 0; + + EXPECT_FALSE(TimeSyncConsumer::handleResult(stats, replay, last_source, clock_accepted, TIME_SYNC_OK, + msg, 1783862400UL, 3600UL, new_time)); + EXPECT_EQ(1UL, stats.stale_timestamp); + EXPECT_EQ(0UL, stats.accepted); +} + +TEST(TimeSyncConsumer, HandlesSequenceWrapOnNewerTimestamp) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + clock_accepted = true; + + replay[0].accepted_this_boot = true; + replay[0].last_timestamp = 1783862400UL; + replay[0].last_sequence = 65535; + TimeSyncMessage msg; + msg.timestamp = 1783862401UL; + msg.sequence = 0; + msg.source_index = 0; + uint32_t new_time = 0; + + EXPECT_TRUE(TimeSyncConsumer::handleResult(stats, replay, last_source, clock_accepted, TIME_SYNC_OK, + msg, 1783862400UL, 3600UL, new_time)); + EXPECT_EQ(1783862401UL, new_time); + EXPECT_EQ(0, replay[0].last_sequence); + EXPECT_EQ(1UL, stats.accepted); +} + +TEST(TimeSyncConsumer, RejectsEqualTimestampReplayAcrossSequenceWrap) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + clock_accepted = true; + + replay[0].accepted_this_boot = true; + replay[0].last_timestamp = 1783862400UL; + replay[0].last_sequence = 65535; + TimeSyncMessage msg; + msg.timestamp = 1783862400UL; + msg.sequence = 0; + msg.source_index = 0; + uint32_t new_time = 0; + + EXPECT_FALSE(TimeSyncConsumer::handleResult(stats, replay, last_source, clock_accepted, TIME_SYNC_OK, + msg, 1783862400UL, 3600UL, new_time)); + EXPECT_EQ(1UL, stats.stale_timestamp); +} + +TEST(TimeSyncConsumer, LooksUpConfiguredChannelOnly) { + mesh::GroupChannel configured; + TimeSyncConfigView config = makeConfig(configured); + TimeSyncAuthorityConfig source; + source.slot_index = 0; + source.display_name = config.display_name; + source.public_key = config.public_key; + config.source_count = 1; + config.sources = &source; + + mesh::GroupChannel out[1]; + EXPECT_EQ(1, TimeSyncConsumer::searchChannelByHash(config, configured.hash, out, 1)); + EXPECT_EQ(0, memcmp(configured.hash, out[0].hash, PATH_HASH_SIZE)); + + uint8_t wrong_hash[PATH_HASH_SIZE]; + memset(wrong_hash, configured.hash[0] ^ 0xFF, sizeof(wrong_hash)); + EXPECT_EQ(0, TimeSyncConsumer::searchChannelByHash(config, wrong_hash, out, 1)); +} + +TEST(TimeSyncConsumer, CountsParserOutcomesAccurately) { + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + TimeSyncMessage msg; + msg.timestamp = 1783862400UL; + msg.sequence = 1; + msg.source_index = 0; + uint32_t new_time = 0; + + EXPECT_FALSE(TimeSyncConsumer::handleResult(stats, replay, last_source, clock_accepted, + TIME_SYNC_DISPLAY_NAME_MISMATCH, msg, 1783862300UL, + 3600UL, new_time)); + EXPECT_FALSE(TimeSyncConsumer::handleResult(stats, replay, last_source, clock_accepted, + TIME_SYNC_SIGNATURE_INVALID, msg, 1783862300UL, + 3600UL, new_time)); + EXPECT_FALSE(TimeSyncConsumer::handleResult(stats, replay, last_source, clock_accepted, + TIME_SYNC_MALFORMED, msg, 1783862300UL, 3600UL, + new_time)); + + EXPECT_EQ(1UL, stats.display_name_mismatch); + EXPECT_EQ(1UL, stats.signature_invalid); + EXPECT_EQ(1UL, stats.malformed); +} + +TEST(TimeSyncConsumer, AppliesFullPacketReceptionToClock) { + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + TimeSyncAuthorityConfig source; + source.slot_index = 0; + source.display_name = config.display_name; + source.public_key = config.public_key; + config.source_count = 1; + config.sources = &source; + + uint8_t payload[96]; + size_t payload_len = makeBinary(payload, channel, 1783862400UL, 12345, "TimeBot", "TimeBot"); + uint8_t datagram[128]; + size_t datagram_len = makeGroupDatagram(datagram, payload, payload_len); + + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + uint32_t new_time = 0; + + EXPECT_TRUE(TimeSyncConsumer::consumeGroupData(stats, replay, last_source, clock_accepted, config, + PAYLOAD_TYPE_GRP_DATA, datagram, datagram_len, + TIME_SYNC_MIN_UNIX_TIME, 3600UL, new_time)); + EXPECT_EQ(1783862400UL, new_time); + EXPECT_EQ(1UL, stats.received); + EXPECT_EQ(1UL, stats.accepted); + EXPECT_EQ(1UL, stats.clock_updates); + EXPECT_EQ(0, last_source); +} + +TEST(TimeSyncConsumer, IgnoresNonTimeSyncDatagramsOnConfiguredChannel) { + mesh::GroupChannel channel; + TimeSyncConfigView config = makeConfig(channel); + TimeSyncAuthorityConfig source; + source.slot_index = 0; + source.display_name = config.display_name; + source.public_key = config.public_key; + config.source_count = 1; + config.sources = &source; + + uint8_t payload[] = {0xAA, 0xBB, 0xCC}; + uint8_t datagram[16]; + size_t datagram_len = makeGroupDatagram(datagram, payload, sizeof(payload), DATA_TYPE_DEV); + + TimeSyncStats stats; + TimeSyncReplayState replay[TIME_SYNC_MAX_SOURCES]; + uint8_t last_source; + bool clock_accepted; + resetConsumer(stats, replay, last_source, clock_accepted); + uint32_t new_time = 0; + + EXPECT_FALSE(TimeSyncConsumer::consumeGroupData(stats, replay, last_source, clock_accepted, config, + PAYLOAD_TYPE_GRP_DATA, datagram, datagram_len, + 1783862300UL, 3600UL, new_time)); + EXPECT_EQ(0UL, stats.received); + EXPECT_EQ(0UL, stats.malformed); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS();