diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 92087d5..3d6adf0 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -50,4 +50,7 @@ jobs: BirdsEye/local_time.cpp \ BirdsEye/setting_parse.cpp \ BirdsEye/ble_stream.cpp \ - BirdsEye/loop_profile.cpp + BirdsEye/loop_profile.cpp \ + BirdsEye/sha256.cpp \ + BirdsEye/remote_auth.cpp \ + BirdsEye/pin_page.cpp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd93905..d0c67e3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,7 +134,8 @@ to the matching `*_LOOP()`. untouched. Readings older than 1 s go NaN — never held across a dropout. Gated on the `BIRDSEYE_ENABLE_SENSOREGG` build flag: on in the beta channel, off in master/release, where the scanner and temp - pages are compiled out, BLE returns to lazy init, and the DOVEX + pages are compiled out, BLE init moves from boot to the first + main-menu visit (remote-transfer standby), and the DOVEX `Temp1`/`Junction1`/`Temp2` columns are written as `nan` so the log format stays identical across channels. - **NeoPixel strip** (`neopixel` + the `led_frame` / `led_modes` / @@ -375,8 +376,10 @@ an explicit `bleOwner` (NONE/TRANSFER/CAMERA); the owner model keeps a camera link from ever triggering the transfer path's auto-reboot-on-disconnect, and opening the transfer page force-releases the camera first. The link is Just-Works bonded (the genuine remote link -is encrypted), and BLE comes up lazily on the first camera action, so an -unpaired device pays nothing. +is encrypted). The camera costs nothing until its first advertising action, +but it no longer decides when BLE starts: the remote-transfer standby +(below) brings the core up on the first main-menu visit, and SensorEgg +builds start it at boot. The lifecycle is a **pure FSM** (`camera_fsm`), deliberately RPM-driven and simple: wake on engine start, connect+subscribe, record once RPM has held a @@ -439,6 +442,41 @@ moving the dates) and tzdata is ~100 KB shipped to a sealed device. `DateTime` carries a 4-digit year even though the sketch's `gpsData.year` is 2-digit. +### Remote transfer is PIN-gated, and the camera always wins + +From the main menu the logger advertises the transfer service in a +**locked** mode (plan 0019): a connected peer may run only the PIN +handshake and `BATT` until it answers a one-time 16-byte challenge with the +first half of `HMAC-SHA256(PIN, "BEAUTH1|" + nonce)`. A correct answer +promotes the link into the ordinary transfer page; a local Transfer → +Bluetooth start stays **open**, exactly as before, and is the only session +that may read the PIN back (`PINGET`), which is how an app pairs. The +lockout (5 tries, then 60 s doubling to 15 min) and the 20 s squatting limit +keep the pits out; they do not stop a sniffer, because the link is +unencrypted Just Works — a captured handshake brute-forces a 4-digit PIN +offline. That is accepted and written down in the plan, along with the +other accepted gap: an open (local) session hands the PIN to whichever app +connects first, which is why the transfer page then says "PIN sent to app!" +and why a local start first drops any link left over from the menu advert. + +A remote session must never be able to strand the logger, because nobody +at the logger started it: it ends (with an `AUTH:` notice, then the usual +reboot) when the engine starts, when the camera wants the radio, or after +10 minutes without a request. + +There is one peripheral link, shared with the Insta360 remote, and the +camera has priority at every stage. Standby only advertises while the +camera has no use for the radio, and it lets go when a paired camera's +engine passes 500 rpm (held until it drops below 300, so a pull-start +can't flap the advert) — before the camera's own 2 s wake debounce, so the +camera never finds the radio taken. Handing the radio over is synchronous: +standby releases ownership, then waits out its peer's disconnect before the +camera can claim the slot, and the disconnect callback matches the transfer +link by handle before routing on owner. Remote standby therefore never calls +`CAMERA_FORCE_RELEASE()`; only a person at the logger can bump the camera. +All of these rules are the `remote_auth` pure unit, and the 3 s hold that +reveals or replaces the PIN on the device is `pin_page`. + ### The settings file has a hard size ceiling Every settings read path caps at `sizeof(settingsFileBuffer) - 1`. A file diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 7b2e04e..1f4a05e 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -109,6 +109,8 @@ #include "replay.h" #include "sat_bars.h" #include "sd_format_page.h" +#include "pin_page.h" +#include "remote_auth.h" #include "sd_functions.h" #include "sensoregg.h" #include "settings.h" @@ -840,6 +842,21 @@ const int PAGE_DRAG_STAGING = -19; // tree countdown / run / results // all gated). const int PAGE_PAIR_EGG = -20; // egg pairing / paired-status screen const int PAGE_EGG_TEST = -21; // egg bench/live-data page (never idle-sleeps) +// Transfer → PIN (plan 0019): hold-to-reveal / hold-to-replace the remote +// transfer PIN. Driven by transferPinPageLoop() between readButtons() and +// displayLoop(); Back is an ordinary menu press. +const int PAGE_TRANSFER_PIN = -22; +pin_page::State transferPinState; +// The digits, only while the page is showing them — cleared on hide. +char transferPinDigits[remote_auth::kPinLen + 1] = ""; +// Why the last Show / New PIN produced no digits (shown on the page instead +// of a blank). A stored PIN that isn't exactly four digits is reported as +// such, never shown cut down to four. +const uint8_t TRANSFER_PIN_NOTE_NONE = 0; +const uint8_t TRANSFER_PIN_NOTE_WRITE_FAILED = 1; +const uint8_t TRANSFER_PIN_NOTE_READ_FAILED = 2; +const uint8_t TRANSFER_PIN_NOTE_INVALID = 3; +uint8_t transferPinNote = TRANSFER_PIN_NOTE_NONE; // running menu (these must be in order) #if BIRDSEYE_ENABLE_PROFILING @@ -2289,6 +2306,65 @@ void gpsStatusPageLoop() { } } +/** + * @brief Drive the Transfer → PIN page (plan 0019): the hold-to-reveal and + * hold-to-replace state machine is the host-tested pin_page unit; + * this owns the settings read/write and drops the digits from RAM + * the moment the reveal window closes. + */ +void transferPinPageLoop() { + if (currentPage != PAGE_TRANSFER_PIN) return; + + pin_page::Inputs in; + in.selectHeld = isButtonHeld(2, 0); + // A held side button is the Select+side reboot combo, never a confirm. + in.otherButtonHeld = isButtonHeld(1, 0) || isButtonHeld(3, 0); + in.row = menuSelectionIndex; + in.nowMs = millis(); + + switch (pin_page::step(transferPinState, in)) { + case pin_page::Action::kReveal: { + // Read wider than a PIN so a too-long value is caught, not truncated. + char stored[16]; + if (!getSetting("bluetooth_pin", stored, sizeof(stored))) { + transferPinNote = TRANSFER_PIN_NOTE_READ_FAILED; + } else if (!remote_auth::isValidPin(stored)) { + transferPinNote = TRANSFER_PIN_NOTE_INVALID; + } else { + transferPinNote = TRANSFER_PIN_NOTE_NONE; + strncpy(transferPinDigits, stored, sizeof(transferPinDigits)); + transferPinDigits[sizeof(transferPinDigits) - 1] = '\0'; + pin_page::reveal(transferPinState, in.nowMs); + } + memset(stored, 0, sizeof(stored)); + forceDisplayRefresh(); + break; + } + case pin_page::Action::kNewPin: { + char pin[remote_auth::kPinLen + 1]; + generateBlePin(pin); + // Persist-first: show the new PIN only once it is the stored one. + const bool written = setSetting("bluetooth_pin", pin); + transferPinNote = written ? TRANSFER_PIN_NOTE_NONE : TRANSFER_PIN_NOTE_WRITE_FAILED; + if (written) { + strncpy(transferPinDigits, pin, sizeof(transferPinDigits)); + transferPinDigits[sizeof(transferPinDigits) - 1] = '\0'; + pin_page::reveal(transferPinState, in.nowMs); + } + memset(pin, 0, sizeof(pin)); + forceDisplayRefresh(); + break; + } + case pin_page::Action::kNone: + break; + } + + if (!pin_page::isRevealed(transferPinState, in.nowMs) && transferPinDigits[0] != '\0') { + memset(transferPinDigits, 0, sizeof(transferPinDigits)); + forceDisplayRefresh(); + } +} + /** * @brief Drive the SD format-confirm boot page: step the hold-to-confirm * state machine (host-tested sd_format_page unit) and act on its @@ -2971,6 +3047,10 @@ void loop() { // When BLE is active, skip GPS/tach/lap processing for better throughput if (bleActive) { PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP()); + // A remotely started session ends when the engine starts, the camera + // wants the radio, or it sits idle 10 min (plan 0019). Runs the tach + // itself; no-op on a local session. + PROFILE_SECTION(loop_profile::kTach, bleRemoteSessionGuard()); // Keep battery voltage fresh for BLE BATT command and display if (millis() - lastBatteryCheck > batteryUpdateInterval) { @@ -3040,7 +3120,10 @@ void loop() { PROFILE_SECTION(loop_profile::kGps, GPS_LOOP()); PROFILE_SECTION(loop_profile::kTach, TACH_LOOP()); PROFILE_SECTION(loop_profile::kAccel, ACCEL_LOOP()); - PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP()); + // Menu standby advert for remote transfer (plan 0019). Before + // CAMERA_LOOP() on purpose: standby lets go of the radio the moment the + // camera wants it, so the camera never finds it taken. + PROFILE_SECTION(loop_profile::kBle, BLUETOOTH_LOOP(); BLE_STANDBY_LOOP()); // drain SensorEgg scan buffer (Temp1 fresh for logging) PROFILE_SECTION(loop_profile::kEgg, SENSOREGG_LOOP()); @@ -3157,9 +3240,10 @@ void loop() { // slot the LOOP PROFILE page needed for SLP — see loop_profile.h. // gpsStatusPageLoop: consume presses, hold/auto-close // sdFormatPageLoop: hold Select 3s to format + // transferPinPageLoop: hold Select 3s to show / replace the BLE PIN // courseCreatorLoop: feed GPS into an averaging hold PROFILE_SECTION(loop_profile::kDisplay, gpsStatusPageLoop(); - sdFormatPageLoop(); courseCreatorLoop(); + sdFormatPageLoop(); transferPinPageLoop(); courseCreatorLoop(); dragStagingLoop(); displayLoop()); resetButtons(); diff --git a/BirdsEye/bluetooth.h b/BirdsEye/bluetooth.h index 2dd9fe5..a8224c5 100644 --- a/BirdsEye/bluetooth.h +++ b/BirdsEye/bluetooth.h @@ -85,6 +85,38 @@ void bleShutdownQuiesce(); // burst-send chunk pipeline for any active file transfer. void BLUETOOTH_LOOP(); +// --- Remote transfer (plan 0019) ------------------------------------------- +// While the logger idles on its main menu it advertises the transfer +// service in LOCKED mode: a peer may only run the PIN handshake (AUTH?, +// AUTH:) and BATT until it answers correctly, at which point the +// session is promoted into the normal transfer page. A local Transfer → +// Bluetooth start stays OPEN, as before. The camera always wins the radio: +// standby never advertises while it wants it, and a remote session ends +// when it starts to. All the rules live in the host-tested remote_auth unit. + +// Run the menu standby advert: start/stop it as the page, the camera, a +// race and the remote_transfer setting allow; service the handshake; drop +// a peer that hasn't authenticated within 20 s; promote on AUTH:OK. Called +// every main-loop iteration, before CAMERA_LOOP() so the radio is already +// free whenever the camera reaches for it. +void BLE_STANDBY_LOOP(); + +// Called from the parked transfer branch. On a REMOTE session only, keeps +// the tachometer running and ends the session — an AUTH: notice, then the +// usual reboot — when the engine starts (AUTH:ENGINE, camera paired or +// not), when the camera wants the radio back (AUTH:CAMERA), or after 10 +// minutes without a request (AUTH:IDLE). Nobody at the logger started a +// remote session, so it must never be able to park the logger for good. +void bleRemoteSessionGuard(); + +// True while the transfer page is serving a session that started remotely. +bool bleRemoteSessionActive(); + +// True once this (local) transfer session has handed out the PIN over +// PINGET — the transfer page shows it, so whoever is standing at the +// logger knows the connected app can now start remote transfers. +bool blePinSentThisSession(); + // --- Transfer diagnostics (read by the Bluetooth page) --------------------- // These exist so a download-speed regression is visible on the device instead // of being inferred from a progress bar. See diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino index e2e7921..7730598 100644 --- a/BirdsEye/bluetooth.ino +++ b/BirdsEye/bluetooth.ino @@ -8,6 +8,7 @@ #include "camera_ble.h" #include "filename_validator.h" #include "firmware_ota.h" +#include "remote_auth.h" // For SENSOREGG_SLEEP() — BLE_SETUP() quiesces the egg scanner so the // transfer link never shares the radio with a scan window (plan 0012). #include "sensoregg.h" @@ -102,6 +103,48 @@ static bool deferFileCommand(const char* cmd) { return true; } +// ---- Remote transfer (plan 0019) ---- +// Lockout + nonce + per-connection auth. RAM only, so a reboot clears a +// lockout — which takes the device in hand anyway. +static remote_auth::State bleAuth; +// Open = transfer started on the device (everything allowed, PINGET too); +// locked = the menu standby advert (handshake first). Read by the callback. +static volatile remote_auth::Mode bleSessionMode = remote_auth::Mode::kOpen; +// The menu standby advert is up (bleOwner == TRANSFER, bleActive false). +static bool bleStandbyActive = false; +// The transfer page is serving a session promoted from standby. +static bool bleRemoteSession = false; +// When the current peripheral peer connected, for the 20 s auth limit. +static volatile uint32_t bleConnectAtMs = 0; +// Our own peripheral link. Bluefruit.connHandle() is the LAST connection of +// any role, which on a SensorEgg build can be the egg's central link. +static volatile uint16_t bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; +// remote_transfer setting, read once (it only changes over BLE, and every +// BLE session ends in a reboot): -1 unread, 0 off, 1 on. +static int8_t bleRemoteTransferSetting = -1; +// Deferred handshake command (needs the RNG and the PIN off the card, so it +// runs on the main loop like every other SD-touching command). +static volatile bool authCmdPending = false; +static char authCmdBuffer[65]; +// A link we are in the middle of dropping. Writes from it are ignored (so a +// peer on its way out can never act in whatever mode comes next), and its +// disconnect event is routed to the transfer path by this handle even if it +// lands after the owner has moved on. +static volatile uint16_t bleDroppingConnHandle = BLE_CONN_HANDLE_INVALID; +// Bounded wait for a local disconnect to be confirmed (WDT-fed). A local +// terminate normally lands in a few connection intervals. +static const uint32_t BLE_DROP_SETTLE_MS = 1000; +// Last write to the request characteristic — the remote-session idle clock. +static volatile uint32_t bleLastRequestMs = 0; +// Hysteresis state for a paired camera's engine claim (remote_auth). +static bool bleCameraEngineLatch = false; +// Post-squat quiet period for the menu advert (remote_auth::kSquatBackoffMs). +static bool bleSquatBackoffArmed = false; +static uint32_t bleSquatDroppedAtMs = 0; +// PINGET was served this session — the transfer page says so, so a person +// at the logger sees that whoever is connected now holds the PIN. +static volatile bool blePinServed = false; + // Set by the disconnect callback; BLUETOOTH_LOOP() performs the SD teardown // (close transfer/staging file, release SD, abort OTA) and the auto-reboot // on the main loop, so SdFat is only ever touched by one task. @@ -128,6 +171,9 @@ void bleConnectCallback(uint16_t conn_handle) { debugln(F("BLE: Device connected!")); bleConnected = true; bleTransferEngaged = false; // this peer hasn't used the service yet + bleTransferConnHandle = conn_handle; + bleConnectAtMs = millis(); + remote_auth::onDisconnect(bleAuth); // a new peer starts unauthenticated BLEConnection* connection = Bluefruit.Connection(conn_handle); @@ -238,13 +284,32 @@ void bleDisconnectCallback(uint16_t conn_handle, uint8_t reason) { // already moved to NONE/TRANSFER (e.g. CAMERA_FORCE_RELEASE() // immediately followed by BLE_SETUP() on the transfer page) — owner // routing alone would misdeliver it here and reboot the device. - if (cameraBleOwnsConnHandle(conn_handle) || bleOwner == BLE_OWNER_CAMERA) { + // + // Our own transfer/standby link is matched by handle FIRST, for the + // mirror-image reason: the menu standby can hand the radio to the camera + // while its peer's disconnect is still in flight, and owner routing would + // then deliver that peer to the camera module, leaving bleConnected and + // the stale handle behind. + const bool transferLink = conn_handle == bleTransferConnHandle || + conn_handle == bleDroppingConnHandle; + if (!transferLink && + (cameraBleOwnsConnHandle(conn_handle) || bleOwner == BLE_OWNER_CAMERA)) { cameraBleOnDisconnect(conn_handle, reason); return; } debugln(F("BLE: Disconnected!")); + if (conn_handle == bleDroppingConnHandle) bleDroppingConnHandle = BLE_CONN_HANDLE_INVALID; + // A link we had already let go of, while a different peer is current: + // nothing below belongs to it. + if (bleTransferConnHandle != BLE_CONN_HANDLE_INVALID && conn_handle != bleTransferConnHandle) { + return; + } bleConnected = false; + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + // The next peer must authenticate from scratch. The lockout survives. + remote_auth::onDisconnect(bleAuth); + authCmdPending = false; bleNegotiatedMtu = 23; // Reset to default bleLinkTuneStage = 0; // abandon any pending link tuning for this peer bleLinkDataLen = 27; // pre-DLE default @@ -576,6 +641,9 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* // peer that connects during camera mode must not queue deferred SD work — // BLUETOOTH_LOOP() is gated on bleActive and would never drain it. if (bleOwner != BLE_OWNER_TRANSFER) return; + // A link being dropped gets nothing more (plan 0019). + if (conn_hdl == bleDroppingConnHandle) return; + bleLastRequestMs = millis(); // A write to the request characteristic means this is a genuine transfer // peer (the phone app), not a bonded camera vetting our GATT — arm the @@ -638,6 +706,30 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* debug(buffer); debugln(F("]")); + // Plan 0019: a remotely started (locked) session may only run the PIN + // handshake and BATT until it has answered correctly. + if (!remote_auth::commandAllowed(bleSessionMode, bleAuth.authed, buffer)) { + fileStatusChar.notify((uint8_t*)"AUTH:REQUIRED", 13); + return; + } + + if (strcmp(buffer, "AUTH?") == 0 || strncmp(buffer, "AUTH:", 5) == 0) { + // Nothing to prove on a local start, or after a correct answer. + if (bleSessionMode == remote_auth::Mode::kOpen) { + fileStatusChar.notify((uint8_t*)"AUTH:OPEN", 9); + } else if (bleAuth.authed) { + fileStatusChar.notify((uint8_t*)"AUTH:OK", 7); + } else if (authCmdPending) { + fileStatusChar.notify((uint8_t*)"AUTH:BUSY", 9); + } else { + // Needs the RNG and the PIN off the card: main loop. + strncpy(authCmdBuffer, buffer, sizeof(authCmdBuffer) - 1); + authCmdBuffer[sizeof(authCmdBuffer) - 1] = '\0'; + authCmdPending = true; + } + return; + } + // File commands (LIST/GET/DELETE/TLIST/TGET) all touch SD, so they are // DEFERRED to BLUETOOTH_LOOP() via deferFileCommand() — SdFat must never // run in this Bluefruit callback task. Filename validation is RAM-only @@ -674,7 +766,8 @@ void bleFileRequestCallback(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* } else if (strcmp(buffer, "SLIST") == 0 || strncmp(buffer, "SGET:", 5) == 0 || strncmp(buffer, "SSET:", 5) == 0 || - strcmp(buffer, "SRESET") == 0) { + strcmp(buffer, "SRESET") == 0 || + strcmp(buffer, "PINGET") == 0) { // Settings commands — defer to main loop for thread-safe SD access if (settingsCmdPending) { fileStatusChar.notify((uint8_t*)"SBUSY", 5); @@ -857,6 +950,269 @@ void bleCoreEnsureInit() { // owner (transfer page or camera module) applies its own advert set. } +/////////////////////////////////////////// +// REMOTE TRANSFER STANDBY (plan 0019) +/////////////////////////////////////////// + +static bool bleRemoteTransferEnabled() { + if (bleRemoteTransferSetting < 0) { + char v[8]; + // No card means no PIN to check against, so nothing to offer. + bleRemoteTransferSetting = + (sdSetupSuccess && + !(getSetting("remote_transfer", v, sizeof(v)) && strcmp(v, "off") == 0)) + ? 1 + : 0; + } + return bleRemoteTransferSetting == 1; +} + +static bool bleCameraWantsRadio() { + remote_auth::CameraInputs cam; + const camera_fsm::State st = cameraFsmState(); + cam.fsmIdleOrUnpaired = + st == camera_fsm::State::kUnpaired || st == camera_fsm::State::kIdle; + cam.testPageOpen = cameraTestModeActive(); + cam.ownsRadio = bleOwner == BLE_OWNER_CAMERA; + cam.paired = cameraIsPaired(); + cam.rpm = (uint16_t)(tachLastReported > 65535 ? 65535 : tachLastReported); + return remote_auth::cameraWantsRadio(cam, bleCameraEngineLatch); +} + +// Send an unsolicited AUTH: notice and give it a moment to reach the app +// before the link drops. nullptr = drop silently. +static void bleNotifyDropReason(const char* token) { + if (token == nullptr) return; + fileStatusChar.notify((uint8_t*)token, strlen(token)); + delay(50); +} + +// Drop our transfer/standby link, if any, and wait (bounded, WDT-fed) for +// the stack to confirm it. Main-loop only. While it is going, the link is +// marked as dropping: its writes are ignored and its disconnect event is +// routed here by handle, so neither can leak into whatever mode or owner +// the caller sets next — even if the confirmation outlives the wait. +static void bleDropTransferLink(const char* reason) { + const uint16_t handle = bleTransferConnHandle; + if (handle == BLE_CONN_HANDLE_INVALID) return; + BLEConnection* connection = Bluefruit.Connection(handle); + if (!connection || !connection->connected()) { + // Already gone (its event was handled elsewhere): just forget it. + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + bleConnected = false; + return; + } + bleNotifyDropReason(reason); + bleDroppingConnHandle = handle; + Bluefruit.disconnect(handle); + const uint32_t t0 = millis(); + while (bleTransferConnHandle == handle && (uint32_t)(millis() - t0) < BLE_DROP_SETTLE_MS) { + wdtPet(); + delay(10); + } + if (bleTransferConnHandle == handle) debugln(F("BLE: drop not confirmed in time")); +} + +// Every queued command and half-finished exchange from a previous peer or +// session. Main-loop only; nothing here touches SD. +static void bleClearPendingCommands() { + authCmdPending = false; + settingsCmdPending = false; + fileCmdPending = false; + trackDeletePending = false; + trackUploadActive = false; + trackUploadReady = false; + trackUploadComplete = false; + trackUploadError = false; + remote_auth::onDisconnect(bleAuth); +} + +static void bleStandbyStart() { + bleCoreEnsureInit(); + // Start from a clean slate: no peer from an earlier owner can still be + // counted as ours (a disconnect misrouted or not yet landed would leave + // bleConnected and the handle stale, and a stale handle can be reused). + bleDropTransferLink(nullptr); + bleConnected = false; + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + bleSessionMode = remote_auth::Mode::kLocked; + bleRemoteSession = false; + bleClearPendingCommands(); + bleOwner = BLE_OWNER_TRANSFER; + bleApplyTransferAdvertising(); + // No blue LED for an idle menu advert — that LED means "transfer page". + bleConnLedOff(); + bleStandbyActive = true; + debugln(F("BLE: remote standby advertising")); +} + +// `reason` is the AUTH: notice a connected, unauthenticated peer gets +// before it is dropped (nullptr = none). +static void bleStandbyStop(const char* reason) { + bleStandbyActive = false; + // Owner FIRST. Once it is NONE the request callback ignores every write, + // so a command that preempts the rest of this teardown can never run — + // in particular not in the open mode set at the end. + bleOwner = BLE_OWNER_NONE; + // Disarm BEFORE the async disconnect, or Bluefruit restarts an ownerless + // advert behind us (see BLE_STOP()). + Bluefruit.Advertising.restartOnDisconnect(false); + Bluefruit.Advertising.stop(); + // Wait the disconnect out before anyone else (the camera, in the same + // loop iteration) can take the radio, so its event can't be misrouted. + bleDropTransferLink(reason); + bleClearPendingCommands(); + bleSessionMode = remote_auth::Mode::kOpen; + debugln(F("BLE: remote standby off")); +} + +// AUTH:OK on the menu advert: become the transfer page, exactly as a local +// start would, except the session stays locked-and-authenticated (so +// PINGET stays off) and is labelled remote. +static void bleStandbyPromote() { + if (!bleConnected) return; // peer left between the answer and here + bleStandbyActive = false; + bleRemoteSession = true; + bleLastRequestMs = millis(); // the idle clock starts at the handshake + blePinServed = false; + sdSetTransferSpeed(true); + SENSOREGG_SLEEP(); // same guarantee BLE_SETUP() gives the transfer link + Bluefruit.autoConnLed(true); + Bluefruit.setConnLedInterval(250); + bleActive = true; + switchToDisplayPage(PAGE_BLUETOOTH); + debugln(F("BLE: remote transfer authenticated")); +} + +// Handshake commands, deferred from the callback (main-loop context). +static void bleProcessAuthCommand() { + if (!authCmdPending) return; + char cmd[sizeof(authCmdBuffer)]; + strncpy(cmd, authCmdBuffer, sizeof(cmd)); + cmd[sizeof(cmd) - 1] = '\0'; + authCmdPending = false; + + // Promoted already (or never locked): nothing to prove. + if (bleSessionMode != remote_auth::Mode::kLocked || bleAuth.authed) return; + + const uint32_t now = millis(); + char reply[48]; + if (remote_auth::isLocked(bleAuth, now)) { + snprintf(reply, sizeof(reply), "AUTH:LOCKED:%lu", + (unsigned long)remote_auth::lockSecondsLeft(bleAuth, now)); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + return; + } + + if (strcmp(cmd, "AUTH?") == 0) { + uint8_t nonce[remote_auth::kNonceLen]; + if (!hwRandomBytes(nonce, sizeof(nonce))) { + fileStatusChar.notify((uint8_t*)"AUTH:BUSY", 9); + return; + } + remote_auth::issueNonce(bleAuth, nonce); + snprintf(reply, sizeof(reply), "AUTH:NONCE:%s", bleAuth.nonceHex); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + return; + } + + // AUTH:. An unreadable or malformed stored PIN (SD refused, a + // corrupt file, a hand-edited number) is kPinUnavailable: AUTH:BUSY, the + // nonce kept and no failure counted, so the app can simply retry. + char pin[16]; + if (!getSetting("bluetooth_pin", pin, sizeof(pin))) pin[0] = '\0'; + const remote_auth::Verdict v = remote_auth::checkAnswer(bleAuth, cmd + 5, pin, now); + memset(pin, 0, sizeof(pin)); + switch (v) { + case remote_auth::Verdict::kOk: + fileStatusChar.notify((uint8_t*)"AUTH:OK", 7); + bleStandbyPromote(); + break; + case remote_auth::Verdict::kFail: + snprintf(reply, sizeof(reply), "AUTH:FAIL:%u", (unsigned)remote_auth::triesLeft(bleAuth)); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + break; + case remote_auth::Verdict::kLocked: + snprintf(reply, sizeof(reply), "AUTH:LOCKED:%lu", + (unsigned long)remote_auth::lockSecondsLeft(bleAuth, now)); + fileStatusChar.notify((uint8_t*)reply, strlen(reply)); + break; + case remote_auth::Verdict::kNoNonce: + fileStatusChar.notify((uint8_t*)"AUTH:NO_NONCE", 13); + break; + case remote_auth::Verdict::kPinUnavailable: + debugln(F("BLE: stored PIN unreadable or invalid")); + fileStatusChar.notify((uint8_t*)"AUTH:BUSY", 9); + break; + } +} + +void BLE_STANDBY_LOOP() { + const bool onMenu = currentPage == PAGE_MAIN_MENU; + const bool cameraWants = bleCameraWantsRadio(); + const uint32_t now = millis(); + + remote_auth::StandbyInputs in; + in.onMainMenu = onMenu; + in.radioFree = bleOwner == BLE_OWNER_NONE || + (bleStandbyActive && bleOwner == BLE_OWNER_TRANSFER); + in.cameraWantsRadio = cameraWants; + in.raceActive = raceActive; + in.squatBackoff = remote_auth::squatBackoffActive(bleSquatBackoffArmed, bleSquatDroppedAtMs, now); + // Only read the setting (an SD read, once) when it could matter. + in.settingEnabled = onMenu && bleRemoteTransferEnabled(); + + const bool wanted = remote_auth::standbyWanted(in); + if (!bleStandbyActive) { + if (wanted) bleStandbyStart(); + return; + } + if (!wanted) { + bleStandbyStop(cameraWants ? "AUTH:CAMERA" : nullptr); + return; + } + + bleProcessAuthCommand(); + if (!bleStandbyActive) return; // promoted + + // Nobody gets to sit on the only peripheral slot without the PIN. The + // advert then stays down for a short back-off, or a squatter (or a + // bonded camera chasing our address) just reconnects and repeats. + if (bleTransferConnHandle != BLE_CONN_HANDLE_INVALID && !bleAuth.authed && + remote_auth::authTimedOut(bleConnectAtMs, now)) { + debugln(F("BLE: remote peer never authenticated, dropping")); + bleStandbyStop("AUTH:TIMEOUT"); + bleSquatBackoffArmed = true; + bleSquatDroppedAtMs = millis(); + } +} + +void bleRemoteSessionGuard() { + if (!bleRemoteSession) return; + // The parked branch skips the normal pipeline, tach included — and the + // engine starting is exactly what has to end this session. + TACH_LOOP(); + + remote_auth::RemoteSessionInputs in; + in.otaApplyPending = fwApplyRequested(); + in.rpm = (uint16_t)(tachLastReported > 65535 ? 65535 : tachLastReported); + in.cameraWantsRadio = bleCameraWantsRadio(); + in.busy = bleTransferInProgress || trackUploadActive || fwReceiving(); + in.lastRequestMs = bleLastRequestMs; + in.nowMs = millis(); + const remote_auth::RemoteEnd end = remote_auth::remoteSessionEnd(in); + if (end == remote_auth::RemoteEnd::kNone) return; + + debug(F("BLE: ending remote transfer: ")); + debugln(remote_auth::remoteEndToken(end)); + bleNotifyDropReason(remote_auth::remoteEndToken(end)); + bleExitTransferMode(); +} + +bool bleRemoteSessionActive() { return bleActive && bleRemoteSession; } + +bool blePinSentThisSession() { return bleActive && blePinServed; } + void BLE_SETUP() { // Parked transfer — bump the SD clock for faster file transfers. Reverted // in BLE_STOP() (and by the auto-reboot on phone disconnect). @@ -874,8 +1230,26 @@ void BLE_SETUP() { debugln(F("BLE: Starting transfer mode...")); + // A local start takes the radio over from the menu standby advert (the + // Transfer page is not the main menu, so standby has normally stopped + // already) and is OPEN: someone at the logger chose to start it. + if (bleStandbyActive) bleStandbyStop(nullptr); + bleCoreEnsureInit(); + // An OPEN session hands out the PIN (PINGET), so it must begin with + // nobody connected: a standby peer whose link outlived the menu would + // otherwise be promoted, unauthenticated, into full access. Then forget + // everything a previous peer or session left queued. + bleDropTransferLink(nullptr); + bleConnected = false; + bleTransferConnHandle = BLE_CONN_HANDLE_INVALID; + bleClearPendingCommands(); + bleDisconnectCleanupPending = false; + blePinServed = false; + bleSessionMode = remote_auth::Mode::kOpen; + bleRemoteSession = false; + // Take the radio for the transfer service (main-loop context — the camera // module released its links via CAMERA_FORCE_RELEASE() before this page // opened). The full advert rebuild below is what makes re-entry correct @@ -932,7 +1306,9 @@ void BLE_STOP() { // Disconnect any connected device if (Bluefruit.connected()) { - Bluefruit.disconnect(Bluefruit.connHandle()); + Bluefruit.disconnect(bleTransferConnHandle != BLE_CONN_HANDLE_INVALID + ? (uint16_t)bleTransferConnHandle + : Bluefruit.connHandle()); // BLE disconnect is async; no delay needed - stack handles it } @@ -948,6 +1324,8 @@ void BLE_STOP() { // Release radio ownership. The camera module re-acquires it on its next // advertising action (in CAMERA_LOOP()) — nothing to hand off here. bleOwner = BLE_OWNER_NONE; + bleRemoteSession = false; + bleSessionMode = remote_auth::Mode::kOpen; // Restore the EMI-safe SD clock now that the transfer session is over. sdSetTransferSpeed(false); @@ -1019,6 +1397,13 @@ void bleShutdownQuiesce() { } } bleConnLedOff(); // pre-begin() it only parks the pin (see its guard) + // The charging loop can soft-resume to the main menu without a reboot; + // standby must then start from scratch, not believe its advert is up. + if (bleStandbyActive) { + bleStandbyActive = false; + bleSessionMode = remote_auth::Mode::kOpen; + if (bleOwner == BLE_OWNER_TRANSFER) bleOwner = BLE_OWNER_NONE; + } } // Execute a deferred file command (main-loop context — the only place @@ -1110,6 +1495,10 @@ void processSettingsCommand() { int count = 0; for (JsonPair kv : doc.as()) { + // The PIN never leaves the logger through SLIST (plan 0019): anyone + // connected could otherwise read the secret that gates them. PINGET + // hands it out, on a local start only. + if (remote_auth::isProtectedSettingKey(kv.key().c_str())) continue; char entry[64]; // A hand-edited SETTINGS.json can hold a non-string value, and // as() returns NULL for those — %s on NULL streams @@ -1135,6 +1524,11 @@ void processSettingsCommand() { debug(key); debugln(F("]")); + if (remote_auth::isProtectedSettingKey(key)) { + fileStatusChar.notify((uint8_t*)"SERR:PROTECTED", 14); + return; + } + char valueBuf[48]; if (getSetting(key, valueBuf, sizeof(valueBuf))) { char response[64]; @@ -1159,10 +1553,17 @@ void processSettingsCommand() { char* key = payload; char* value = eq + 1; + // The PIN is the one write with a format: the handshake only ever + // accepts exactly four digits, so anything else would lock remote + // transfer out for good. + if (remote_auth::isProtectedSettingKey(key) && !remote_auth::isValidPin(value)) { + debugln(F("BLE: SSET - rejected PIN format")); + fileStatusChar.notify((uint8_t*)"SERR:BAD_VALUE", 14); + return; + } + debug(F("BLE: SSET - key: [")); debug(key); - debug(F("] value: [")); - debug(value); debugln(F("]")); if (setSetting(key, value)) { @@ -1175,6 +1576,28 @@ void processSettingsCommand() { debugln(F("BLE: SSET - write failed")); fileStatusChar.notify((uint8_t*)"SERR:WRITE_FAIL", 15); } + } else if (strcmp(settingsCmdBuffer, "PINGET") == 0) { + // Local start only — the callback already refuses it on a locked + // session; checked again because this is the one command that hands + // out the secret. + if (bleSessionMode != remote_auth::Mode::kOpen) { + fileStatusChar.notify((uint8_t*)"AUTH:REQUIRED", 13); + return; + } + char pin[16]; + if (getSetting("bluetooth_pin", pin, sizeof(pin)) && remote_auth::isValidPin(pin)) { + char response[24]; + snprintf(response, sizeof(response), "PIN:%s", pin); + fileStatusChar.notify((uint8_t*)response, strlen(response)); + memset(response, 0, sizeof(response)); + // Whoever is connected now holds the PIN; the transfer page says so. + blePinServed = true; + } else { + // Unreadable, or a value the handshake could never accept — handing + // that out would only pair the app to a PIN that cannot work. + fileStatusChar.notify((uint8_t*)"SERR:NOT_FOUND", 14); + } + memset(pin, 0, sizeof(pin)); } else if (strcmp(settingsCmdBuffer, "SRESET") == 0) { debugln(F("BLE: SRESET - resetting all settings to defaults")); if (resetSettings()) { diff --git a/BirdsEye/camera_ble.h b/BirdsEye/camera_ble.h index 48bcf1a..94baf11 100644 --- a/BirdsEye/camera_ble.h +++ b/BirdsEye/camera_ble.h @@ -104,6 +104,10 @@ bool cameraIsPaired(); // camera_fsm::stateName()). camera_fsm::State cameraFsmState(); +// True while the camera bench-test page is open. The bench page drives the +// radio by hand, so remote transfer standby must stay off it (plan 0019). +bool cameraTestModeActive(); + // True while the camera is connected to our remote service. bool cameraRemoteLinkUp(); diff --git a/BirdsEye/camera_ble.ino b/BirdsEye/camera_ble.ino index ed41159..45f29e7 100644 --- a/BirdsEye/camera_ble.ino +++ b/BirdsEye/camera_ble.ino @@ -961,6 +961,8 @@ void cameraTestExitMode() { debugln(F("CAM: bench-test mode exited")); } +bool cameraTestModeActive() { return cameraTestActive; } + bool cameraTestWake() { if (!cameraTestActive) return false; // Present the wake / remote-identity advert (only reaches an ARMED diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index e35e3a8..07275f8 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -18,6 +18,7 @@ void displayPage_gps_status(); void displayPage_main_menu(); void displayPage_bluetooth(); void displayPage_transfer_menu(); +void displayPage_transfer_pin(); void displayPage_usb_storage(); void displayPage_pair_camera(); void displayPage_camera_test(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 9878759..ce322f4 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -9,6 +9,7 @@ #include "lap_format.h" #include "sat_bars.h" #include "sd_format_page.h" +#include "pin_page.h" #include "sd_functions.h" #include "wake_cause.h" #include "nan_bits.h" @@ -404,7 +405,19 @@ void displayPage_bluetooth() { resetDisplay(); display.setTextSize(1); - display.println(F(" Bluetooth Connection")); + // A session the app started from the menu (plan 0019) says so, so a + // transfer page nobody here opened never looks like a malfunction. + if (bleRemoteSessionActive()) { + display.println(F(" Remote Transfer")); + } else if (blePinSentThisSession()) { + // A local session hands the PIN to whoever asks (PINGET), and the first + // app in range is not necessarily the owner's. The title says so for + // the rest of the session — it is the one line a transfer never + // repurposes — so the person who opened it can see it happened. + display.println(F(" PIN sent to app!")); + } else { + display.println(F(" Bluetooth Connection")); + } display.println(); display.setTextSize(2); @@ -467,20 +480,76 @@ void displayPage_bluetooth() { void displayPage_transfer_menu() { resetDisplay(); + // Four rows since plan 0019 added PIN, so this uses the drag-distance + // scrolling window (title 8 px + 3 size-2 rows + hint line). + // + // Back matters more here than on most menus: both transfer modes leave by + // rebooting, and this page is not the main menu so the idle-shutdown timer + // never runs on it. Without this row, opening Transfer by mistake could + // only be undone with the (unlabelled) reboot combo. + static const char* const kItems[] = {"Bluetooth", "USB", "PIN", "Back"}; + const int itemCount = (int)(sizeof(kItems) / sizeof(kItems[0])); + const int visibleRows = 3; + int first = menuSelectionIndex - 1; + if (first < 0) first = 0; + if (first > itemCount - visibleRows) first = itemCount - visibleRows; + display.setTextSize(1); display.println(F(" Transfer Mode")); + display.setTextSize(2); + for (int i = first; i < first + visibleRows; i++) { + display.print(menuSelectionIndex == i ? "->" : " "); + display.println(kItems[i]); + } + + display.setTextSize(1); + display.print(first > 0 ? F("^") : F(" ")); + if (first + visibleRows < itemCount) display.print(F(" v more")); + + safeDisplayUpdate(); +} + +// Transfer → PIN (plan 0019). Hidden until Select is held 3 s on Show; the +// digits then stay up for 15 s. Never on the transfer page itself. +void displayPage_transfer_pin() { + resetDisplay(); + const uint32_t now = millis(); + + display.setTextSize(1); + display.println(F(" Transfer PIN")); display.println(); + display.setTextSize(2); + const bool shown = pin_page::isRevealed(transferPinState, now) && + transferPinDigits[0] != '\0'; + display.print(F(" ")); + display.println(shown ? transferPinDigits : "****"); - display.print(menuSelectionIndex == 0 ? "->" : " "); - display.println(F("Bluetooth")); - display.print(menuSelectionIndex == 1 ? "->" : " "); - display.println(F("USB")); - // Back matters more here than on most menus: both transfer modes leave by - // rebooting, and this page is not the main menu so the idle-shutdown timer - // never runs on it. Without this row, opening Transfer by mistake could - // only be undone with the (unlabelled) reboot combo. - display.print(menuSelectionIndex == 2 ? "->" : " "); + display.setTextSize(1); + const uint32_t holdLeft = pin_page::holdSecondsLeft(transferPinState, now); + if (holdLeft > 0) { + display.print(F("keep holding ")); + display.print(holdLeft); + display.println(F("s")); + } else if (shown) { + display.print(F("hides in ")); + display.print(pin_page::revealSecondsLeft(transferPinState, now)); + display.println(F("s")); + } else if (transferPinNote == TRANSFER_PIN_NOTE_WRITE_FAILED) { + display.println(F("PIN write failed")); + } else if (transferPinNote == TRANSFER_PIN_NOTE_READ_FAILED) { + display.println(F("PIN read failed")); + } else if (transferPinNote == TRANSFER_PIN_NOTE_INVALID) { + display.println(F("PIN invalid: New PIN")); + } else { + display.println(F("hold SELECT 3s")); + } + + display.print(menuSelectionIndex == pin_page::kRowShow ? "->" : " "); + display.println(F("Show PIN")); + display.print(menuSelectionIndex == pin_page::kRowNewPin ? "->" : " "); + display.println(F("New PIN")); + display.print(menuSelectionIndex == pin_page::kRowBack ? "->" : " "); display.println(F("Back")); safeDisplayUpdate(); diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 88d8159..67972dd 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -516,10 +516,20 @@ void handleMenuPageSelection() { forceDisplayRefresh(); #endif } else if (currentPage == PAGE_TRANSFER_MENU) { - if (menuSelectionIndex == 2) { + if (menuSelectionIndex == 3) { // Back — the only non-rebooting way off this page. debugln(F("Transfer: Back selected")); switchToDisplayPage(PAGE_MAIN_MENU); + } else if (menuSelectionIndex == 2) { + // PIN — the remote-transfer PIN, behind a hold (plan 0019). + debugln(F("Transfer: PIN selected")); + pin_page::begin(transferPinState); + transferPinNote = TRANSFER_PIN_NOTE_NONE; + memset(transferPinDigits, 0, sizeof(transferPinDigits)); + // Land on Show, not on this menu's row index (2 = the PIN page's Back) + // for the frames before the page's first render resets it. + menuSelectionIndex = 0; + switchToDisplayPage(PAGE_TRANSFER_PIN); } else if (menuSelectionIndex == 0) { // Bluetooth — same flow as before debugln(F("Transfer: Bluetooth selected")); @@ -551,6 +561,15 @@ void handleMenuPageSelection() { } } } + } else if (currentPage == PAGE_TRANSFER_PIN) { + // Show / New PIN act on a 3 s HOLD (transferPinPageLoop); only Back + // acts on a press. Leaving hides the PIN and forgets the digits. + if (menuSelectionIndex == pin_page::kRowBack) { + debugln(F("Transfer PIN: Back")); + pin_page::begin(transferPinState); + memset(transferPinDigits, 0, sizeof(transferPinDigits)); + switchToDisplayPage(PAGE_TRANSFER_MENU); + } } else if (currentPage == PAGE_USB_STORAGE) { // Exit — reboot to drop the drive and remount a fresh filesystem debugln(F("USB Storage: Exit selected")); @@ -732,6 +751,8 @@ void displayLoop() { displayPage_bluetooth(); } else if (currentPage == PAGE_TRANSFER_MENU) { displayPage_transfer_menu(); + } else if (currentPage == PAGE_TRANSFER_PIN) { + displayPage_transfer_pin(); } else if (currentPage == PAGE_USB_STORAGE) { displayPage_usb_storage(); } else if (currentPage == PAGE_PAIR_CAMERA) { @@ -840,6 +861,7 @@ void displayLoop() { currentPage == PAGE_DRAG_MODE || currentPage == PAGE_BLUETOOTH || currentPage == PAGE_TRANSFER_MENU || + currentPage == PAGE_TRANSFER_PIN || currentPage == PAGE_USB_STORAGE || currentPage == LOGGING_STOP_CONFIRM || currentPage == PAGE_COURSE_PRUNE || @@ -876,7 +898,9 @@ void displayLoop() { } else if (currentPage == PAGE_BLUETOOTH) { menuLimit = 1; // Only "Exit" option } else if (currentPage == PAGE_TRANSFER_MENU) { - menuLimit = 3; // Bluetooth, USB, Back + menuLimit = 4; // Bluetooth, USB, PIN, Back + } else if (currentPage == PAGE_TRANSFER_PIN) { + menuLimit = pin_page::kRowCount; // Show, New PIN, Back } else if (currentPage == PAGE_USB_STORAGE) { menuLimit = 1; // Only "Exit" option } else if (currentPage == PAGE_PAIR_CAMERA) { @@ -941,6 +965,7 @@ void displayLoop() { #endif currentPage == PAGE_COURSE_PRUNE || currentPage == PAGE_TRANSFER_MENU || + currentPage == PAGE_TRANSFER_PIN || courseCreatorActive()); // BUTTON UP (or DOWN for reversed menus) diff --git a/BirdsEye/pin_page.cpp b/BirdsEye/pin_page.cpp new file mode 100644 index 0000000..c6a9de4 --- /dev/null +++ b/BirdsEye/pin_page.cpp @@ -0,0 +1,54 @@ +#include "pin_page.h" + +namespace pin_page { + +void begin(State& s) { s = State(); } + +Action step(State& s, const Inputs& in) { + if (!in.selectHeld) { + s.selectSeenReleased = true; + s.holdArmed = false; + return Action::kNone; + } + const bool actionRow = in.row == kRowShow || in.row == kRowNewPin; + if (!s.selectSeenReleased || in.otherButtonHeld || !actionRow) { + s.holdArmed = false; + return Action::kNone; + } + if (!s.holdArmed || s.holdRow != in.row) { + s.holdArmed = true; + s.holdRow = in.row; + s.holdSinceMs = in.nowMs; + return Action::kNone; + } + if (uint32_t(in.nowMs - s.holdSinceMs) < kHoldMs) return Action::kNone; + + // Fire once; a fresh hold needs a release first. + s.holdArmed = false; + s.selectSeenReleased = false; + return in.row == kRowNewPin ? Action::kNewPin : Action::kReveal; +} + +void reveal(State& s, uint32_t nowMs) { + s.revealed = true; + s.revealSinceMs = nowMs; +} + +bool isRevealed(State& s, uint32_t nowMs) { + if (s.revealed && uint32_t(nowMs - s.revealSinceMs) >= kRevealMs) s.revealed = false; + return s.revealed; +} + +uint32_t revealSecondsLeft(State& s, uint32_t nowMs) { + if (!isRevealed(s, nowMs)) return 0; + return (kRevealMs - uint32_t(nowMs - s.revealSinceMs) + 999u) / 1000u; +} + +uint32_t holdSecondsLeft(const State& s, uint32_t nowMs) { + if (!s.holdArmed) return 0; + const uint32_t held = uint32_t(nowMs - s.holdSinceMs); + if (held >= kHoldMs) return 1; + return (kHoldMs - held + 999u) / 1000u; +} + +} // namespace pin_page diff --git a/BirdsEye/pin_page.h b/BirdsEye/pin_page.h new file mode 100644 index 0000000..f7399f6 --- /dev/null +++ b/BirdsEye/pin_page.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +/////////////////////////////////////////// +// TRANSFER PIN PAGE STATE MACHINE (plan 0019) +// Transfer → PIN: the one place the remote-transfer PIN is shown on the +// device, and where a lost one is replaced. With the SD card soldered in, +// this page (plus the USB drive and a local Bluetooth start) is the whole +// recovery story — but the PIN must not sit on screen for anyone walking +// past, so both actions are deliberate holds: +// - Show: hold Select 3 s → PIN visible for 15 s, then hidden again. +// - New PIN: hold Select 3 s → a fresh PIN is generated and shown. +// - Back: an ordinary press (handled by the menu code, not here). +// A release, a change of row, or a side button held alongside Select (the +// Select+side reboot combo) cancels a hold with no partial credit. A +// Select still down from the press that opened the page never counts — +// it must be seen released first, exactly like the SD format confirm. +// +// Pure logic — no Arduino headers — so it is exercised by host tests. +/////////////////////////////////////////// + +namespace pin_page { + +constexpr uint32_t kHoldMs = 3000; +constexpr uint32_t kRevealMs = 15000; + +// Row order on the page. Back is last, as on every menu. +constexpr int kRowShow = 0; +constexpr int kRowNewPin = 1; +constexpr int kRowBack = 2; +constexpr int kRowCount = 3; + +enum class Action : uint8_t { + kNone, + kReveal, // show the stored PIN + kNewPin, // generate + store a new PIN, then show it +}; + +struct Inputs { + bool selectHeld = false; + bool otherButtonHeld = false; + int row = kRowShow; + uint32_t nowMs = 0; +}; + +struct State { + bool selectSeenReleased = false; + bool holdArmed = false; + int holdRow = kRowShow; + uint32_t holdSinceMs = 0; + bool revealed = false; + uint32_t revealSinceMs = 0; +}; + +// Page entry: everything hidden, nothing armed. +void begin(State& s); + +// Advance one loop iteration. +Action step(State& s, const Inputs& in); + +// Start (or restart) the 15 s reveal window — the caller does this once it +// has the digits to show, after kReveal or a successful kNewPin. +void reveal(State& s, uint32_t nowMs); + +// True while the PIN should be on screen. Expires the window as a side +// effect so the caller can drop its copy of the digits. +bool isRevealed(State& s, uint32_t nowMs); + +// Seconds left in the reveal (1..15), 0 when hidden. +uint32_t revealSecondsLeft(State& s, uint32_t nowMs); + +// Seconds left in a running hold (1..3), 0 when none. +uint32_t holdSecondsLeft(const State& s, uint32_t nowMs); + +} // namespace pin_page diff --git a/BirdsEye/remote_auth.cpp b/BirdsEye/remote_auth.cpp new file mode 100644 index 0000000..bec9c5c --- /dev/null +++ b/BirdsEye/remote_auth.cpp @@ -0,0 +1,198 @@ +#include "remote_auth.h" + +#include + +#include "sha256.h" + +namespace remote_auth { + +namespace { + +const char kHexDigits[] = "0123456789abcdef"; + +inline char lowerAscii(char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; } + +inline bool isHex(char c) { + c = lowerAscii(c); + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); +} + +bool startsWith(const char* s, const char* prefix) { + return strncmp(s, prefix, strlen(prefix)) == 0; +} + +} // namespace + +bool isValidPin(const char* pin) { + if (pin == nullptr) return false; + for (size_t i = 0; i < kPinLen; ++i) { + if (pin[i] < '0' || pin[i] > '9') return false; + } + return pin[kPinLen] == '\0'; +} + +void pinFromRandom(uint32_t r, char* out) { + uint32_t v = 1000u + (r % 9000u); + for (int i = int(kPinLen) - 1; i >= 0; --i) { + out[i] = char('0' + (v % 10u)); + v /= 10u; + } + out[kPinLen] = '\0'; +} + +void toHex(const uint8_t* bytes, size_t len, char* out) { + for (size_t i = 0; i < len; ++i) { + out[i * 2] = kHexDigits[bytes[i] >> 4]; + out[i * 2 + 1] = kHexDigits[bytes[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +void computeAnswer(const char* pin, const char* nonceHex, char* out) { + char msg[8 + kNonceHexLen + 1]; + size_t n = 0; + const char* parts[] = {"BEAUTH1|", nonceHex}; + for (const char* part : parts) { + for (size_t i = 0; part[i] != '\0' && n < sizeof(msg) - 1; ++i) msg[n++] = part[i]; + } + msg[n] = '\0'; + + uint8_t mac[sha256::kDigestLen]; + sha256::hmac(pin, strlen(pin), msg, n, mac); + toHex(mac, kAnswerHexLen / 2, out); +} + +bool hexEqualConstTime(const char* a, const char* b, size_t n) { + if (a == nullptr || b == nullptr) return false; + if (strlen(a) != n || strlen(b) != n) return false; + uint8_t diff = 0; + for (size_t i = 0; i < n; ++i) { + diff |= uint8_t(lowerAscii(a[i]) ^ lowerAscii(b[i])); + } + return diff == 0; +} + +uint32_t lockoutLengthMs(uint8_t lockoutsSoFar) { + uint32_t len = kLockoutBaseMs; + for (uint8_t i = 0; i < lockoutsSoFar && len < kLockoutMaxMs; ++i) len *= 2u; + return len > kLockoutMaxMs ? kLockoutMaxMs : len; +} + +bool isLocked(State& s, uint32_t nowMs) { + if (!s.lockActive) return false; + if (uint32_t(nowMs - s.lockStartMs) >= s.lockLenMs) { + s.lockActive = false; + s.fails = 0; + return false; + } + return true; +} + +uint32_t lockSecondsLeft(State& s, uint32_t nowMs) { + if (!isLocked(s, nowMs)) return 0; + const uint32_t left = s.lockLenMs - uint32_t(nowMs - s.lockStartMs); + return (left + 999u) / 1000u; +} + +void issueNonce(State& s, const uint8_t* bytes) { + toHex(bytes, kNonceLen, s.nonceHex); + s.nonceValid = true; +} + +Verdict checkAnswer(State& s, const char* answerHex, const char* pin, uint32_t nowMs) { + if (isLocked(s, nowMs)) { + s.nonceValid = false; + return Verdict::kLocked; + } + if (!s.nonceValid) return Verdict::kNoNonce; + // Before the nonce is spent: the logger can't check anything right now. + if (!isValidPin(pin)) return Verdict::kPinUnavailable; + + // Single use, right or wrong. + s.nonceValid = false; + + bool ok = false; + if (answerHex != nullptr && strlen(answerHex) == kAnswerHexLen) { + bool wellFormed = true; + for (size_t i = 0; i < kAnswerHexLen; ++i) wellFormed = wellFormed && isHex(answerHex[i]); + if (wellFormed) { + char expected[kAnswerHexLen + 1]; + computeAnswer(pin, s.nonceHex, expected); + ok = hexEqualConstTime(answerHex, expected, kAnswerHexLen); + } + } + memset(s.nonceHex, 0, sizeof(s.nonceHex)); + + if (ok) { + s.authed = true; + s.fails = 0; + return Verdict::kOk; + } + + if (s.fails < kMaxFails) ++s.fails; + if (s.fails >= kMaxFails) { + s.lockActive = true; + s.lockStartMs = nowMs; + s.lockLenMs = lockoutLengthMs(s.lockouts); + if (s.lockouts < 0xff) ++s.lockouts; + return Verdict::kLocked; + } + return Verdict::kFail; +} + +void onDisconnect(State& s) { + s.authed = false; + s.nonceValid = false; + memset(s.nonceHex, 0, sizeof(s.nonceHex)); +} + +bool commandAllowed(Mode mode, bool authed, const char* cmd) { + if (cmd == nullptr) return false; + const bool isPinGet = strcmp(cmd, "PINGET") == 0; + if (mode == Mode::kOpen) return true; + if (isPinGet) return false; + if (authed) return true; + return strcmp(cmd, "AUTH?") == 0 || startsWith(cmd, "AUTH:") || strcmp(cmd, "BATT") == 0; +} + +bool isProtectedSettingKey(const char* key) { + return key != nullptr && strcmp(key, "bluetooth_pin") == 0; +} + +bool cameraWantsRadio(const CameraInputs& in, bool& engineLatch) { + if (!in.paired || in.rpm < kCameraReleaseRpm) { + engineLatch = false; + } else if (in.rpm > kCameraWakeRpm) { + engineLatch = true; + } + return !in.fsmIdleOrUnpaired || in.testPageOpen || in.ownsRadio || engineLatch; +} + +bool standbyWanted(const StandbyInputs& in) { + return in.settingEnabled && in.onMainMenu && in.radioFree && !in.cameraWantsRadio && + !in.raceActive && !in.squatBackoff; +} + +RemoteEnd remoteSessionEnd(const RemoteSessionInputs& in) { + if (in.otaApplyPending) return RemoteEnd::kNone; + if (in.rpm > kEngineRunningRpm) return RemoteEnd::kEngine; + if (in.cameraWantsRadio) return RemoteEnd::kCamera; + if (!in.busy && uint32_t(in.nowMs - in.lastRequestMs) >= kRemoteIdleMs) return RemoteEnd::kIdle; + return RemoteEnd::kNone; +} + +const char* remoteEndToken(RemoteEnd e) { + switch (e) { + case RemoteEnd::kEngine: + return "AUTH:ENGINE"; + case RemoteEnd::kCamera: + return "AUTH:CAMERA"; + case RemoteEnd::kIdle: + return "AUTH:IDLE"; + case RemoteEnd::kNone: + break; + } + return nullptr; +} + +} // namespace remote_auth diff --git a/BirdsEye/remote_auth.h b/BirdsEye/remote_auth.h new file mode 100644 index 0000000..1cfd0fd --- /dev/null +++ b/BirdsEye/remote_auth.h @@ -0,0 +1,215 @@ +#pragma once + +#include +#include + +/////////////////////////////////////////// +// REMOTE TRANSFER AUTH (plan 0019) +// +// Pure, Arduino-free rules for the PIN-gated remote transfer: the +// challenge-response answer, the nonce + lockout state machine, which +// commands a not-yet-authenticated peer may send, which settings are +// protected, and when the camera takes the radio back. bluetooth.ino owns +// only the radio and the clock; every decision lives here so it is +// host-tested. +// +// THE ANSWER (shared byte-for-byte with DovesDataViewer and LapWing): +// mac = HMAC-SHA256(key = PIN as ASCII, msg = "BEAUTH1|" + nonceHex) +// answer = lowercase hex of the first 16 bytes of mac (32 chars) +// The nonce is 16 random bytes, sent as 32 lowercase hex chars. It is not +// bound to the device name: the nonce is already unique to this logger and +// this attempt, and the advertised name can be truncated on air, so mixing +// it in would only add a way for two correct implementations to disagree. +// +// What this does NOT protect against: the link is unencrypted Just Works, +// so a sniffer that records one handshake can brute-force a 4-digit PIN +// offline. The PIN keeps other people in the pits out; see the plan. +/////////////////////////////////////////// + +namespace remote_auth { + +constexpr size_t kNonceLen = 16; +constexpr size_t kNonceHexLen = kNonceLen * 2; +constexpr size_t kAnswerHexLen = 32; +constexpr size_t kPinLen = 4; + +// Wrong answers allowed before a lockout. +constexpr uint8_t kMaxFails = 5; +// First lockout; each further lockout doubles it, up to kLockoutMaxMs. +constexpr uint32_t kLockoutBaseMs = 60000; +constexpr uint32_t kLockoutMaxMs = 15UL * 60UL * 1000UL; +// A locked-mode peer that hasn't authenticated by now is dropped, so a +// stranger can't squat on the only peripheral connection slot. +constexpr uint32_t kAuthTimeoutMs = 20000; +// Camera wake threshold (camera_fsm kRpmOnThreshold). A paired camera +// claims the radio above it, and keeps the claim until the engine falls +// below kCameraReleaseRpm (camera_fsm kRpmOffThreshold) — without that +// hysteresis a pull-start cranking through 500 thrashes the standby advert +// on and off, an SD read and an advert rebuild each time. +constexpr uint16_t kCameraWakeRpm = 500; +constexpr uint16_t kCameraReleaseRpm = 300; +// An engine above this ends a remote session whatever the camera is doing: +// the driver is about to need logging, auto-race and the race pages, and +// the parked transfer loop runs none of them. Same number as the camera +// wake and auto-race thresholds. +constexpr uint16_t kEngineRunningRpm = 500; +// A remote session with no request traffic for this long ends. Nobody at +// the logger started it, so nobody there would think to end it, and the +// parked loop has no idle shutdown of its own. +constexpr uint32_t kRemoteIdleMs = 10UL * 60UL * 1000UL; +// After dropping a peer that never authenticated, the menu advert stays +// down this long. One slot means a squatter (or a bonded camera chasing +// our address) could otherwise hold it in a tight 20 s loop forever. +constexpr uint32_t kSquatBackoffMs = 5000; + +// Exactly kPinLen ASCII digits, nothing else. +bool isValidPin(const char* pin); + +// 32-bit random -> a 4-digit PIN in 1000-9999 (the range every logger has +// used since first boot). `out` holds at least kPinLen + 1 bytes. +void pinFromRandom(uint32_t r, char* out); + +// Lowercase hex of `len` bytes into `out` (2*len + 1 bytes). +void toHex(const uint8_t* bytes, size_t len, char* out); + +// The expected answer for (pin, nonceHex) into `out` (kAnswerHexLen + 1 +// bytes). +void computeAnswer(const char* pin, const char* nonceHex, char* out); + +// Case-insensitive compare of two hex strings of exactly `n` chars, in time +// independent of where they differ. False if either is shorter than `n` or +// longer than `n`. +bool hexEqualConstTime(const char* a, const char* b, size_t n); + +// Per-boot auth state (RAM only — a reboot clears the lockout, which needs +// the device in hand anyway). +struct State { + bool nonceValid = false; + char nonceHex[kNonceHexLen + 1] = {0}; + uint8_t fails = 0; // wrong answers since the last lockout + uint8_t lockouts = 0; // lockouts so far this boot (sets the length) + bool lockActive = false; + uint32_t lockStartMs = 0; + uint32_t lockLenMs = 0; + bool authed = false; // AUTH:OK seen on the current connection +}; + +// Length of the next lockout given how many have already happened. +uint32_t lockoutLengthMs(uint8_t lockoutsSoFar); + +// True while a lockout is running. Clears an expired lockout (and the fail +// count that caused it) as a side effect. Wrap-safe on nowMs. +bool isLocked(State& s, uint32_t nowMs); + +// Whole seconds left in the current lockout, rounded up; 0 if none. +uint32_t lockSecondsLeft(State& s, uint32_t nowMs); + +// Wrong answers left before a lockout. +inline uint8_t triesLeft(const State& s) { + return s.fails >= kMaxFails ? 0 : uint8_t(kMaxFails - s.fails); +} + +// Install a fresh nonce from `bytes` (kNonceLen random bytes). Any previous +// nonce is discarded. +void issueNonce(State& s, const uint8_t* bytes); + +enum class Verdict : uint8_t { + kOk, // AUTH:OK + kFail, // AUTH:FAIL: + kLocked, // AUTH:LOCKED: (already locked, or this fail locked it) + kNoNonce, // AUTH:NO_NONCE — no AUTH? since the last answer + kPinUnavailable, // AUTH:BUSY — the stored PIN is unreadable or not 4 digits +}; + +// Check `answerHex` against the expected answer for the current nonce. The +// nonce is consumed on kOk, kFail and kLocked. kNoNonce has none to consume, +// and kPinUnavailable leaves it intact and counts nothing: an SD refusal or +// a hand-edited PIN is the logger's fault, not a wrong guess, so it must +// not burn the app's attempt or walk it toward a lockout. A malformed +// answer counts as a wrong one. +Verdict checkAnswer(State& s, const char* answerHex, const char* pin, uint32_t nowMs); + +// Reset per-connection state (authed flag, nonce). The lockout survives. +void onDisconnect(State& s); + +// Session modes. Open = someone started transfer on the device; locked = +// the logger is advertising from its main menu. +enum class Mode : uint8_t { kOpen, kLocked }; + +// May this command run? `cmd` is the trimmed text written to the request +// characteristic, compared exactly — a leading space or anything after an +// embedded NUL makes it a different (unknown) command, never an allowed one. Open: everything. Locked before AUTH:OK: only AUTH?, +// AUTH:<…> and BATT. PINGET is open-mode only — it is how an app learns the +// PIN, so it is never available to a peer that started remotely. +bool commandAllowed(Mode mode, bool authed, const char* cmd); + +// Settings that SLIST must omit and SGET must refuse (SERR:PROTECTED). +bool isProtectedSettingKey(const char* key); + +// What the camera is doing, as far as the radio is concerned. +struct CameraInputs { + bool fsmIdleOrUnpaired = true; // camera_fsm state is kUnpaired or kIdle + bool testPageOpen = false; // camera bench page holds the radio + bool ownsRadio = false; // bleOwner == CAMERA + bool paired = false; + uint16_t rpm = 0; +}; + +// The camera has, or is about to have, a use for the radio: its state +// machine is anywhere but UNPAIRED/IDLE, the bench page is open, it owns +// the radio — or a paired camera's engine has passed the wake threshold. +// That last one fires before the camera FSM's own 2 s wake debounce, so +// remote transfer has always let go of the radio before the camera asks. +// `engineLatch` is the caller's state for the engine half: set above +// kCameraWakeRpm, cleared below kCameraReleaseRpm (or when unpaired). +bool cameraWantsRadio(const CameraInputs& in, bool& engineLatch); + +// When the menu standby advert should be up. +struct StandbyInputs { + bool onMainMenu = false; + bool radioFree = false; // bleOwner == NONE, or already ours for standby + bool cameraWantsRadio = false; + bool raceActive = false; + bool settingEnabled = false; // remote_transfer setting + bool squatBackoff = false; // squatBackoffActive() +}; +bool standbyWanted(const StandbyInputs& in); + +// The 20 s squatting limit, wrap-safe. +inline bool authTimedOut(uint32_t connectedAtMs, uint32_t nowMs) { + return uint32_t(nowMs - connectedAtMs) >= kAuthTimeoutMs; +} + +// The post-squat quiet period, wrap-safe. `armed` is false until the first +// squat drop. +inline bool squatBackoffActive(bool armed, uint32_t droppedAtMs, uint32_t nowMs) { + return armed && uint32_t(nowMs - droppedAtMs) < kSquatBackoffMs; +} + +// Why a remote (menu-started, authenticated) session must end now. +enum class RemoteEnd : uint8_t { + kNone, + kEngine, // AUTH:ENGINE — engine above kEngineRunningRpm + kCamera, // AUTH:CAMERA — the camera wants the radio back + kIdle, // AUTH:IDLE — kRemoteIdleMs without a request +}; + +struct RemoteSessionInputs { + bool otaApplyPending = false; // FWAPPLY accepted: the flasher owns the exit + uint16_t rpm = 0; + bool cameraWantsRadio = false; + bool busy = false; // a download / upload / OTA stream in flight + uint32_t lastRequestMs = 0; // last write to the request characteristic + uint32_t nowMs = 0; +}; + +// Engine first (it also covers a paired camera's own engine wake), then the +// camera, then idleness. An in-flight transfer is activity — a long +// download is one command — but never outranks the engine or the camera. +// An accepted OTA apply is never interrupted: it reboots on its own. +RemoteEnd remoteSessionEnd(const RemoteSessionInputs& in); + +// The unsolicited notice sent before the drop; nullptr for kNone. +const char* remoteEndToken(RemoteEnd e); + +} // namespace remote_auth diff --git a/BirdsEye/settings.h b/BirdsEye/settings.h index 79ce704..899f6c3 100644 --- a/BirdsEye/settings.h +++ b/BirdsEye/settings.h @@ -8,6 +8,7 @@ /////////////////////////////////////////// #include +#include /////////////////////////////////////////// // SETTINGS JSON CAPACITY — the single source of truth. @@ -37,7 +38,7 @@ // a SensorEgg one (25 — sensoregg_mac added), rising to 599/657 with the // longest values every key accepts, against a 1023-byte read cap. That // is roughly fifteen average keys of headroom. Each key costs -// len(key) + len(value) + 6. +// len(key) + len(value) + 6. Plan 0019's remote_transfer adds 23. /////////////////////////////////////////// #define SETTINGS_JSON_CAPACITY 1024 @@ -62,6 +63,18 @@ bool setSetting(const char* key, const char* value); // quarantining the user's whole settings file behind their back. bool removeSetting(const char* key); -// Delete the settings file and re-create with fresh defaults -// (rolls a new random BLE name and PIN). +// Delete the settings file and re-create with fresh defaults (rolls a new +// random BLE name). A valid bluetooth_pin is KEPT (plan 0019): SRESET can +// come from a remote session, which could never learn a new PIN. Replacing +// the PIN is the device's Transfer -> PIN page. bool resetSettings(); + +// Fill `out` with `n` bytes from the nRF52840 hardware RNG — through the +// SoftDevice when it is enabled (it owns the peripheral then), directly +// otherwise. False only if the SoftDevice pool stays empty for 50 ms. +// Used for the BLE PIN and the remote-transfer auth nonce (plan 0019). +bool hwRandomBytes(uint8_t* out, size_t n); + +// A fresh 4-digit BLE PIN (1000-9999) from the hardware RNG into `out` +// (at least 5 bytes). +void generateBlePin(char* out); diff --git a/BirdsEye/settings.ino b/BirdsEye/settings.ino index 9fa2d56..66457a6 100644 --- a/BirdsEye/settings.ino +++ b/BirdsEye/settings.ino @@ -5,6 +5,7 @@ /////////////////////////////////////////// #include "settings.h" +#include "remote_auth.h" static const char SETTINGS_FILE_PATH[] = "/SETTINGS.json"; // Quarantine target for a corrupt (non-empty, unparseable) settings file — @@ -49,11 +50,66 @@ static void generateDeviceName(char* buf, size_t bufSize) { snprintf(buf, bufSize, "%s%s", kDeviceNameWords[i], kDeviceNameWords[j]); } +bool hwRandomBytes(uint8_t* out, size_t n) { +#ifdef SIM + // The sim has no RNG peripheral and must stay deterministic anyway. + for (size_t i = 0; i < n; i++) out[i] = (uint8_t)random(0, 256); + return true; +#else + uint8_t sdEnabled = 0; + (void)sd_softdevice_is_enabled(&sdEnabled); + if (sdEnabled) { + // The SoftDevice owns the RNG peripheral once it is up; its pool refills + // in tens of microseconds per byte, so 50 ms is a generous bound. + const uint32_t start = millis(); + size_t got = 0; + while (got < n) { + uint8_t avail = 0; + (void)sd_rand_application_bytes_available_get(&avail); + if (avail > 0) { + uint8_t take = (uint8_t)((n - got) < avail ? (n - got) : avail); + if (sd_rand_application_vector_get(out + got, take) == NRF_SUCCESS) got += take; + } else if (millis() - start > 50) { + return false; + } + } + return true; + } + // No SoftDevice yet (first boot on a stock build): drive the peripheral + // directly, with bias correction on. + NRF_RNG->CONFIG = RNG_CONFIG_DERCEN_Msk; + NRF_RNG->EVENTS_VALRDY = 0; + NRF_RNG->TASKS_START = 1; + for (size_t i = 0; i < n; i++) { + while (NRF_RNG->EVENTS_VALRDY == 0) { + } + out[i] = (uint8_t)NRF_RNG->VALUE; + NRF_RNG->EVENTS_VALRDY = 0; + } + NRF_RNG->TASKS_STOP = 1; + return true; +#endif +} + +void generateBlePin(char* out) { + uint8_t r[4] = {0}; + if (!hwRandomBytes(r, sizeof(r))) { + // Only reachable with the SoftDevice up and its pool wedged; a PIN is + // still required, so fall back rather than leave the key missing. + for (size_t i = 0; i < sizeof(r); i++) r[i] = (uint8_t)random(0, 256); + } + remote_auth::pinFromRandom(((uint32_t)r[0] << 24) | ((uint32_t)r[1] << 16) | + ((uint32_t)r[2] << 8) | r[3], + out); +} + /** - * @brief Generate default settings file on first boot + * @brief Write a fresh default settings file. + * @param keepPin A valid PIN to carry over (resetSettings()), or nullptr to + * roll a new one. * @return true if file created successfully */ -bool createDefaultSettings() { +static bool writeDefaultSettings(const char* keepPin) { if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { debugln(F("Settings: Cannot acquire SD access for defaults")); return false; @@ -70,9 +126,15 @@ bool createDefaultSettings() { snprintf(defaultName, sizeof(defaultName), "DovesDataLogger-%s", nameSuffix); settingsJson["bluetooth_name"] = defaultName; - // Generate random 4-digit PIN - char defaultPin[5]; - snprintf(defaultPin, sizeof(defaultPin), "%04d", (int)random(1000, 10000)); + // Random 4-digit PIN from the hardware RNG (plan 0019): it gates remote + // transfer now, so random() seeded from micros() is not good enough. + char defaultPin[remote_auth::kPinLen + 1]; + if (keepPin != nullptr && remote_auth::isValidPin(keepPin)) { + strncpy(defaultPin, keepPin, sizeof(defaultPin)); + defaultPin[sizeof(defaultPin) - 1] = '\0'; + } else { + generateBlePin(defaultPin); + } settingsJson["bluetooth_pin"] = defaultPin; // Random racing-themed device name so multi-device log dumps stay sorted @@ -101,14 +163,22 @@ bool createDefaultSettings() { settingsFile.close(); releaseSDAccess(SD_ACCESS_TRACK_PARSE); + // The PIN is a secret (plan 0019) — never on the debug serial. + memset(defaultPin, 0, sizeof(defaultPin)); debug(F("Settings: Created defaults - name: ")); - debug(defaultName); - debug(F(", pin: ")); - debugln(defaultPin); + debugln(defaultName); return true; } +/** + * @brief Generate default settings file on first boot + * @return true if file created successfully + */ +bool createDefaultSettings() { + return writeDefaultSettings(nullptr); +} + /** * @brief Ensure all expected settings keys exist, adding missing ones with defaults. * Called when settings file already exists to handle firmware upgrades that add new keys. @@ -212,6 +282,10 @@ static void ensureDefaultSettings() { { "led_brightness_night", "16" }, { "led_day_start_hour", "7" }, { "led_night_start_hour", "19" }, + // Plan 0019: advertise the transfer service from the main menu so the + // app can start a PIN-gated transfer remotely. "off" disables it; + // anything else means on. + { "remote_transfer", "on" }, }; char buf[48]; @@ -238,11 +312,17 @@ static void ensureDefaultSettings() { snprintf(name, sizeof(name), "DovesDataLogger-%03d", (int)random(0, 1000)); setSetting("bluetooth_name", name); } - if (!getSetting("bluetooth_pin", buf, sizeof(buf))) { - char pin[5]; - snprintf(pin, sizeof(pin), "%04d", (int)random(1000, 10000)); + // A PIN the handshake can never accept (a hand-edited "12345", a number + // instead of a string — which getSetting() can't read either) would lock + // remote transfer out for good, so it is replaced like a missing one. + // The new one is on the device's Transfer -> PIN page. + if (!getSetting("bluetooth_pin", buf, sizeof(buf)) || !remote_auth::isValidPin(buf)) { + char pin[remote_auth::kPinLen + 1]; + generateBlePin(pin); setSetting("bluetooth_pin", pin); + memset(pin, 0, sizeof(pin)); } + memset(buf, 0, sizeof(buf)); } /** @@ -393,12 +473,23 @@ bool getSetting(const char* key, char* buf, size_t bufSize) { * @return true on success, false on failure */ /** - * @brief Delete settings file and recreate with fresh defaults (new random BLE name/PIN). + * @brief Delete settings file and recreate with fresh defaults (new random + * BLE name). The remote-transfer PIN is KEPT when it is valid (plan + * 0019): SRESET can come from a remote session, and an app that has + * just reset the logger must still be able to reach it — it has no + * way to learn a new PIN without the device in hand. A new PIN is + * what the device's Transfer -> PIN page is for. * @return true on success */ bool resetSettings() { if (!sdSetupSuccess) return false; + char keepPin[16]; + if (!getSetting("bluetooth_pin", keepPin, sizeof(keepPin)) || + !remote_auth::isValidPin(keepPin)) { + keepPin[0] = '\0'; + } + if (!acquireSDAccess(SD_ACCESS_TRACK_PARSE)) { debugln(F("Settings: Cannot acquire SD for reset")); return false; @@ -410,7 +501,9 @@ bool resetSettings() { releaseSDAccess(SD_ACCESS_TRACK_PARSE); debugln(F("Settings: Deleted, recreating defaults")); - return createDefaultSettings(); + const bool ok = writeDefaultSettings(keepPin[0] != '\0' ? keepPin : nullptr); + memset(keepPin, 0, sizeof(keepPin)); + return ok; } static bool setSettingInner(const char* key, const char* value, bool healCorrupt) { @@ -486,8 +579,13 @@ static bool setSettingInner(const char* key, const char* value, bool healCorrupt debug(F("Settings: Saved ")); debug(key); - debug(F(" = ")); - debugln(value); + // The PIN never reaches the debug serial (plan 0019). + if (remote_auth::isProtectedSettingKey(key)) { + debugln(F(" = ")); + } else { + debug(F(" = ")); + debugln(value); + } return true; } diff --git a/BirdsEye/sha256.cpp b/BirdsEye/sha256.cpp new file mode 100644 index 0000000..10248bf --- /dev/null +++ b/BirdsEye/sha256.cpp @@ -0,0 +1,146 @@ +#include "sha256.h" + +#include + +namespace sha256 { + +namespace { + +const uint32_t kK[64] = { + 0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, + 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u, 0xd807aa98u, 0x12835b01u, + 0x243185beu, 0x550c7dc3u, 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, + 0xc19bf174u, 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, + 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau, 0x983e5152u, + 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, + 0x06ca6351u, 0x14292967u, 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, + 0x53380d13u, 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u, + 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, + 0xd6990624u, 0xf40e3585u, 0x106aa070u, 0x19a4c116u, 0x1e376c08u, + 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, + 0x682e6ff3u, 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, + 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u}; + +inline uint32_t rotr(uint32_t x, unsigned n) { return (x >> n) | (x << (32 - n)); } + +void compress(Ctx& ctx, const uint8_t* p) { + uint32_t w[64]; + for (size_t i = 0; i < 16; ++i) { + w[i] = (uint32_t(p[i * 4]) << 24) | (uint32_t(p[i * 4 + 1]) << 16) | + (uint32_t(p[i * 4 + 2]) << 8) | uint32_t(p[i * 4 + 3]); + } + for (int i = 16; i < 64; ++i) { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + uint32_t a = ctx.h[0], b = ctx.h[1], c = ctx.h[2], d = ctx.h[3]; + uint32_t e = ctx.h[4], f = ctx.h[5], g = ctx.h[6], h = ctx.h[7]; + for (int i = 0; i < 64; ++i) { + const uint32_t s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const uint32_t ch = (e & f) ^ (~e & g); + const uint32_t t1 = h + s1 + ch + kK[i] + w[i]; + const uint32_t s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const uint32_t t2 = s0 + maj; + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + ctx.h[0] += a; + ctx.h[1] += b; + ctx.h[2] += c; + ctx.h[3] += d; + ctx.h[4] += e; + ctx.h[5] += f; + ctx.h[6] += g; + ctx.h[7] += h; +} + +} // namespace + +void init(Ctx& ctx) { + static const uint32_t kInit[8] = {0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au, + 0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u}; + memcpy(ctx.h, kInit, sizeof(kInit)); + ctx.bitLen = 0; + ctx.blockLen = 0; +} + +void update(Ctx& ctx, const void* data, size_t len) { + const uint8_t* p = static_cast(data); + ctx.bitLen += uint64_t(len) * 8u; + while (len > 0) { + size_t take = kBlockLen - ctx.blockLen; + if (take > len) take = len; + memcpy(ctx.block + ctx.blockLen, p, take); + ctx.blockLen += take; + p += take; + len -= take; + if (ctx.blockLen == kBlockLen) { + compress(ctx, ctx.block); + ctx.blockLen = 0; + } + } +} + +void finish(Ctx& ctx, uint8_t out[kDigestLen]) { + const uint64_t bitLen = ctx.bitLen; + ctx.block[ctx.blockLen++] = 0x80; + if (ctx.blockLen > kBlockLen - 8) { + memset(ctx.block + ctx.blockLen, 0, kBlockLen - ctx.blockLen); + compress(ctx, ctx.block); + ctx.blockLen = 0; + } + memset(ctx.block + ctx.blockLen, 0, kBlockLen - 8 - ctx.blockLen); + for (int i = 0; i < 8; ++i) { + ctx.block[kBlockLen - 1 - i] = uint8_t(bitLen >> (8 * i)); + } + compress(ctx, ctx.block); + for (size_t i = 0; i < 8; ++i) { + out[i * 4] = uint8_t(ctx.h[i] >> 24); + out[i * 4 + 1] = uint8_t(ctx.h[i] >> 16); + out[i * 4 + 2] = uint8_t(ctx.h[i] >> 8); + out[i * 4 + 3] = uint8_t(ctx.h[i]); + } +} + +void hash(const void* data, size_t len, uint8_t out[kDigestLen]) { + Ctx ctx; + init(ctx); + update(ctx, data, len); + finish(ctx, out); +} + +void hmac(const void* key, size_t keyLen, const void* msg, size_t msgLen, + uint8_t out[kDigestLen]) { + uint8_t k[kBlockLen]; + memset(k, 0, sizeof(k)); + if (keyLen > kBlockLen) { + hash(key, keyLen, k); + } else if (keyLen > 0) { + memcpy(k, key, keyLen); + } + + uint8_t pad[kBlockLen]; + for (size_t i = 0; i < kBlockLen; ++i) pad[i] = k[i] ^ 0x36u; + uint8_t inner[kDigestLen]; + Ctx ctx; + init(ctx); + update(ctx, pad, kBlockLen); + update(ctx, msg, msgLen); + finish(ctx, inner); + + for (size_t i = 0; i < kBlockLen; ++i) pad[i] = k[i] ^ 0x5cu; + init(ctx); + update(ctx, pad, kBlockLen); + update(ctx, inner, kDigestLen); + finish(ctx, out); +} + +} // namespace sha256 diff --git a/BirdsEye/sha256.h b/BirdsEye/sha256.h new file mode 100644 index 0000000..d7fc32a --- /dev/null +++ b/BirdsEye/sha256.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +/////////////////////////////////////////// +// SHA-256 + HMAC-SHA256 (FIPS 180-4, RFC 2104) +// +// Pure, Arduino-free. Exists for the remote-transfer PIN handshake +// (plan 0019): the logger and both companion apps (WebCrypto in the +// viewer, the hmac/sha2 crates in LapWing) must agree on the answer +// byte-for-byte, so this unit is pinned to the FIPS 180-4 and RFC 4231 +// test vectors in tests/sha256_test.cpp. +// +// Small and slow on purpose: it runs once per authentication attempt, +// never in a hot path, so code size wins over speed. +/////////////////////////////////////////// + +namespace sha256 { + +constexpr size_t kDigestLen = 32; +constexpr size_t kBlockLen = 64; + +struct Ctx { + uint32_t h[8]; + uint64_t bitLen; + uint8_t block[kBlockLen]; + size_t blockLen; +}; + +void init(Ctx& ctx); +void update(Ctx& ctx, const void* data, size_t len); +void finish(Ctx& ctx, uint8_t out[kDigestLen]); + +// One-shot digest. +void hash(const void* data, size_t len, uint8_t out[kDigestLen]); + +// HMAC-SHA256. Keys longer than one block are hashed first, per RFC 2104. +void hmac(const void* key, size_t keyLen, const void* msg, size_t msgLen, + uint8_t out[kDigestLen]); + +} // namespace sha256 diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 4b9fbd3..fae12da 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -160,9 +160,12 @@ set(SIM_CORE_SOURCES ${BIRDSEYE_DIR}/sat_bars.cpp ${BIRDSEYE_DIR}/sd_access_policy.cpp ${BIRDSEYE_DIR}/sd_format_page.cpp + ${BIRDSEYE_DIR}/pin_page.cpp + ${BIRDSEYE_DIR}/remote_auth.cpp ${BIRDSEYE_DIR}/sd_probe.cpp ${BIRDSEYE_DIR}/sector_purple.cpp ${BIRDSEYE_DIR}/sensoregg_protocol.cpp + ${BIRDSEYE_DIR}/sha256.cpp ${BIRDSEYE_DIR}/sprint_select.cpp ${BIRDSEYE_DIR}/tach_filter.cpp ${BIRDSEYE_DIR}/track_json.cpp diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index 94e9f25..83214d5 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -6,7 +6,10 @@ drag_staging_await_arm -19 54f95c26 drag_staging_wait_gps -19 41e9989b main_menu_after_drag_exit -1 14716beb main_menu_transfer -1 40da09f8 -transfer_menu -4 ae15b2a8 +transfer_menu -4 58ad68c6 +transfer_menu_pin -4 b51888cd +transfer_pin_hidden -22 195ab8b5 +transfer_pin_revealed -22 612cd8e4 bluetooth_waiting -2 c0f9211b warning_no_dovex 100 f800764b warning_create_needs_gps 100 45982329 diff --git a/BirdsEye/sim/golden_main.cpp b/BirdsEye/sim/golden_main.cpp index 0ce2699..12acaf6 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -79,6 +79,7 @@ constexpr int kPageDragMode = -18; constexpr int kPageDragStaging = -19; constexpr int kPageTransferMenu = -4; constexpr int kPageBluetooth = -2; +constexpr int kPageTransferPin = -22; constexpr int kPagePairCamera = -6; constexpr int kPageCameraSerialEntry = -7; constexpr int kPageCourseTrack = -11; @@ -209,10 +210,29 @@ void runScript() { press(2); capture("main_menu_transfer", kPageMainMenu); - // Select -> Transfer submenu (Bluetooth / USB). + // Select -> Transfer submenu (Bluetooth / USB / PIN / Back). press(1); capture("transfer_menu", kPageTransferMenu); + // Plan 0019: walk to PIN (the window scrolls) and open it. The PIN is + // hidden until Select is held 3 s on Show; then it stays up for 15 s. + press(2); + press(2); + capture("transfer_menu_pin", kPageTransferMenu); + press(1); + capture("transfer_pin_hidden", kPageTransferPin); + sim_button_down(1); + sim_step_millis(3300); + sim_button_up(1); + sim_step_millis(400); + capture("transfer_pin_revealed", kPageTransferPin); + + // Back (third row) returns to the submenu with the selection on its + // first row, so the walk continues into Bluetooth as before. + press(2); + press(2); + press(1); + // Select Bluetooth -> BLE page (stub radio: waiting-for-connection UI). press(1); capture("bluetooth_waiting", kPageBluetooth); diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index 9c280ed..8a36971 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -90,6 +90,7 @@ void checkAutoIdle(); void autoRaceModeCheck(); void gpsStatusPageLoop(); void sdFormatPageLoop(); +void transferPinPageLoop(); int courseCreatorPage(); bool courseCreatorActive(); bool courseCreatorEnter(); @@ -117,6 +118,7 @@ void displayPrintDragStats(float trapMph, unsigned long split60Ms); void displayPrintSplitSeconds(unsigned long ms); void displayPage_bluetooth(); void displayPage_transfer_menu(); +void displayPage_transfer_pin(); void displayPage_usb_storage(); void displayPage_pair_camera(); void displayPage_camera_test(); diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp index a61344c..d1a9331 100644 --- a/BirdsEye/sim/stubs/module_stubs.cpp +++ b/BirdsEye/sim/stubs/module_stubs.cpp @@ -55,6 +55,12 @@ void bleShutdownQuiesce() {} void BLUETOOTH_LOOP() {} +// No radio, so no menu standby advert and never a remote session. +void BLE_STANDBY_LOOP() {} +void bleRemoteSessionGuard() {} +bool bleRemoteSessionActive() { return false; } +bool blePinSentThisSession() { return false; } + // Transfer diagnostics the Bluetooth page prints. No radio in the sim, so // the page renders its idle "waiting" state and these are never meaningful — // they exist so display_pages.ino compiles unchanged. @@ -79,6 +85,7 @@ bool cameraBleOwnsConnHandle(uint16_t) { return false; } bool cameraIsPaired() { return false; } camera_fsm::State cameraFsmState() { return camera_fsm::State::kUnpaired; } +bool cameraTestModeActive() { return false; } bool cameraRemoteLinkUp() { return false; } bool cameraAdvertisingUp() { return false; } bool cameraCe82Subscribed() { return false; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3404752..297bd32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,56 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Added +- **Remote transfer with a PIN** (plan 0019). While the logger sits on its + main menu it now advertises the transfer service, so the app can start a + download without anyone pressing Transfer → Bluetooth — once it proves it + knows the logger's PIN. A remote start never sends the PIN over the air: + the logger sends a one-time challenge and the app answers with an HMAC of + it. (It does travel, unencrypted, on one path: to the app connected to a + transfer started on the device, which is how the app learns it.) Five + wrong answers lock remote transfer for a minute (doubling up to 15 + minutes), and a peer that never answers is dropped after 20 seconds, with + a 5-second pause before the logger advertises again. Leaving the menu + stops the advert, and the `remote_transfer` setting (`on` by default) + turns it off entirely. The transfer page says **Remote Transfer** when a + session started this way. +- **A remote transfer can't strand the logger.** It ends on its own — the + app is told why (`AUTH:ENGINE`, `AUTH:CAMERA`, `AUTH:IDLE`), then the + logger reboots as after any transfer — when the engine starts (camera + paired or not), when the camera needs the radio, or after 10 minutes + without a request. Before, an app left connected kept the logger parked + with no logging, no auto-race and no idle shutdown. +- **The camera always wins the radio.** Remote standby never advertises + while a paired Insta360 is doing anything, the camera bench page is open, + or a paired camera's engine is running (from 500 rpm until it drops below + 300, so pull-starting doesn't flick the advert on and off); and a remote + transfer ends the moment the engine starts, so auto-record is never held + up. Starting Transfer → Bluetooth on the device still bumps the camera, + as before. +- **Transfer → PIN page.** Hold Select for 3 seconds on *Show PIN* to see + the PIN for 15 seconds, or on *New PIN* to replace it. With the SD card + soldered in, this (plus the USB drive and the app re-reading it on a local + Bluetooth start) is how a lost PIN is recovered. If the stored PIN can't + be read, or isn't four digits, the page says so instead of going blank. + The Transfer menu now scrolls to fit its fourth row. +- On a transfer started on the device, the page title changes to **PIN sent + to app!** once the connected app has read the PIN — the first app in + range isn't necessarily yours. +- BLE commands `AUTH?`, `AUTH:` and `PINGET` (local start only). + +### Changed +- **The PIN is no longer readable over Bluetooth by whoever is connected.** + `SLIST` leaves `bluetooth_pin` out and `SGET:bluetooth_pin` answers + `SERR:PROTECTED`; the app reads it with `PINGET` on a local start + instead. `SSET:bluetooth_pin` only accepts exactly four digits. +- New PINs come from the hardware random number generator instead of + `random()` seeded with the boot time. +- A stored PIN that isn't exactly four digits is replaced at boot (see it + under Transfer → PIN), and a settings reset (`SRESET`) now keeps the PIN, + so an app that resets the logger remotely can still reach it. +- The PIN is no longer printed on the debug serial port. + ### Fixed - **Drag mode review fixes** (plans 0015/0016): - A GPS fix lost mid-pass and never regained no longer wedges manual diff --git a/CLAUDE.md b/CLAUDE.md index 134df59..3fdf490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,9 @@ Core capabilities: - Accelerometer logging (g-force X/Y/Z) via onboard LSM6DS3 IMU - DOVEX data logging with reserved 1 KB header (crash-safe GPS data) - 8+ display pages on a 128x64 OLED (3 Hz refresh) -- Bluetooth LE file download to companion apps / LapWingData.com +- Bluetooth LE file download to companion apps / LapWingData.com — + started on the device, or **remotely from the main menu behind a PIN + challenge-response** (plan 0019; the camera always wins the radio) - On-device session replay: instant DOVEX header replay - **Insta360 X4 camera auto-record**: emulates the Insta360 GPS Remote as a pure BLE peripheral — wakes the camera on engine start, records via a ce82 @@ -162,6 +164,9 @@ desktop toolchain. This is where logic worth unit-testing lives. | `gps_status_page.{h,cpp}` | GPS status boot page state machine: hold, 3 s auto-close after fix+timeValid, button skip, exit destination (menu vs race), idle → shutdown; `timeSyncState()` names which time milestone is outstanding (date/time vs the slow `fullyResolved`) | | `sd_format_page.{h,cpp}` | SD format-confirm boot page state machine: Select held 3 s continuously → format (release restarts the full window; other buttons never confirm), 5 min idle → shutdown | | `sd_probe.{h,cpp}` | Boot SD probe verdict (mounted / retry / unformatted / dead): the format offer needs the card layer to answer on **two consecutive** probes across a 250 ms settle with the volume mounting on neither — one no-volume look is what a card wedged mid-command by a WDT-interrupted boot produces — and a card-layer failure at any point is "dead" (FAULT), never "blank" | +| `sha256.{h,cpp}` | SHA-256 + HMAC-SHA256 for the remote-transfer handshake (plan 0019), pinned to FIPS 180-4 and RFC 4231 vectors — the viewer (WebCrypto) and LapWing (hmac/sha2) must agree byte-for-byte | +| `remote_auth.{h,cpp}` | Remote transfer rules (plan 0019): the challenge answer (`HMAC(PIN, "BEAUTH1|"+nonce)`, first 16 bytes as hex — golden vector shared with both apps), single-use nonce, 5-try lockout doubling 60 s → 15 min (wrap-safe), 20 s squat limit, locked-mode command gating (`AUTH?`/`AUTH:`/`BATT` only, exact match; `PINGET` open-mode only), protected setting keys + PIN format (an unreadable/invalid stored PIN is `kPinUnavailable` — nonce kept, nothing counted), the standby predicate + 5 s post-squat back-off, `cameraWantsRadio()` with its 500/300 rpm engine latch, and `remoteSessionEnd()` (engine > camera > 10 min idle; never during an accepted OTA apply) | +| `pin_page.{h,cpp}` | Transfer → PIN page state machine (plan 0019): hold Select 3 s on Show (reveal 15 s) or New PIN; release / row change / side button cancels; the entry press never counts | | `sat_bars.{h,cpp}` | Status-page satellite signal bars: NAV-SAT CNO selection (used-in-nav first, strongest first) + bar x/w/h layout math for the 128×~30 px bottom half | ### Simulator (`BirdsEye/sim/`) @@ -243,6 +248,7 @@ loop() ~250 Hz ├─ TACH_LOOP() re-enable ISR after debounce, apply EMA filter ├─ ACCEL_LOOP() read LSM6DS3 accelerometer X/Y/Z (g-force) ├─ BLUETOOTH_LOOP() stream file chunks if transfer active + ├─ BLE_STANDBY_LOOP() menu remote-transfer advert + PIN handshake (plan 0019) ├─ SENSOREGG_LOOP() drain SensorEgg scan buffer → Temp1/Junction1 ├─ trackDetectionLoop() haversine scan → create CourseManager on match ├─ checkForNewLapData() reads from active timer (CourseManager or lapTimer) @@ -557,8 +563,10 @@ loop() ~250 Hz row — see `replayItemCount()` in `replay.h`, the single source of that layout for the renderer, the menu limit and the select handler), `PAGE_REPLAY_RESULTS` (-8), `PAGE_REPLAY_EXIT` (-9). - - Transfer: `PAGE_TRANSFER_MENU` (-4) Bluetooth/USB/Back submenu, - `PAGE_USB_STORAGE` (-5) USB drive active. + - Transfer: `PAGE_TRANSFER_MENU` (-4) Bluetooth/USB/PIN/Back submenu + (scrolling 3-row window), `PAGE_USB_STORAGE` (-5) USB drive active, + `PAGE_TRANSFER_PIN` (-22) hold-to-reveal / hold-to-replace the BLE PIN + (`transferPinPageLoop()` + the `pin_page` unit; plan 0019). - **Every menu page carries a Back/Cancel row.** Both transfer modes and the replay browser leave by rebooting or by walking forward, and the idle-shutdown timer only runs on the main menu (and the fault page), so @@ -734,6 +742,50 @@ loop() ~250 Hz - Upload/delete state machines: BLE callback sets flags, `BLUETOOTH_LOOP()` calls `processTrackUpload()` / `processTrackDelete()` for thread-safe SD access. Both call `buildTrackList()` after success. +- **Remote transfer + PIN (plan 0019)**. `BLE_STANDBY_LOOP()` advertises + the transfer service while on `PAGE_MAIN_MENU` with the radio free, no + race, `remote_transfer` not `off`, and `!cameraWantsRadio()`. Standby + holds `bleOwner = TRANSFER` with `bleActive` FALSE, so nothing parks and a + standby peer's disconnect never reboots. The session mode + (`bleSessionMode`) is **locked**: the callback answers anything but + `AUTH?` / `AUTH:` / `BATT` with `AUTH:REQUIRED`. `AUTH?` → + `AUTH:NONCE:` (16 bytes, `hwRandomBytes()`); `AUTH:` → + `AUTH:OK` (promote: transfer page, `bleRemoteSession`, labelled *Remote + Transfer*) / `AUTH:FAIL:` / `AUTH:LOCKED:` / `AUTH:NO_NONCE` / + `AUTH:BUSY` (also the answer when the stored PIN is unreadable or not + four digits — the nonce is kept and no failure counted). Unauthenticated + after 20 s → `AUTH:TIMEOUT` + drop, then the advert stays down 5 s so a + squatter can't hold the slot in a tight loop. A local + `BLE_SETUP()` is **open**: `AUTH?` → `AUTH:OPEN`, and `PINGET` → + `PIN:` is how an app pairs — after which the transfer page's + title reads `PIN sent to app!`, since the first app in range need not be + the owner's. `BLE_SETUP()` drops any surviving link and clears every + pending flag first, so a standby peer can never be promoted into an open + session. `SLIST` omits `bluetooth_pin`, + `SGET:bluetooth_pin` → `SERR:PROTECTED`, `SSET:bluetooth_pin` needs four + digits (`SERR:BAD_VALUE`), and `SRESET` keeps a valid PIN. **Camera + first**: standby stops (an unauthed + peer gets `AUTH:CAMERA`) when the camera FSM leaves IDLE/UNPAIRED, the + bench page opens, the camera owns the radio, or a paired camera's engine + passes 500 rpm (latched until it falls below 300, so a pull-start can't + flap the advert) — ahead of the FSM's 2 s wake debounce, which is why the + standby step runs before `CAMERA_LOOP()`. **Standby teardown order**: + owner → NONE first (writes ignored from then on), advert stopped, then + the peer dropped and the disconnect WAITED for (bounded, WDT-fed) before + the loop moves on — and the disconnect callback matches our own link by + handle before routing on owner, so the camera taking the radio in the + same iteration can never be handed the standby peer's disconnect. A link + being dropped is marked (`bleDroppingConnHandle`) and gets no further + commands. The parked branch runs `bleRemoteSessionGuard()` (with + `TACH_LOOP()`); a **remote** session ends — notice, then the usual + reboot — on engine > 500 rpm whether or not a camera is paired + (`AUTH:ENGINE`), on the camera wanting the radio (`AUTH:CAMERA`), or + after 10 min without a request (`AUTH:IDLE`; an in-flight transfer counts + as activity). Nobody at the logger started it, so without these it could + park the logger with no logging, auto-race or idle shutdown. Remote start + never calls `CAMERA_FORCE_RELEASE()`. The PIN + only blocks other people in the pits — the link is unencrypted, so a + sniffer can brute-force a captured handshake offline (see the plan). - **Firmware OTA commands** (`FW*`, handled by `firmware_ota.ino` — see subsystem 11): `FWBEGIN`/`FWPUT`/`FWDONE`/`FWAPPLY`/`FWABORT`/`FWDFU`. The BLE callback dispatches them via `fwIsCommand()`/`fwHandleCommand()` and routes @@ -987,7 +1039,8 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. never touched. The charging-loop soft resume (`softResumeFromCharging()`) restarts the egg scanner via `SENSOREGG_WAKE()` and re-raises the strip via `NEOPIXEL_WAKE()`; - BLE/camera stay lazy. + the camera stays lazy, and the remote-transfer standby advert (plan + 0019) restarts by itself once the menu is up again. - **System OFF entry** (`shutdownSystemOff()`, no return): wait for the entry combo's buttons to release (a held button = SENSE satisfied = instant wake-reset), **sample the tach line's parked idle level** @@ -1003,7 +1056,9 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. hardcoded). Then clear the GPIO LATCH registers (a set latch = pending DETECT = instant re-wake), clear pending FPU exceptions, then `sd_power_system_off()` when the SoftDevice is enabled - (BLE is lazy — check `sd_softdevice_is_enabled()`) else raw + (the SoftDevice may never have started — no SD card, `remote_transfer` + off, or a shutdown before the menu was reached — so check + `sd_softdevice_is_enabled()`) else raw `NRF_POWER->SYSTEMOFF`. **GPREGRET is untouched** — register 0 belongs to the OTA/bootloader handoff (subsystem 11). The WDT halts in System OFF (all clocks stop); `wdtSetup()` re-arms on the fresh boot. @@ -1293,9 +1348,12 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. Shutdown entry runs `CAMERA_SLEEP()` (same, plus a power-off that is **streamed synchronously** before the disconnect — the chip powers off right after, so the non-blocking ce82 hold would otherwise never transmit - a frame and the camera would run all night). BLE - comes up **lazily** on the first camera action (first advertising - `Action`), so unpaired users pay zero RAM/power cost. + a frame and the camera would run all night). The + camera is no longer what brings BLE up: since plan 0019 the remote-transfer standby + starts the BLE core on the first main-menu visit (unless + `remote_transfer` is `off` or there is no SD card), and SensorEgg builds + start it at boot. The camera itself still costs nothing until its first + advertising `Action`, so an unpaired camera adds no radio time. - **Threading**: same deferred pattern as `firmware_ota` — Bluefruit callbacks (connect/disconnect/ce81 writes/ce82 CCCD writes) only copy into RAM and set volatile flags; `CAMERA_LOOP()` on the main loop @@ -1355,7 +1413,8 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`. compiles `sensoregg.ino` down to no-op `SENSOREGG_SETUP/LOOP` and NaN accessors, drops the Temp1 and Temp2 race pages from the rotation (`display_pages.ino` + the page-constant block in `BirdsEye.ino`), and - returns BLE to lazy init. `1` (passed by `beta.yml`, and by + returns BLE init to the first main-menu visit (the plan-0019 remote + transfer standby) instead of boot. `1` (passed by `beta.yml`, and by `compile-sketch.yml` for PRs targeting `BETA`) is everything described below. **Plan 0013 closed the three leaks**: the DOVEX `Temp1`/`Junction1`/`Temp2` columns, the `temp1_alert_c` setting, and @@ -2001,14 +2060,16 @@ the one loaded). Sector lines stay optional — zero, one, or two. "utc_offset_min": "0", "led_brightness_night": "16", "led_day_start_hour": "7", - "led_night_start_hour": "19" + "led_night_start_hour": "19", + "remote_transfer": "on" } ``` | Key | Type | Default | Purpose | |-----|------|---------|---------| | `bluetooth_name` | string | Random | BLE device name | -| `bluetooth_pin` | string | Random 4-digit | BLE pairing PIN | +| `bluetooth_pin` | string | Random 4-digit (hardware RNG) | Remote-transfer PIN (plan 0019). **Never readable over BLE except `PINGET` on a locally started session**; shown on-device under Transfer → PIN; `SSET` accepts exactly 4 digits; a stored value that isn't 4 digits is regenerated at boot; `SRESET` keeps it; never printed on debug serial | +| `remote_transfer` | string | `"on"` | Menu standby advert for PIN-gated remote transfer (plan 0019). Only an explicit `off` disables it. Read once per boot | | `camera_serial` | string | `""` (empty = unpaired) | Paired Insta360 X4's 6-char serial (auto-captured on pairing, or entered manually) | | `device_name` | string | Random racing words | Identifies the logging device (DOVEX header) | | `driver_name` | string | `"Driver"` | Logged in DOVEX header | @@ -2070,6 +2131,12 @@ the one loaded). Sector lines stay optional — zero, one, or two. | Onboard charging (HICHG hold + USB charge UX) | `BIRDSEYE_ENABLE_ONBOARD_CHARGING`, default 0 (all channels) | `project.h` | | SensorEgg wireless EGT POC | `BIRDSEYE_ENABLE_SENSOREGG`, default 0; 1 on the beta channel | `project.h` | | Charging screen timeout | 10 s (`CHARGE_DISPLAY_TIMEOUT_MS`) | `project.h` | +| Remote auth lockout | 5 wrong answers → 60 s, doubling per lockout to 15 min; RAM only | `remote_auth.h` | +| Remote auth squat limit | 20 s connected without `AUTH:OK` → dropped, then 5 s with no advert | `remote_auth.h` | +| Remote session end | engine > 500 rpm (`AUTH:ENGINE`) / camera wants radio (`AUTH:CAMERA`) / 10 min without a request (`AUTH:IDLE`) | `remote_auth.h` | +| Remote standby camera engine latch | claim > 500 rpm, release < 300 rpm (paired camera only) | `remote_auth.h` | +| BLE local-drop settle | ≤ 1 s, WDT-fed (`BLE_DROP_SETTLE_MS`) | `bluetooth.ino` | +| Transfer PIN page | hold Select 3 s; reveal lasts 15 s | `pin_page.h` | | Sat bars display cap / CNO ceiling | 16 bars / 50 dB-Hz | `sat_bars.h` | | Crossing threshold | 7.0 m | `BirdsEye.ino` | | Max laps/session | 1 000 | `BirdsEye.ino` | diff --git a/README.md b/README.md index 7e5d51e..974fd0c 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ Settings are stored in `/SETTINGS.json` on the SD card. The file is created auto | Setting | Description | Default | |---|---|---| | `bluetooth_name` | BLE device name visible during pairing | Random (e.g. `DovesDataLogger-042`) | -| `bluetooth_pin` | PIN displayed on device for webapp pairing | Random 4-digit | +| `bluetooth_pin` | PIN the app proves it knows before a remote transfer. See it on the device under Transfer → PIN (hold Select 3 s). Never sent over Bluetooth except to an app connected through a transfer started on the device | Random 4-digit | +| `remote_transfer` | `on`: advertise from the main menu so the app can start a PIN-gated transfer remotely. `off`: only Transfer → Bluetooth on the device starts one | `on` | | `race_mode` | Circuit-vs-sprint tiebreak when both track kinds are within detection range (`sprint` always prefers the sprint track; `circuit` yields only on an event day) | `circuit` | | `driver_name` | Driver name logged in DOVEX session header | `Driver` | | `lap_detection_distance` | Crossing detection threshold in meters | `7` | @@ -275,7 +276,7 @@ DovesDataLogger/ # repo root │ ├── gps_config.h # GPS configuration constants (baud, nav rate) │ ├── images.h # PROGMEM bitmap data (splash, animations) │ ├── accelerometer.{h,ino} # LSM6DS3 IMU init and g-force reads -│ ├── bluetooth.{h,ino} # BLE service (file transfer, settings, track sync) +│ ├── bluetooth.{h,ino} # BLE service (file transfer, settings, track sync, remote transfer) │ ├── display_pages.{h,ino} # All page rendering functions (displayPage_*()) │ ├── display_ui.{h,ino} # Display init, button handling, menu navigation │ ├── gps_functions.{h,ino} # GPS init, PVT callback, time conversion, logging diff --git a/docs/plans/0019-remote-transfer-pin.md b/docs/plans/0019-remote-transfer-pin.md new file mode 100644 index 0000000..d50b760 --- /dev/null +++ b/docs/plans/0019-remote-transfer-pin.md @@ -0,0 +1,235 @@ +# 0019 — Remote transfer with a paired PIN + +Status: **implemented, pending hardware check** (logger side). Companion plans: DovesDataViewer +`docs/plans/0031-remote-transfer-pin.md`; LapWing implements the same +handshake in `loggers/doveslogger/` (no plan folder there). + +## Goal + +Let the webapp (and LapWing) start a Bluetooth transfer while the logger sits +on its **main menu**, without anyone pressing Transfer → Bluetooth — gated by +the `bluetooth_pin` every logger has generated since first boot but that +nothing ever checked. Not a wake-from-sleep: System OFF has no radio, so the +logger must already be awake on the menu. + +The SD card is soldered in on current hardware, so "pull the card and read +`SETTINGS.json`" is gone as a recovery path. The PIN has to be viewable on +the device, and recoverable when lost, without being shown on the transfer +screen all the time. + +## Where things stood + +- `bluetooth_pin` came from `random(1000, 10000)` seeded with `micros()` — + not a strong random number. +- No code read it. The file-service characteristics are `SECMODE_OPEN` and + pairing is Just Works with MITM off. +- `SLIST` / `SGET:bluetooth_pin` handed the PIN to whoever was connected, and + `SRESET` regenerated it remotely. **A PIN gate is meaningless while the + protocol reads the PIN out to an unauthenticated peer**, so redaction is + step one. +- The only access control was standing next to the device and pressing the + button. + +## Two session modes + +- **Open (local start)** — Transfer → Bluetooth on the device. Unchanged: + every command works. This is the *pairing moment*: the app reads the PIN + with `PINGET` and stores it. +- **Locked (remote start)** — the logger advertises 0x1820 from the main + menu. A connected peer may only send `AUTH?`, `AUTH:` and `BATT` (DIS + reads stay available). Everything else answers `AUTH:REQUIRED`. `AUTH:OK` + switches the logger into the normal transfer page and code path, labelled + as a remote session. + +## Handshake (challenge-response) + +``` +app → AUTH? +log → AUTH:NONCE:<32 hex> (locked) | AUTH:OPEN (open) + | AUTH:LOCKED: | AUTH:CAMERA +app → AUTH: +log → AUTH:OK | AUTH:FAIL: | AUTH:LOCKED: | AUTH:NO_NONCE + | AUTH:BUSY (RNG pool empty, an answer already queued, or the + stored PIN unreadable / not 4 digits) +``` + +- The handshake never sends the PIN; a recorded exchange can't be replayed + because the nonce is single-use (consumed on any right or wrong answer) + and a new `AUTH?` discards the previous one. The PIN does cross the air + in one place — `PINGET` on a local start — see the threat model below. +- **The logger's own failure is not a wrong answer.** If the stored PIN + can't be read (SD refused, corrupt file) or isn't exactly 4 digits (a + hand-edited `"12345"`, or `1234` stored as a JSON number), the answer is + `AUTH:BUSY`: the nonce stays valid and no failure is counted, so the app + retries instead of walking toward a lockout it did nothing to earn. Boot + replaces a stored PIN that isn't 4 digits. +- Nonce = 16 bytes from the SoftDevice RNG (`sd_rand_application_vector_get`). +- **Not bound to `bluetooth_name`** (changed during implementation). The + design artifact mixed the name into the MAC, but the nonce is already + unique to this logger and this attempt, and the advertised name can be + truncated on air (31-byte advert), so an app could hold a correct PIN and + still compute the wrong answer. Two correct implementations disagreeing + buys nothing. +- Golden vector, pinned in all three implementations: PIN `4821`, nonce + `00112233445566778899aabbccddeeff` → `4936f5dad4502101eb0f65790cca77b4`. +- Lowercase hex on the wire; the logger compares case-insensitively and in + constant time. + +### Other wire changes + +| App sends | Logger replies | When | +|---|---|---| +| `PINGET` | `PIN:` / `AUTH:REQUIRED` / `SERR:NOT_FOUND` (unreadable or not 4 digits) | Open mode only. The transfer page then reads `PIN sent to app!` | +| `SLIST` | as before, **minus `bluetooth_pin`** | All modes | +| `SGET:bluetooth_pin` | `SERR:PROTECTED` | All modes — use `PINGET` | +| `SSET:bluetooth_pin=…`, `SRESET` | as before | Open, or locked after `AUTH:OK`. PIN must be exactly 4 digits (`SERR:BAD_VALUE`). `SRESET` **keeps** a valid PIN — a remote app that reset the logger could never learn a new one; replacing it is the device's PIN page | +| (unsolicited) | `AUTH:CAMERA` | Right before the logger drops a link because the camera needs the radio | +| (unsolicited) | `AUTH:TIMEOUT` | Right before a peer that never authenticated is dropped (20 s); the advert then stays down 5 s | +| (unsolicited) | `AUTH:ENGINE` | Right before a **remote** session ends because the engine passed 500 rpm (camera paired or not) | +| (unsolicited) | `AUTH:IDLE` | Right before a **remote** session ends after 10 min with no request (an in-flight download/upload/OTA counts as activity) | + +All four drop notices are best-effort: the logger sends one, waits 50 ms, +then drops the link (a remote session then reboots, as every transfer +exit does). An app must treat a plain disconnect the same way. + +## Logger rules + +- **When it advertises:** `PAGE_MAIN_MENU`, radio owner `NONE`, camera + inactive, no race session, `remote_transfer` setting on. Leaving the menu + stops the advert. Auto-race and the 5-minute menu idle shutdown are + unchanged, so standby lasts exactly as long as the menu does. +- **Squatting:** a peer without `AUTH:OK` after 20 s is disconnected, and + the advert stays down for 5 s. One slot means anyone in range (or a + bonded camera chasing our address) can hold it in a 20 s loop; the + back-off stops it being a tight one. It is still a denial of service the + PIN can't prevent — it never bypasses auth. +- **A remote session can't strand the logger.** It parks `loop()` (no + logging, no auto-race, no idle shutdown), and nobody at the logger + started it, so it ends — `AUTH:ENGINE` / `AUTH:CAMERA` / `AUTH:IDLE`, then + the usual reboot — on engine > 500 rpm, on the camera wanting the radio, + or after 10 minutes without a request. Engine first; an accepted OTA + apply is never interrupted. The rule is `remote_auth::remoteSessionEnd`. +- **Handing the radio over is synchronous.** Standby stop releases the + owner first (so a write that preempts the teardown is ignored), stops the + advert, then waits (≤ 1 s, WDT-fed) for its peer's disconnect before the + loop moves on — the camera can claim the radio in the same iteration, + and the disconnect callback matches our link by handle before routing on + owner, so the camera is never handed the standby peer's disconnect. A + link being dropped is marked and gets no further commands. +- **A local start begins empty.** `BLE_SETUP()` drops any surviving + transfer-side link and clears every pending flag before opening an open + session — otherwise an unauthenticated standby peer whose link outlived + the menu would be promoted straight into it, `PINGET` included. +- **State:** standby holds `bleOwner = TRANSFER` with `bleActive` false — + nothing parks, and a standby peer's disconnect never reboots. `AUTH:OK` + promotes into the ordinary transfer page (`bleActive`, reboot on + disconnect) with the session still locked-and-authenticated, so `PINGET` + stays off. +- **Guessing:** 5 wrong answers lock auth for 60 s, doubling per lockout up + to 15 min. RAM-only — a reboot clears it, which needs physical access. +- **PIN generation** moves to the hardware RNG. + +## The camera always wins + +One peripheral link is shared by the transfer service and the camera remote +(`bleOwner`). Remote transfer only ever gets it when the camera has no use +for it. + +- "Camera active" = FSM anywhere except `kUnpaired`/`kIdle`, OR the camera + test page open, OR `bleOwner == CAMERA`. No standby advert while active. +- Camera turns active during standby → advert stops that iteration; an + unauthenticated peer gets `AUTH:CAMERA` and is dropped. +- Engine starts with a paired camera → RPM above 500 (the camera wake + threshold) stops standby, or ends a **remote** transfer (`AUTH:ENGINE` + — since the review, any engine start ends a remote session, camera or + not — then the usual reboot). The engine claim is latched until RPM + falls below 300, so a pull-start cranking through 500 doesn't flap the + advert (an SD read and an advert rebuild each time). This fires ahead of + the camera FSM's own 2 s wake debounce, and the standby step runs before + `CAMERA_LOOP()`, so the camera never finds the radio taken. The transfer branch parks `loop()`, so the + remote guard runs `TACH_LOOP()` itself. +- Remote start **never** calls `CAMERA_FORCE_RELEASE()`. Only a person at + the logger choosing Transfer → Bluetooth bumps the camera (today's rule). +- Consequence: after a camera session the camera stays in `WATCHING` until + shutdown, so remote transfer is unavailable until the next power-up. + +## Seeing and recovering the PIN + +Never on the transfer screen. Every path needs the device in hand. + +- **Transfer → PIN** (new row between USB and Back; the Transfer menu now + scrolls): rows *Show PIN*, *New PIN*, *Back*. Hold Select 3 s on Show to + reveal for 15 s, then it hides and the digits are wiped from RAM. Hold 3 s + on New PIN to regenerate it (persist-first, then shown). Apps forget the + old one on their next `AUTH:FAIL`. +- **Local Bluetooth transfer:** the app re-learns it via `PINGET`. +- **USB drive:** Transfer → USB exposes `SETTINGS.json`. + +## Mixed versions + +- New app, old firmware: no reply to `AUTH?` in 2 s → legacy, local start only. +- Old app, new firmware: local start works; remote start answers + `AUTH:REQUIRED`; the settings screen no longer shows the PIN. + +## What the PIN does and doesn't protect + +It keeps the pits out (weeks of over-the-air guessing with the lockout). Two +gaps are accepted: + +- **A local start hands the PIN to whoever connects first.** Transfer → + Bluetooth is open to anyone in range, as it always was — but where a + stranger who won that race used to get one session, they now also get + the PIN (`PINGET`) and keep remote access until it is replaced. Mitigated, + not closed: the session starts with no link left over from the menu + advert, the transfer page says `PIN sent to app!` the moment `PINGET` is + served so the person who opened it can see it, and *New PIN* on the + device revokes it. + +It also does **not** stop a sniffer: the link is unencrypted Just Works, so data +after `AUTH:OK` is readable and one captured handshake brute-forces offline +instantly (6 digits would not change that). Real protection needs an +encrypted, bonded link (LE Secure Connections) — a separate project. + +## Decisions + +- **4-digit PIN** (Dove, 2026-10-07): existing loggers keep their PIN; 6 + digits does not change the sniffer case. +- **`remote_transfer` setting, default on** (`on`/`off`; anything but an + explicit `off` means on). + +## Touch points + +- Pure units + host tests: `sha256` (FIPS 180-4 + RFC 4231 HMAC vectors), + `remote_auth` (nonce/answer/lockout/timeout state machine, command gating, + protected-key and PIN-format rules, standby/camera predicates, the + engine latch, the squat back-off, `remoteSessionEnd`), + `pin_page` (the hold-to-reveal page). +- `bluetooth.ino`: AUTH dispatch, locked-mode gate, settings redaction, + standby advert + teardown, remote session promotion and guard, the + handle-first disconnect routing. +- `settings.ino`: hardware-RNG PIN, `remote_transfer` default, invalid-PIN + regeneration, PIN kept across `SRESET`, PIN off the debug log. +- `BirdsEye.ino`: standby step in `loop()`, remote parked-branch guard. +- `display_ui.ino` / `display_pages.ino`: PIN page; `BirdsEye.ino` + `transferPinPageLoop()`. +- Sim: stubs for the new BLE surface, goldens for the PIN page (the + transfer menu's hash moved with its fourth row). +- `camera_ble.{h,ino}`: camera-active accessor (+ sim stub). +- Docs: `CLAUDE.md`, `ARCHITECTURE.md`, `CHANGELOG.md`. + +## Status + +- [x] Plan committed +- [x] PIN redaction, `PINGET`, write gates, hardware RNG +- [x] Auth pure units + tests +- [x] Standby advertising, locked mode, camera priority +- [x] PIN page +- [x] Docs + changelog +- [x] Review fixes (PR #168): synchronous standby handover + handle-first + disconnect routing, remote-session end on engine / idle, unreadable PIN + = `AUTH:BUSY`, local start drops leftover links + "PIN sent", engine + latch, squat back-off, PIN kept across `SRESET`, PIN off debug serial +- [ ] Hardware check: a phone authenticating from the menu, the lockout, + the camera taking the radio back with the engine running, and a remote + session ending on engine start (`AUTH:ENGINE`) with no camera paired diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4db3cf8..6662549 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -47,7 +47,13 @@ add_executable(birdseye_tests loop_profile_test.cpp drag_timer_test.cpp drag_tree_test.cpp + sha256_test.cpp + remote_auth_test.cpp + pin_page_test.cpp ${BIRDSEYE_DIR}/haversine.cpp + ${BIRDSEYE_DIR}/sha256.cpp + ${BIRDSEYE_DIR}/remote_auth.cpp + ${BIRDSEYE_DIR}/pin_page.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp ${BIRDSEYE_DIR}/gps_validation.cpp diff --git a/tests/pin_page_test.cpp b/tests/pin_page_test.cpp new file mode 100644 index 0000000..afe5059 --- /dev/null +++ b/tests/pin_page_test.cpp @@ -0,0 +1,117 @@ +#include "doctest.h" + +#include "pin_page.h" + +using namespace pin_page; + +namespace { + +Action press(State& s, int row, uint32_t t, bool held = true, bool other = false) { + Inputs in; + in.selectHeld = held; + in.otherButtonHeld = other; + in.row = row; + in.nowMs = t; + return step(s, in); +} + +} // namespace + +TEST_CASE("the press that opened the page never counts") { + State s; + begin(s); + for (uint32_t t = 0; t <= 5000; t += 100) CHECK(press(s, kRowShow, t) == Action::kNone); + CHECK(holdSecondsLeft(s, 5000) == 0); +} + +TEST_CASE("the entry press on the Transfer menu's PIN row never counts") { + // Transfer -> PIN is opened from the Transfer menu's third row, so for a + // frame or two the shared menu index still reads 2 (this page's Back) + // before the page resets it to Show — all while the opening Select is + // still down. Neither row may arm a hold off that press. + State s; + begin(s); + CHECK(press(s, kRowBack, 0) == Action::kNone); + for (uint32_t t = 100; t <= 5000; t += 100) { + CHECK(press(s, kRowShow, t) == Action::kNone); + CHECK(holdSecondsLeft(s, t) == 0); + } + // Released once, a fresh hold then works as normal. + press(s, kRowShow, 5100, false); + press(s, kRowShow, 5200); + CHECK(press(s, kRowShow, 5200 + kHoldMs) == Action::kReveal); +} + +TEST_CASE("hold Select 3 s on Show reveals, once per hold") { + State s; + begin(s); + press(s, kRowShow, 0, false); + CHECK(press(s, kRowShow, 1000) == Action::kNone); + CHECK(holdSecondsLeft(s, 1000) == 3); + CHECK(holdSecondsLeft(s, 3500) == 1); + CHECK(press(s, kRowShow, 3999) == Action::kNone); + CHECK(press(s, kRowShow, 4000) == Action::kReveal); + // Still held: no repeat. + CHECK(press(s, kRowShow, 9000) == Action::kNone); +} + +TEST_CASE("hold on New PIN asks for a new PIN") { + State s; + begin(s); + press(s, kRowNewPin, 0, false); + press(s, kRowNewPin, 10); + CHECK(press(s, kRowNewPin, 3010) == Action::kNewPin); +} + +TEST_CASE("release, row change or a side button cancels with no credit") { + State s; + begin(s); + press(s, kRowShow, 0, false); + press(s, kRowShow, 100); + press(s, kRowShow, 2000, false); // released + press(s, kRowShow, 2100); + CHECK(press(s, kRowShow, 4000) == Action::kNone); // only 1.9 s since re-hold + + begin(s); + press(s, kRowShow, 0, false); + press(s, kRowShow, 100); + press(s, kRowNewPin, 2000); // moved + CHECK(press(s, kRowNewPin, 4000) == Action::kNone); + CHECK(press(s, kRowNewPin, 5000) == Action::kNewPin); + + begin(s); + press(s, kRowShow, 0, false); + press(s, kRowShow, 100); + press(s, kRowShow, 1000, true, true); // reboot combo + CHECK(press(s, kRowShow, 3200) == Action::kNone); +} + +TEST_CASE("Back never arms a hold") { + State s; + begin(s); + press(s, kRowBack, 0, false); + for (uint32_t t = 0; t <= 6000; t += 100) CHECK(press(s, kRowBack, t) == Action::kNone); +} + +TEST_CASE("reveal lasts 15 s and wraps safely") { + State s; + begin(s); + CHECK_FALSE(isRevealed(s, 0)); + reveal(s, 1000); + CHECK(isRevealed(s, 1000)); + CHECK(revealSecondsLeft(s, 1000) == 15); + CHECK(revealSecondsLeft(s, 15999) == 1); + CHECK_FALSE(isRevealed(s, 16000)); + CHECK(revealSecondsLeft(s, 16000) == 0); + + reveal(s, 0xffffffffu - 1000u); + CHECK(isRevealed(s, 5000u)); // wrapped, 6 s in + CHECK_FALSE(isRevealed(s, 14000u)); +} + +TEST_CASE("begin hides the PIN") { + State s; + reveal(s, 0); + begin(s); + CHECK_FALSE(isRevealed(s, 1)); +} diff --git a/tests/remote_auth_test.cpp b/tests/remote_auth_test.cpp new file mode 100644 index 0000000..0d548dc --- /dev/null +++ b/tests/remote_auth_test.cpp @@ -0,0 +1,396 @@ +#include "doctest.h" + +#include + +#include "remote_auth.h" + +using namespace remote_auth; + +namespace { + +const uint8_t kNonceA[kNonceLen] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}; + +// The answer for kNonceA / "4821". The SAME vector is +// pinned in DovesDataViewer (src/lib/ble/auth.test.ts) and LapWing +// (loggers/doveslogger/auth.rs) — change all three together or none. +const char kGoldenAnswer[] = "4936f5dad4502101eb0f65790cca77b4"; + +void answerFor(const State& s, const char* pin, char* out) { + computeAnswer(pin, s.nonceHex, out); +} + +} // namespace + +TEST_CASE("answer matches the cross-implementation golden vectors") { + char out[kAnswerHexLen + 1]; + computeAnswer("4821", "00112233445566778899aabbccddeeff", out); + CHECK(std::strcmp(out, kGoldenAnswer) == 0); + computeAnswer("1000", "ffffffffffffffffffffffffffffffff", out); + CHECK(std::strcmp(out, "3db7fac4dc48559fa85594079b59fce4") == 0); +} + +TEST_CASE("PIN format") { + CHECK(isValidPin("4821")); + CHECK(isValidPin("0000")); + CHECK_FALSE(isValidPin("482")); + CHECK_FALSE(isValidPin("48210")); + CHECK_FALSE(isValidPin("48a1")); + CHECK_FALSE(isValidPin(" 482")); + CHECK_FALSE(isValidPin("")); + CHECK_FALSE(isValidPin(nullptr)); +} + +TEST_CASE("pinFromRandom stays in 1000-9999") { + char pin[kPinLen + 1]; + pinFromRandom(0, pin); + CHECK(std::strcmp(pin, "1000") == 0); + pinFromRandom(8999, pin); + CHECK(std::strcmp(pin, "9999") == 0); + pinFromRandom(9000, pin); + CHECK(std::strcmp(pin, "1000") == 0); + pinFromRandom(0xffffffffu, pin); + CHECK(isValidPin(pin)); + CHECK(pin[0] != '0'); +} + +TEST_CASE("constant-time hex compare is case-insensitive and length-strict") { + CHECK(hexEqualConstTime("abcd", "ABCD", 4)); + CHECK_FALSE(hexEqualConstTime("abce", "abcd", 4)); + CHECK_FALSE(hexEqualConstTime("abc", "abcd", 4)); + CHECK_FALSE(hexEqualConstTime("abcde", "abcd", 4)); + CHECK_FALSE(hexEqualConstTime(nullptr, "abcd", 4)); +} + +TEST_CASE("right answer authenticates and consumes the nonce") { + State s; + issueNonce(s, kNonceA); + CHECK(std::strcmp(s.nonceHex, "00112233445566778899aabbccddeeff") == 0); + CHECK(checkAnswer(s, kGoldenAnswer, "4821", 0) == Verdict::kOk); + CHECK(s.authed); + // Replay of the same answer: the nonce is gone. + CHECK(checkAnswer(s, kGoldenAnswer, "4821", 0) == Verdict::kNoNonce); +} + +TEST_CASE("uppercase answer is accepted") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "4936F5DAD4502101EB0F65790CCA77B4", "4821", 0) == + Verdict::kOk); +} + +TEST_CASE("answer is bound to the PIN") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, kGoldenAnswer, "4822", 0) == Verdict::kFail); +} + +TEST_CASE("wrong answer consumes the nonce and counts down") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", 0) == Verdict::kFail); + CHECK(triesLeft(s) == kMaxFails - 1); + CHECK_FALSE(s.nonceValid); + CHECK_FALSE(s.authed); +} + +TEST_CASE("malformed answers count as wrong") { + State s; + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "short", "4821", 0) == Verdict::kFail); + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "zzc61d4ff111ba3236b50217443c3daa", "4821", 0) == Verdict::kFail); + CHECK(triesLeft(s) == kMaxFails - 2); +} + +TEST_CASE("an unreadable or invalid stored PIN never authenticates") { + State s; + issueNonce(s, kNonceA); + char ans[kAnswerHexLen + 1]; + computeAnswer("", s.nonceHex, ans); + CHECK(checkAnswer(s, ans, "", 0) == Verdict::kPinUnavailable); + CHECK_FALSE(s.authed); + computeAnswer("12345", s.nonceHex, ans); + CHECK(checkAnswer(s, ans, "12345", 0) == Verdict::kPinUnavailable); + CHECK(checkAnswer(s, ans, "12a4", 0) == Verdict::kPinUnavailable); + CHECK_FALSE(s.authed); +} + +TEST_CASE("PIN unreadable leaves the nonce intact and counts nothing") { + State s; + issueNonce(s, kNonceA); + // Every try with the PIN unreadable: no failure, no lockout, nonce kept. + for (uint8_t i = 0; i < kMaxFails + 2; ++i) { + CHECK(checkAnswer(s, "00000000000000000000000000000000", "", 0) == + Verdict::kPinUnavailable); + } + CHECK(s.nonceValid); + CHECK(triesLeft(s) == kMaxFails); + CHECK_FALSE(isLocked(s, 0)); + // Once the card answers again, the SAME nonce still authenticates. + CHECK(checkAnswer(s, kGoldenAnswer, "4821", 0) == Verdict::kOk); + CHECK_FALSE(s.nonceValid); +} + +TEST_CASE("no nonce outranks an unreadable PIN") { + State s; + CHECK(checkAnswer(s, kGoldenAnswer, "", 0) == Verdict::kNoNonce); +} + +TEST_CASE("five wrong answers lock for 60 s, then doubling to 15 min") { + State s; + uint32_t now = 1000; + for (uint8_t i = 0; i + 1 < kMaxFails; ++i) { + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", now) == Verdict::kFail); + } + issueNonce(s, kNonceA); + CHECK(checkAnswer(s, "00000000000000000000000000000000", "4821", now) == Verdict::kLocked); + CHECK(isLocked(s, now)); + CHECK(lockSecondsLeft(s, now) == 60); + CHECK(lockSecondsLeft(s, now + 59001) == 1); + + // Even the right answer is refused while locked, and the nonce is burnt. + issueNonce(s, kNonceA); + char ans[kAnswerHexLen + 1]; + answerFor(s, "4821", ans); + CHECK(checkAnswer(s, ans, "4821", now + 1000) == Verdict::kLocked); + CHECK_FALSE(s.nonceValid); + + now += 60000; + CHECK_FALSE(isLocked(s, now)); + CHECK(triesLeft(s) == kMaxFails); + + CHECK(lockoutLengthMs(0) == 60000); + CHECK(lockoutLengthMs(1) == 120000); + CHECK(lockoutLengthMs(3) == 480000); + CHECK(lockoutLengthMs(4) == kLockoutMaxMs); + CHECK(lockoutLengthMs(200) == kLockoutMaxMs); +} + +TEST_CASE("lockout survives millis() wrap") { + State s; + const uint32_t now = 0xffffffffu - 10000u; + for (uint8_t i = 0; i < kMaxFails; ++i) { + issueNonce(s, kNonceA); + checkAnswer(s, "00000000000000000000000000000000", "4821", now); + } + CHECK(isLocked(s, now + 30000u)); // wrapped past zero, still inside 60 s + CHECK_FALSE(isLocked(s, now + 60000u)); +} + +TEST_CASE("disconnect clears auth but not the lockout") { + State s; + issueNonce(s, kNonceA); + checkAnswer(s, kGoldenAnswer, "4821", 0); + onDisconnect(s); + CHECK_FALSE(s.authed); + CHECK_FALSE(s.nonceValid); + + for (uint8_t i = 0; i < kMaxFails; ++i) { + issueNonce(s, kNonceA); + checkAnswer(s, "00000000000000000000000000000000", "4821", 0); + } + onDisconnect(s); + CHECK(isLocked(s, 1)); +} + +TEST_CASE("command gating") { + // Open (local start): everything, including PINGET. + CHECK(commandAllowed(Mode::kOpen, false, "LIST")); + CHECK(commandAllowed(Mode::kOpen, false, "PINGET")); + CHECK(commandAllowed(Mode::kOpen, false, "SRESET")); + + // Locked before auth: only the handshake and battery. + CHECK(commandAllowed(Mode::kLocked, false, "AUTH?")); + CHECK(commandAllowed(Mode::kLocked, false, "AUTH:abc")); + CHECK(commandAllowed(Mode::kLocked, false, "BATT")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "LIST")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "SLIST")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "FWDFU")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "AUTHX")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "PINGET")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, nullptr)); + // Exact match only: a leading space is a different (unknown) command. + CHECK_FALSE(commandAllowed(Mode::kLocked, false, " LIST")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, " AUTH?")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, " BATT")); + CHECK_FALSE(commandAllowed(Mode::kLocked, false, "BATT ")); + // An embedded NUL ends the command: what follows can't ride along. + const char authThenList[] = "AUTH?\0LIST"; + CHECK(commandAllowed(Mode::kLocked, false, authThenList)); + CHECK(std::strcmp(authThenList, "AUTH?") == 0); + const char listAfterNul[] = "\0LIST"; + CHECK_FALSE(commandAllowed(Mode::kLocked, false, listAfterNul)); + const char pinGetNul[] = "PINGET\0x"; + CHECK_FALSE(commandAllowed(Mode::kLocked, true, pinGetNul)); + + // Locked after auth: everything but PINGET. + CHECK(commandAllowed(Mode::kLocked, true, "LIST")); + CHECK(commandAllowed(Mode::kLocked, true, "SSET:bluetooth_pin=1234")); + CHECK_FALSE(commandAllowed(Mode::kLocked, true, "PINGET")); +} + +TEST_CASE("protected settings") { + CHECK(isProtectedSettingKey("bluetooth_pin")); + CHECK_FALSE(isProtectedSettingKey("bluetooth_name")); + CHECK_FALSE(isProtectedSettingKey("bluetooth_pin2")); + CHECK_FALSE(isProtectedSettingKey(nullptr)); +} + +TEST_CASE("standby advert only on a free menu with the camera idle") { + StandbyInputs in; + in.onMainMenu = true; + in.radioFree = true; + in.settingEnabled = true; + CHECK(standbyWanted(in)); + + StandbyInputs off = in; + off.settingEnabled = false; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.onMainMenu = false; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.radioFree = false; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.cameraWantsRadio = true; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.raceActive = true; + CHECK_FALSE(standbyWanted(off)); + off = in; + off.squatBackoff = true; + CHECK_FALSE(standbyWanted(off)); +} + +TEST_CASE("squat back-off is 5 s, only once armed, and wrap-safe") { + CHECK_FALSE(squatBackoffActive(false, 1000, 1000)); + CHECK(squatBackoffActive(true, 1000, 1000)); + CHECK(squatBackoffActive(true, 1000, 1000 + kSquatBackoffMs - 1)); + CHECK_FALSE(squatBackoffActive(true, 1000, 1000 + kSquatBackoffMs)); + CHECK(squatBackoffActive(true, 0xfffffff0u, 100)); + CHECK_FALSE(squatBackoffActive(true, 0xfffffff0u, 0xfffffff0u + kSquatBackoffMs)); +} + +TEST_CASE("the camera always wins") { + bool latch = false; + CameraInputs idle; + CHECK_FALSE(cameraWantsRadio(idle, latch)); + + CameraInputs c = idle; + c.fsmIdleOrUnpaired = false; // waking / recording / watching / pairing + CHECK(cameraWantsRadio(c, latch)); + c = idle; + c.testPageOpen = true; + CHECK(cameraWantsRadio(c, latch)); + c = idle; + c.ownsRadio = true; + CHECK(cameraWantsRadio(c, latch)); + + // A paired camera's engine above the wake threshold claims the radio + // before the camera FSM's own debounce does. + c = idle; + c.paired = true; + c.rpm = kCameraWakeRpm; + CHECK_FALSE(cameraWantsRadio(c, latch)); + c.rpm = kCameraWakeRpm + 1; + CHECK(cameraWantsRadio(c, latch)); + CHECK(latch); + + // No paired camera: the engine starting doesn't matter. + latch = false; + c = idle; + c.rpm = 9000; + CHECK_FALSE(cameraWantsRadio(c, latch)); + CHECK_FALSE(latch); +} + +TEST_CASE("the engine claim holds until the engine is really off") { + bool latch = false; + CameraInputs c; + c.paired = true; + // A pull-start: cranking blips over 500 and sags back between pulls. + c.rpm = 450; + CHECK_FALSE(cameraWantsRadio(c, latch)); + c.rpm = 620; + CHECK(cameraWantsRadio(c, latch)); + const uint16_t band[] = {480, 350, 300, 499, 450}; + for (uint16_t rpm : band) { + c.rpm = rpm; + CHECK(cameraWantsRadio(c, latch)); // no flapping between 300 and 500 + } + c.rpm = kCameraReleaseRpm - 1; + CHECK_FALSE(cameraWantsRadio(c, latch)); + CHECK_FALSE(latch); + c.rpm = 450; // back in the band, but not latched: stays released + CHECK_FALSE(cameraWantsRadio(c, latch)); + + // Unpairing releases a latched claim at once. + c.rpm = 900; + CHECK(cameraWantsRadio(c, latch)); + c.paired = false; + c.rpm = 450; + CHECK_FALSE(cameraWantsRadio(c, latch)); + CHECK_FALSE(latch); +} + +TEST_CASE("a remote session ends on the engine, the camera, or idleness") { + RemoteSessionInputs in; + in.lastRequestMs = 1000; + in.nowMs = 2000; + CHECK(remoteSessionEnd(in) == RemoteEnd::kNone); + + // Engine, with or without a camera — and ahead of it. + RemoteSessionInputs e = in; + e.rpm = kEngineRunningRpm; + CHECK(remoteSessionEnd(e) == RemoteEnd::kNone); + e.rpm = kEngineRunningRpm + 1; + CHECK(remoteSessionEnd(e) == RemoteEnd::kEngine); + e.cameraWantsRadio = true; + CHECK(remoteSessionEnd(e) == RemoteEnd::kEngine); + e.busy = true; // a download in flight doesn't hold off the engine + CHECK(remoteSessionEnd(e) == RemoteEnd::kEngine); + + RemoteSessionInputs c = in; + c.cameraWantsRadio = true; + c.busy = true; + CHECK(remoteSessionEnd(c) == RemoteEnd::kCamera); + + // Idle: 10 minutes with no request, unless something is streaming. + RemoteSessionInputs i = in; + i.nowMs = in.lastRequestMs + kRemoteIdleMs - 1; + CHECK(remoteSessionEnd(i) == RemoteEnd::kNone); + i.nowMs = in.lastRequestMs + kRemoteIdleMs; + CHECK(remoteSessionEnd(i) == RemoteEnd::kIdle); + i.busy = true; + CHECK(remoteSessionEnd(i) == RemoteEnd::kNone); + // Wrap-safe. + i.busy = false; + i.lastRequestMs = 0xffffff00u; + i.nowMs = 0xffffff00u + kRemoteIdleMs; + CHECK(remoteSessionEnd(i) == RemoteEnd::kIdle); + i.nowMs = 100; + CHECK(remoteSessionEnd(i) == RemoteEnd::kNone); + + // An accepted OTA apply owns the exit, whatever else is going on. + RemoteSessionInputs o = e; + o.otaApplyPending = true; + o.nowMs = o.lastRequestMs + kRemoteIdleMs; + CHECK(remoteSessionEnd(o) == RemoteEnd::kNone); +} + +TEST_CASE("remote end tokens") { + CHECK(remoteEndToken(RemoteEnd::kNone) == nullptr); + CHECK(std::strcmp(remoteEndToken(RemoteEnd::kEngine), "AUTH:ENGINE") == 0); + CHECK(std::strcmp(remoteEndToken(RemoteEnd::kCamera), "AUTH:CAMERA") == 0); + CHECK(std::strcmp(remoteEndToken(RemoteEnd::kIdle), "AUTH:IDLE") == 0); +} + +TEST_CASE("auth timeout is 20 s and wrap-safe") { + CHECK_FALSE(authTimedOut(1000, 20999)); + CHECK(authTimedOut(1000, 21000)); + CHECK_FALSE(authTimedOut(0xffffff00u, 100)); + CHECK(authTimedOut(0xffffff00u, 0xffffff00u + kAuthTimeoutMs)); +} diff --git a/tests/sha256_test.cpp b/tests/sha256_test.cpp new file mode 100644 index 0000000..8b926c4 --- /dev/null +++ b/tests/sha256_test.cpp @@ -0,0 +1,77 @@ +#include "doctest.h" + +#include +#include + +#include "remote_auth.h" +#include "sha256.h" + +// The remote-transfer handshake (plan 0019) must agree byte-for-byte with +// WebCrypto in the viewer and the hmac/sha2 crates in LapWing, so the +// primitives are pinned to the published vectors. + +namespace { + +std::string hex(const uint8_t* d, size_t n) { + char buf[2 * sha256::kDigestLen + 1]; + remote_auth::toHex(d, n, buf); + return buf; +} + +std::string digestOf(const std::string& s) { + uint8_t out[sha256::kDigestLen]; + sha256::hash(s.data(), s.size(), out); + return hex(out, sizeof(out)); +} + +std::string hmacOf(const std::string& key, const std::string& msg) { + uint8_t out[sha256::kDigestLen]; + sha256::hmac(key.data(), key.size(), msg.data(), msg.size(), out); + return hex(out, sizeof(out)); +} + +} // namespace + +TEST_CASE("sha256 FIPS 180-4 vectors") { + CHECK(digestOf("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(digestOf("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + CHECK(digestOf("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") == + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); +} + +TEST_CASE("sha256 one million 'a' through incremental updates") { + sha256::Ctx ctx; + sha256::init(ctx); + const std::string chunk(1000, 'a'); + for (int i = 0; i < 1000; ++i) sha256::update(ctx, chunk.data(), chunk.size()); + uint8_t out[sha256::kDigestLen]; + sha256::finish(ctx, out); + CHECK(hex(out, sizeof(out)) == + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); +} + +TEST_CASE("sha256 padding boundaries (55/56/64 bytes)") { + // 55 bytes fits the length in the same block; 56 spills into a second. + CHECK(digestOf(std::string(55, 'a')) == + "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"); + CHECK(digestOf(std::string(56, 'a')) == + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"); + CHECK(digestOf(std::string(64, 'a')) == + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"); +} + +TEST_CASE("hmac-sha256 RFC 4231 test cases") { + // Case 1 + CHECK(hmacOf(std::string(20, '\x0b'), "Hi There") == + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"); + // Case 2 + CHECK(hmacOf("Jefe", "what do ya want for nothing?") == + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"); + // Case 3 + CHECK(hmacOf(std::string(20, '\xaa'), std::string(50, '\xdd')) == + "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe"); + // Case 6: key longer than one block is hashed first + CHECK(hmacOf(std::string(131, '\xaa'), + "Test Using Larger Than Block-Size Key - Hash Key First") == + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"); +}