Skip to content

fix(C3): persist a monotonic time floor to defeat clock-rewind replay - #10

Open
d33mobile wants to merge 2 commits into
hakierspejs:masterfrom
d33mobile:fix/c3-time-floor
Open

fix(C3): persist a monotonic time floor to defeat clock-rewind replay#10
d33mobile wants to merge 2 commits into
hakierspejs:masterfrom
d33mobile:fix/c3-time-floor

Conversation

@d33mobile

Copy link
Copy Markdown

Finding (ISSUES.md — C3, Critical)

network/ntp.c rollback_check():

if (!synced)
    return true; // no floor before first sync

synced / last_sync_unix / last_sync_monotonic_us are RAM-only statics, nothing is persisted, and the RP2040 RTC is not battery-backed — so every power cycle resets the floor completely. An attacker who observed a valid code C at time T can power-cycle the lock; boot_network() immediately syncs with synced == false, the attacker answers with T, rollback_check waves it through, the RTC is set to T, and C is valid again at the keypad.

Since in-window TOTP replay protection is an accepted risk, this persisted floor is the only control stopping a code captured hours/days earlier from being replayed, so it is load-bearing.

Fix

  • Persisted watermark. New storage_time_floor_get() / storage_time_floor_set() accessors persist a coarse "maximum unix time ever observed" value to a /timefloor littlefs file (mirrors the existing storage_wifi_* pattern). Granularity is hourly (TIME_FLOOR_GRANULARITY_S, rounded down) to bound flash wear to ≤24 writes/day.
  • Load at boot. ntp_init() loads the watermark into persisted_floor (storage is already mounted at that point — main.c runs storage_init() before ntp_init(); in recovery mode the floor stays 0 and only the existing sane band applies).
  • Enforce on every sync, including the first. rollback_check() now rejects new_time < persisted_floor before the !synced early-return, so a spoofed first-sync response can no longer rewind the clock. The in-session monotonic floor is unchanged and still applies once synced.
  • Advance the watermark. apply_time() persists a new coarse bucket whenever an accepted (and already floor-checked) time crosses into it, so it only ever moves forward.

Host-test status

make -C test asan / valgrind / coverage and ./ci --action=check (clang-format + shellcheck) all green.

  • harness_storage.c gains a time-floor set/get roundtrip + pre-mount refusal assertions, exercising the new storage accessors under ASan/UBSan + valgrind.
  • fuzz_ntp.c gains storage stubs and a C3 first-sync-floor scenario asserting that a below-watermark first sync is rejected (RTC untouched) while an above-watermark first sync is accepted and advances the coarse watermark. While wiring this up I repaired three pre-existing bugs that had kept fuzz_ntp from ever linking/running (missing wifi_is_connected stub, NULL addr passed into the ip_addr_cmp deref, and make_ntp_packet leaving stratum=0 → kiss-o-death reject). The harness now builds and runs clean (~5M execs, no crashes), so the new assertions actually execute.

Firmware/hardware behaviour (actual flash persistence across a real power cycle) is verify-on-hw — no ARM toolchain / PICO_SDK_PATH in this environment, so the RP2040 paths were validated by code review + the host tests above.

Refs ISSUES.md C3. Focused on C3 only.

🤖 Generated with Claude Code

…eplay)

network/ntp.c's rollback_check() returned true before the first sync
("no floor before first sync"), and synced/last_sync_unix are RAM-only
statics wiped by every power cycle. An attacker who captured a valid
TOTP code could power-cycle the lock and spoof the first NTP response
with an old timestamp, rewinding the RTC and replaying the code
(ISSUES.md C3).

Persist a coarse "maximum unix time ever observed" watermark to
littlefs (hourly granularity to bound flash wear) via a new
storage_time_floor_get/set accessor pair (mirrors the wifi persistence
pattern). Load it in ntp_init() at boot and enforce new_time >=
persisted_floor in rollback_check() on EVERY sync, including the first -
the !synced early-return no longer bypasses the floor. apply_time()
advances the watermark whenever an accepted time crosses into a new
hourly bucket.

Host tests: harness_storage gains a time-floor roundtrip + pre-mount
refusal (asan/valgrind/coverage green). fuzz_ntp gains storage stubs and
a C3 first-sync-floor scenario; while wiring it up, repaired three
pre-existing bugs that had kept that harness from ever linking/running
(missing wifi_is_connected stub, NULL addr passed into ip_addr_cmp, and
make_ntp_packet leaving stratum=0 -> kiss-o-death reject). It now runs
clean (~5M execs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ompiles

Rebasing onto current master pulled in a latent build break: cmd_status
declares `keys` inside the `if (commands_is_admin())` block but scrubs it
via `secure_wipe(keys, sizeof(keys))` at function scope, so `keys` is
undeclared there. asan_commands links serial/commands.c (not
commands_system.c) so it never compiled this TU, but `make -C test coverage`
compiles the whole first-party surface and fails here (`keys undeclared`) --
as does the real firmware build. Restore `keys` to function scope, matching
the scrub's intent of always clearing the key DB from BSS (incl. the
non-admin path where it stays zero-initialised). Same fix as PR hakierspejs#15 (M1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant