Skip to content

fix(H4): bound boot NTP + core-0 watchdog with core-1 heartbeat + non-blocking latch - #14

Open
d33mobile wants to merge 2 commits into
hakierspejs:masterfrom
d33mobile:fix/h4-boot-watchdog
Open

fix(H4): bound boot NTP + core-0 watchdog with core-1 heartbeat + non-blocking latch#14
d33mobile wants to merge 2 commits into
hakierspejs:masterfrom
d33mobile:fix/h4-boot-watchdog

Conversation

@d33mobile

Copy link
Copy Markdown

ISSUES.md H4 (High)

main.c blocks forever on the first NTP sync before console_init() and the service loop, so while NTP is blocked the door is dead and there is no console recovery. The watchdog (watchdog_enable(8000, true)) is fed ONLY from core 1 (core1.c), and the RP2040 watchdog is system-wide, so core 1 keeps feeding it while core 0 is wedged, and the board never resets. An attacker who blackholes UDP/123 or poisons DNS gets a permanent DoS; the thin latch/grant timing margin (latch_open() blocks 5 s) makes the door-open path the tightest window under the 8 s watchdog.

This PR implements all three remediations. It touches core / hardware / network wiring that cannot be exercised on the host (no RP2040 here) — see the verify-on-hardware note at the end.

The three sub-fixes

1. Bound the boot NTP sync, then degrade instead of hanging (main.c, network/ntp.c)

  • boot_network() no longer loops forever on ntp_sync(). It tries BOOT_NTP_MAX_ATTEMPTS (3) times, then boots anyway into a degraded "time-not-set" state: console_init() runs and the service loop starts.
  • ntp_task() previously early-returned when !synced, so the first sync was only attempted by the boot loop. It now retries the first sync in the degraded state on a spaced NTP_DEGRADED_RETRY_S (30 s) cadence, so the device recovers once NTP is reachable again — without a blocking sync every tick starving the console.
  • Fail closed while time is unset: the door is already denied when the RTC was never set, because totp_verify() returns false when clock_get_unix_time() reports "unset". No behavioural change was needed there; it is consistent with M1's intent and is called out in the code comment.

2. Feed the watchdog from core 0, gated on a core-1 heartbeat (core1.c, core1.h, main.c)

  • New shared volatile uint32_t core1_heartbeat, bumped by core 1 on every keypad-loop iteration and while it waits on a door verdict. Core 1 no longer calls watchdog_update() directly.
  • New watchdog_feed_core0() (core-0 only) pets the watchdog only if the heartbeat advanced since the last call. It is called from the service loop and from the ntp_sync() poll loop. Result: a wedge on either core stops the watchdog being fed and resets the board.
  • watchdog_enable() is moved from the very start of boot to just before the service loop. The boot path has legitimately long single-core blocking (recovery beeps, WiFi association, the bounded NTP sync) that no single core can pet within the RP2040's ~8 s watchdog ceiling; boot is already bounded on every path, so the watchdog now guards steady state where the heartbeat scheme applies.
  • ntp_sync()'s poll loop (a core-0 wait of up to NTP_TIMEOUT_S = 15 s, over the 8 s window) now calls watchdog_feed_core0(), so a reachable-but-slow server no longer trips a reset while a genuinely wedged core still does.
  • network/wifi.c: the blocking cyw43_arch_wifi_connect_timeout_ms is reduced 15 s → 6 s. With core 0 now feeding the watchdog, a 15 s blocking connect on the wifi_task() reconnect path would trip a reset mid-attempt and reboot-loop while the AP is slow/unreachable. A failed attempt is retried on the next wifi_task() tick.

3. Non-blocking latch_open() (hardware/latch.c)

  • Energises the strike, schedules its de-energise with add_alarm_in_ms(LATCH_OPEN_DELAY, ...), and returns immediately instead of sleep_ms(5000). This removes the thin watchdog margin on the door-open path (previously the 5.2 s grant path left only ~2.8 s under the 8 s limit).
  • A repeated open within the window cancels and reschedules the close (fresh full delay, no early close); if the alarm cannot be scheduled it de-energises immediately (fail safe, never stuck open).
  • Caller core1.c drops the buzzer_on()/buzzer_off() that bracketed the old blocking call (the buzzer_play_door_open() cue still sounds). cmd_test (commands_system.c) benefits too — it no longer blocks the console for 5 s.

Host tests (CI gates) — all green

  • make -C test asan → 0
  • make -C test valgrind → 0
  • make -C test coverage → 0
  • ./ci --action=check (clang-format + shellcheck) → 0

hardware/latch.c is compiled (compile-only) by the coverage gate, so a terse host stub for the timer-alarm API (alarm_id_t, add_alarm_in_ms, cancel_alarm) was added to test/stub/pico/time.h. The libFuzzer fuzz_*.c harnesses are out of scope and untouched.

⚠️ Verify on hardware

None of this runs on the host. Please confirm on a real RP2040:

  • Watchdog: normal operation does not spuriously reset; a forced hang of either core 0 or core 1 resets the board within ~8 s; flash_safe_execute (which locks out core 1) completes well under 8 s so it does not self-reset.
  • Latch: the strike stays energised for the full LATCH_OPEN_DELAY and de-energises exactly once; two grants within the window keep it open for a fresh full delay.
  • Boot / degraded mode: with NTP blackholed, boot reaches the console and the door stays denied; once NTP is restored, ntp_task() recovers and the door works.
  • WiFi: the 6 s association cap is enough for your AP (slow APs would fail the attempt and retry every 30 s rather than block).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@d33mobile
d33mobile force-pushed the fix/h4-boot-watchdog branch from a1568e8 to 123346f Compare August 4, 2026 17:39
…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