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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion network/ntp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions network/ntp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 6 additions & 4 deletions serial/commands_system.c
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down
41 changes: 39 additions & 2 deletions storage/storage.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions storage/storage.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
47 changes: 46 additions & 1 deletion test/fuzz_ntp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/harness_storage.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)];
Expand Down
Loading