diff --git a/network/ntp.c b/network/ntp.c index 735d2d5..1ec39af 100644 --- a/network/ntp.c +++ b/network/ntp.c @@ -2,6 +2,7 @@ #include "wifi.h" #include "hardware/buzzer.h" #include "hardware/clock.h" +#include "storage/storage.h" #include "version.h" #include "pico/cyw43_arch.h" @@ -46,13 +47,31 @@ static uint64_t last_sync_monotonic_us = 0; // Cumulative backward slack consumed since boot (see NTP_ROLLBACK_BUDGET_S). static uint32_t rollback_budget_used_s = 0; +// Coarse "maximum unix time ever observed" watermark, loaded from littlefs at +// boot (0 if never written). Enforced as an absolute floor on EVERY sync, +// including the first after a power cycle - the RAM statics above reset every +// boot, so without this a spoofed first-sync response could rewind the clock +// and replay an old TOTP code (ISSUES.md C3). +static uint32_t persisted_floor = 0; + // --------------------------------------------------------------------------- // Rollback protection // --------------------------------------------------------------------------- static bool rollback_check(uint32_t new_time) { + // Absolute floor persisted across reboots (the coarse maximum time ever + // observed). Enforced on EVERY sync INCLUDING the first: the RAM statics + // below are wiped by a power cycle, so this is the only thing stopping a + // spoofed first-sync response from rewinding the clock to replay a captured + // code (ISSUES.md C3). + if (new_time < persisted_floor) { + printf("[ntp] rollback rejected: got %u, persisted floor is %u\r\n", new_time, + persisted_floor); + return false; + } + if (!synced) - return true; // no floor before first sync + return true; // no in-session floor yet; persisted floor already enforced uint64_t elapsed_us = time_us_64() - last_sync_monotonic_us; uint32_t elapsed_s = (uint32_t)(elapsed_us / 1000000ULL); @@ -106,6 +125,18 @@ static void apply_time(uint32_t unix_time) { last_sync_monotonic_us = time_us_64(); synced = true; + // Advance the persisted watermark when the accepted time crosses into a new + // coarse bucket. rollback_check() already guaranteed unix_time >= + // persisted_floor, so this only ever moves forward; rounding DOWN to the + // bucket bounds flash writes to at most one per bucket. + uint32_t coarse = unix_time - (unix_time % TIME_FLOOR_GRANULARITY_S); + if (coarse > persisted_floor) { + if (storage_time_floor_set(coarse)) + persisted_floor = coarse; + else + printf("[ntp] warning: failed to persist time floor %u\r\n", coarse); + } + printf("[ntp] synced: unix=%u\r\n", unix_time); } @@ -236,6 +267,14 @@ static void dns_found_cb(const char *name, const ip_addr_t *ipaddr, void *arg) { void ntp_init(void) { rtc_init(); + + // Load the persisted anti-rollback watermark so the floor is enforced from + // the very first sync. Storage is already mounted by this point in boot + // (main.c runs storage_init() before ntp_init()); if it isn't (recovery + // mode), the floor stays 0 and only the sane band (ntp.h) applies. + uint32_t f; + if (storage_time_floor_get(&f)) + persisted_floor = f; } bool ntp_sync(void) { diff --git a/network/ntp.h b/network/ntp.h index 71adc67..6afe588 100644 --- a/network/ntp.h +++ b/network/ntp.h @@ -26,6 +26,14 @@ // smaller-but-still-bogus distance ahead of the monotonic projection. #define NTP_MAX_FORWARD_STEP_S 86400 // 1 day +// Granularity of the persisted anti-rollback watermark. The accepted time is +// rounded DOWN to this bucket before being written to littlefs, so at most one +// flash write per bucket bounds wear: an hourly floor costs <=24 writes/day +// while still defeating a captured-code replay (the attacker cannot rewind the +// clock past the last whole hour ever observed, even on the first sync after a +// power cycle). +#define TIME_FLOOR_GRANULARITY_S 3600 // 1 hour + // Sanity band for any timestamp accepted from the network: it must fall between // the firmware's own build time and this many years after it. Applied // unconditionally - including before the first sync, where rollback_check() diff --git a/serial/commands_system.c b/serial/commands_system.c index 4b92f9e..bdd3cac 100644 --- a/serial/commands_system.c +++ b/serial/commands_system.c @@ -64,11 +64,13 @@ void cmd_status(int argc, char **argv) { printf("ntp: not synced\r\n"); } - // Keys (admin only: key inventory is target-selection data) + // Keys (admin only: key inventory is target-selection data). Declared at + // function scope so the scrub below always runs, even on the non-admin path + // where the array stays zero-initialised. + static key_record_t keys[BACKUP_MAX_KEYS]; if (commands_is_admin()) { - static key_record_t keys[BACKUP_MAX_KEYS]; - int count = storage_key_list(keys, BACKUP_MAX_KEYS); - int enabled = 0, corrupt = 0; + int count = storage_key_list(keys, BACKUP_MAX_KEYS); + int enabled = 0, corrupt = 0; for (int i = 0; i < count; i++) { if (!keys[i].is_checksum_valid) corrupt++; diff --git a/storage/storage.c b/storage/storage.c index 454e32f..90d9452 100644 --- a/storage/storage.c +++ b/storage/storage.c @@ -176,8 +176,9 @@ static bool mounted = false; // Directory helpers // --------------------------------------------------------------------------- -#define DIR_KEYS "/keys" -#define FILE_WIFI "/wifi" +#define DIR_KEYS "/keys" +#define FILE_WIFI "/wifi" +#define FILE_TFLOOR "/timefloor" static bool ensure_dirs(void) { struct lfs_info info; @@ -273,6 +274,42 @@ bool storage_wifi_clear(void) { return lfs_remove(&lfs, FILE_WIFI) >= 0; } +// --------------------------------------------------------------------------- +// Anti-rollback time floor +// --------------------------------------------------------------------------- + +bool storage_time_floor_get(uint32_t *out) { + if (!mounted) + return false; + + lfs_file_t f; + if (lfs_file_opencfg(&lfs, &f, FILE_TFLOOR, LFS_O_RDONLY, &LFS_FILE_CFG) < 0) + return false; + + uint32_t v; + lfs_ssize_t n = lfs_file_read(&lfs, &f, &v, sizeof(v)); + lfs_file_close(&lfs, &f); + if (n != (lfs_ssize_t)sizeof(v)) + return false; + + *out = v; + return true; +} + +bool storage_time_floor_set(uint32_t floor) { + if (!mounted) + return false; + + lfs_file_t f; + int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; + if (lfs_file_opencfg(&lfs, &f, FILE_TFLOOR, flags, &LFS_FILE_CFG) < 0) + return false; + + lfs_ssize_t n = lfs_file_write(&lfs, &f, &floor, sizeof(floor)); + lfs_file_close(&lfs, &f); + return n == (lfs_ssize_t)sizeof(floor); +} + // --------------------------------------------------------------------------- // Key CRUD // --------------------------------------------------------------------------- diff --git a/storage/storage.h b/storage/storage.h index 7db4082..6518e94 100644 --- a/storage/storage.h +++ b/storage/storage.h @@ -56,6 +56,13 @@ bool storage_wifi_get(wifi_config_t *out); bool storage_wifi_set(const wifi_config_t *cfg); bool storage_wifi_clear(void); +// Persisted anti-rollback time floor: the coarse "maximum unix time ever +// observed" watermark. The NTP layer enforces it as an absolute floor that +// survives power cycles (the RP2040 RTC is not battery-backed), so a spoofed +// first-sync response cannot rewind the clock past a previously-seen time. +bool storage_time_floor_get(uint32_t *out); // false if never set / not mounted +bool storage_time_floor_set(uint32_t floor); // persist watermark; false on error + // Key CRUD bool storage_key_exists(uint16_t id); bool storage_key_get(uint16_t id, key_record_t *out); diff --git a/test/fuzz_ntp.c b/test/fuzz_ntp.c index f00eb99..6896720 100644 --- a/test/fuzz_ntp.c +++ b/test/fuzz_ntp.c @@ -144,6 +144,24 @@ bool wifi_is_connected(void) { return true; } +/* storage: the persisted anti-rollback floor (C3). Backed by a single host var + * so the harness can preset it (as if loaded from littlefs at boot) and inspect + * what apply_time() writes. */ +static uint32_t g_stored_floor; +static int g_stored_floor_valid; + +bool storage_time_floor_get(uint32_t *out) { + if (!g_stored_floor_valid) + return false; + *out = g_stored_floor; + return true; +} +bool storage_time_floor_set(uint32_t floor) { + g_stored_floor = floor; + g_stored_floor_valid = 1; + return true; +} + /* --- the target, static functions and file-static state now in scope ------ */ #include "../network/ntp.c" @@ -156,8 +174,11 @@ static void reset_ntp_state(void) { last_sync_monotonic_us = 0; rollback_budget_used_s = 0; ntp_state = NTP_STATE_IDLE; + persisted_floor = 0; g_applied_called = 0; g_applied_unix = 0; + g_stored_floor = 0; + g_stored_floor_valid = 0; } /* Drive ntp_recv_cb with a datagram: p->len == len selects the parse/too-short @@ -189,7 +210,7 @@ static void feed(const uint8_t *bytes, size_t len) { static void make_ntp_packet(uint8_t pkt[48], uint32_t unix_time) { memset(pkt, 0, 48); pkt[0] = 0x1C; /* LI=0, VN=3, Mode=4 (server) */ - pkt[1] = 1; /* stratum 1 (valid: 1..15) */ + pkt[1] = 1; /* stratum 1 (0 is kiss-o-death, rejected) */ uint32_t seconds_since_1900 = unix_time + NTP_DELTA; pkt[40] = (uint8_t)(seconds_since_1900 >> 24); pkt[41] = (uint8_t)(seconds_since_1900 >> 16); @@ -295,6 +316,30 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { assert(g_applied_called == 0); assert(ntp_state == NTP_STATE_FAILED); + /* (2c) C3: the PERSISTED floor is enforced on the FIRST sync after boot + * (synced == false). A spoofed response below the watermark is rejected and + * never reaches the RTC; one at/above it is accepted, and apply_time + * advances the persisted watermark to the accepted time's coarse bucket. */ + reset_ntp_state(); + persisted_floor = 1700000000u; /* as if loaded from littlefs at boot */ + g_time_us = 0; + + make_ntp_packet(pkt, persisted_floor - 100u); /* rewind attempt */ + feed(pkt, 48); + assert(g_applied_called == 0); /* first-sync rewind rejected */ + assert(ntp_state == NTP_STATE_FAILED); + + uint32_t forward = persisted_floor + TIME_FLOOR_GRANULARITY_S + 100u; + make_ntp_packet(pkt, forward); + feed(pkt, 48); + assert(g_applied_called == 1); /* first-sync forward accepted */ + assert(g_applied_unix == forward); + assert(ntp_state == NTP_STATE_SUCCESS); + /* watermark advanced to the accepted time's coarse (hourly) bucket */ + assert(g_stored_floor_valid); + assert(g_stored_floor == forward - (forward % TIME_FLOOR_GRANULARITY_S)); + assert(persisted_floor == g_stored_floor); + /* (3) Cheap host-safe coverage of the non-parse API that needs no live * lwip: the trivial getters, ntp_init (rtc_init stub), and ntp_task's * due-for-resync arm -- with udp_new_ip_type() stubbed to NULL, the resync diff --git a/test/harness_storage.c b/test/harness_storage.c index 45ed6df..1599876 100644 --- a/test/harness_storage.c +++ b/test/harness_storage.c @@ -130,6 +130,9 @@ int main(void) { assert(storage_wifi_get(&wtmp) == false); assert(storage_wifi_set(&wtmp) == false); assert(storage_wifi_clear() == false); + uint32_t tftmp; + assert(storage_time_floor_get(&tftmp) == false); + assert(storage_time_floor_set(1700000000u) == false); /* --- init on fresh 0xFF flash: mount fails; explicit format recovers --- */ // storage_init() no longer auto-formats a corrupt/blank device (recovery is @@ -227,6 +230,16 @@ int main(void) { assert(storage_wifi_get(&wgot) == false); /* gone */ assert(storage_wifi_clear() == false); /* already gone */ + /* --- anti-rollback time floor (C3) ------------------------------------ */ + uint32_t tfgot = 0; + assert(storage_time_floor_get(&tfgot) == false); /* never written yet */ + assert(storage_time_floor_set(1700000000u) == true); + assert(storage_time_floor_get(&tfgot) == true); + assert(tfgot == 1700000000u); + assert(storage_time_floor_set(1700003600u) == true); /* overwrite advances */ + assert(storage_time_floor_get(&tfgot) == true); + assert(tfgot == 1700003600u); + /* --- backup export -> import roundtrip -------------------------------- */ /* Current store holds keys id=1 (updated) and id=3. */ static uint8_t backup[sizeof(backup_header_t) + BACKUP_MAX_KEYS * sizeof(backup_key_t)];