Skip to content

plan 0019: PIN-gated remote transfer from the main menu - #168

Open
TheAngryRaven wants to merge 7 commits into
BETAfrom
claude/project-thread-5cf43q
Open

TheAngryRaven wants to merge 7 commits into
BETAfrom
claude/project-thread-5cf43q

Conversation

@TheAngryRaven

Copy link
Copy Markdown
Owner

Summary

Before: to start a Bluetooth transfer, someone had to stand at the logger and pick Transfer → Bluetooth. The bluetooth_pin existed, but nothing checked it, and SLIST/SGET sent it to anyone who was connected.

After: while the logger idles on its main menu, it advertises the transfer service in a locked mode. The app answers a one-time 16-byte challenge with HMAC-SHA256(PIN, "BEAUTH1|" + nonce), and a correct answer turns the link into the normal transfer page, labelled Remote Transfer. A local start stays open, as it was before. A local start is now also the only way to read the PIN over BLE, using PINGET.

How it works:

  • Handshake
    • AUTH? gets AUTH:NONCE:<hex32> back, with the nonce taken from the hardware RNG.
    • AUTH:<hex32> gets one of AUTH:OK, AUTH:FAIL:<n>, AUTH:LOCKED:<s>, AUTH:NO_NONCE or AUTH:BUSY.
    • Before AUTH:OK, any other command gets AUTH:REQUIRED, except BATT.
  • Lockout and timeout
    • 5 wrong answers lock remote transfer for 60 s. Each further lockout doubles that, up to 15 min.
    • A peer that hasn't authenticated after 20 s gets AUTH:TIMEOUT and is dropped.
  • The camera always wins
    • Standby stops when any of these happens: the camera state machine leaves IDLE/UNPAIRED, the camera bench page opens, the camera owns the radio, or a paired camera's engine goes above 500 rpm.
    • The rpm check fires before the camera's own 2 s wake debounce, and the standby step runs before CAMERA_LOOP(), so the camera never finds the radio taken.
    • A remote session ends the same way: it sends AUTH:CAMERA and then reboots.
    • A remote start never calls CAMERA_FORCE_RELEASE().
  • PIN protection
    • SLIST leaves out bluetooth_pin.
    • SGET:bluetooth_pin returns SERR:PROTECTED.
    • SSET:bluetooth_pin accepts exactly 4 digits.
  • PIN page: a new Transfer → PIN page. Holding Select for 3 s reveals the PIN for 15 s, or replaces it. The Transfer menu now scrolls to fit a fourth row.
  • New setting: remote_transfer, default on.

The rules are in host-tested pure units:

  • sha256 (FIPS 180-4 / RFC 4231 vectors)
  • remote_auth, with a golden answer vector shared with the viewer and LapWing
  • pin_page

Design record: docs/plans/0019-remote-transfer-pin.md. It includes one change from the reviewed design: the answer is not bound to bluetooth_name. That name can be truncated on air, which would make correct implementations disagree, and the nonce already makes each answer unique.

Security limit: the link is still unencrypted Just Works. The PIN keeps other people in the pits out, but someone who records a handshake can brute-force it offline. The plan states this.

Type of change

  • Bug fix (no user-visible behavior change beyond the fix)
  • New feature / behavior
  • Refactor (no behavior change)
  • Tests only
  • CI / tooling / docs
  • Breaking change (track files, log format, BLE protocol, or a removed mode)

This is additive to the BLE protocol. Old apps keep working with a local start, but they no longer see the PIN in SLIST.

How it was verified

  • Host unit tests pass (ctest --test-dir tests/build)
  • clang-tidy clean, run on the three new units
  • Compiles for the XIAO nRF52840 Sense. arduino-cli couldn't reach its index from my environment, so CI's compile-sketch job is the first compile of the .ino changes.
  • Tested on real hardware. Still needs checking: a phone authenticating from the menu, the lockout, and the camera taking the radio back with the engine running.
  • Sim: all 6 ctest targets pass. Goldens were regenerated because the transfer menu hash moved with its fourth row. There are three new PIN-page fixtures, and the frames were inspected.

Checklist

  • CHANGELOG.md updated under [Unreleased] (if user-visible)
  • ARCHITECTURE.md / CLAUDE.md updated (if a module or interface changed)
  • New testable logic has a matching test in tests/
  • Branch is focused — refactors / behavior / tests are not mixed together

Related issues

Viewer and LapWing handshake PRs follow.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk


Generated by Claude Code

claude added 3 commits October 7, 2026 03:42
Design record for starting a Bluetooth transfer from the app while the
logger idles on its main menu, gated by a challenge-response on the
existing bluetooth_pin, with the camera always taking priority and an
on-device PIN view for soldered-SD units.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk
The PIN handshake has to agree byte-for-byte with WebCrypto in the viewer
and the hmac/sha2 crates in LapWing, so the primitive is pinned to FIPS
180-4 and RFC 4231 vectors, and the answer to a golden vector shared with
both apps. The nonce, lockout, command gating, protected-key and camera
priority rules live in remote_auth so the glue only owns the radio.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk
The logger now advertises the transfer service while it idles on the main
menu, locked until the app answers a one-time challenge with an HMAC of
the PIN. The PIN no longer leaks through SLIST/SGET, a local start hands
it to the app with PINGET, and new PINs come from the hardware RNG. The
camera always wins the radio: standby stops (and a remote session ends)
before a paired camera's wake debounce. Transfer gains a PIN page that
reveals or replaces the PIN behind a 3 s hold, since soldered SD cards
took away the old recovery path.

The answer is not bound to bluetooth_name as the design artifact had it:
the nonce already makes it unique, and a name truncated on air would make
correct implementations disagree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 2640/2675 (98.7%)
Functions 🟢 286/287 (99.7%)
Branches 🟢 1919/2115 (90.7%)

📄 File coverage

File Lines Functions Branches
BirdsEye/ble_stream.cpp 🟢 34/34 (100.0%) 🟢 8/8 (100.0%) 🟡 17/20 (85.0%)
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 213/221 (96.4%) 🟢 21/21 (100.0%) 🟡 119/136 (87.5%)
BirdsEye/course_prune.cpp 🟢 37/37 (100.0%) 🟢 5/5 (100.0%) 🟢 47/50 (94.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/drag_timer.cpp 🟢 177/182 (97.3%) 🟢 11/11 (100.0%) 🟡 92/112 (82.1%)
BirdsEye/drag_tree.cpp 🟢 116/118 (98.3%) 🟡 8/9 (88.9%) 🟢 94/100 (94.0%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 48/48 (100.0%) 🟢 7/7 (100.0%) 🟢 32/34 (94.1%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/idle_policy.cpp 🟢 34/34 (100.0%) 🟢 4/4 (100.0%) 🟢 22/22 (100.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/led_animations.cpp 🟢 84/84 (100.0%) 🟢 7/7 (100.0%) 🟢 43/46 (93.5%)
BirdsEye/led_frame.cpp 🟢 21/21 (100.0%) 🟢 7/7 (100.0%) 🟢 6/6 (100.0%)
BirdsEye/led_modes.cpp 🟢 72/73 (98.6%) 🟢 7/7 (100.0%) 🟢 60/62 (96.8%)
BirdsEye/led_status.cpp 🟢 106/108 (98.1%) 🟢 11/11 (100.0%) 🟢 71/75 (94.7%)
BirdsEye/local_time.cpp 🟢 48/48 (100.0%) 🟢 6/6 (100.0%) 🟢 46/50 (92.0%)
BirdsEye/loop_profile.cpp 🟢 65/65 (100.0%) 🟢 7/7 (100.0%) 🟢 35/36 (97.2%)
BirdsEye/pin_page.cpp 🟢 34/34 (100.0%) 🟢 6/6 (100.0%) 🟢 29/30 (96.7%)
BirdsEye/remote_auth.cpp 🟢 126/126 (100.0%) 🟢 20/20 (100.0%) 🟢 129/141 (91.5%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sd_probe.cpp 🟢 6/6 (100.0%) 🟢 1/1 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/sector_purple.cpp 🟢 84/85 (98.8%) 🟢 3/3 (100.0%) 🟡 57/64 (89.1%)
BirdsEye/sensoregg_gatt.cpp 🟢 154/154 (100.0%) 🟢 24/24 (100.0%) 🟢 99/110 (90.0%)
BirdsEye/sensoregg_protocol.cpp 🟢 90/91 (98.9%) 🟢 14/14 (100.0%) 🟢 76/78 (97.4%)
BirdsEye/setting_parse.cpp 🟢 29/30 (96.7%) 🟢 2/2 (100.0%) 🟢 38/42 (90.5%)
BirdsEye/sha256.cpp 🟢 93/93 (100.0%) 🟢 7/7 (100.0%) 🟢 26/26 (100.0%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 91/91 (100.0%) 🟢 13/13 (100.0%) 🟡 72/82 (87.8%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 23/24 (95.8%) 🟢 3/3 (100.0%) 🟢 27/28 (96.4%)

claude added 4 commits October 8, 2026 02:21
The remote-transfer change rewrote settings.ino with LF endings, which
turned a ~60-line change into a ~1160-line diff. BETA carries this file
as CRLF; put it back so the PR shows only the real edits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk
Review fixes for PR #168 (logger side):

- A standby peer's disconnect could be routed to the camera when the
  camera claimed the radio in the same loop iteration, leaving
  bleConnected and a stale handle behind. Our own link is now matched
  by handle before owner, bleStandbyStop() waits (bounded, WDT-fed)
  for the disconnect, and standby starts from a clean link state.
- bleStandbyStop() releases the owner before switching to open mode,
  so a write that preempts the teardown is ignored; a link being
  dropped is marked and gets no further commands.
- A remote session ends with AUTH:ENGINE when the engine passes
  500 rpm (camera paired or not) and with AUTH:IDLE after 10 minutes
  without a request, so it can no longer park the logger with no
  logging, auto-race or idle shutdown. Decision in remote_auth.
- An unreadable or invalid stored PIN answers AUTH:BUSY without
  spending the nonce or counting a failure; PINGET refuses to hand out
  a PIN that could never authenticate.
- BLE_SETUP() drops any surviving link and clears every pending flag
  before opening a local (open) session, and the transfer page says
  "PIN sent to app!" once PINGET has been served.
- The paired-camera engine claim is latched until rpm < 300, so a
  pull-start no longer thrashes the standby advert.
- After an AUTH:TIMEOUT squat drop the advert stays down 5 s.
- Tests: the above, plus command gating with leading whitespace and
  embedded NULs, and the PIN page's entry press on the Back row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk
Review fixes for PR #168 (logger side):

- ensureDefaultSettings() regenerates a stored PIN that the handshake
  could never accept (wrong length, non-digits, or a JSON number that
  getSetting() can't read), instead of leaving remote transfer locked
  out for good.
- Transfer -> PIN reads the PIN wider than four digits and shows
  "PIN read failed" / "PIN invalid: New PIN" rather than a blank page
  or a value cut down to four digits. The page also lands on Show
  rather than inheriting the Transfer menu's row index.
- The PIN no longer reaches the debug serial: createDefaultSettings()
  drops it from its log line and setSettingInner() hides the value of
  protected keys.
- SRESET keeps a valid bluetooth_pin. A reset sent from a remote
  session used to roll a PIN the app could never learn; replacing the
  PIN stays on the device's PIN page.
- settings.h: remote_transfer costs 23 bytes, not 26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk
- Plan 0019: AUTH:ENGINE / AUTH:IDLE drop notices, AUTH:BUSY for an
  unreadable PIN, the squat back-off, synchronous standby handover,
  SRESET keeping the PIN, and the accepted local-start PINGET gap in
  the threat model.
- CLAUDE.md / ARCHITECTURE.md: BLE is no longer lazy on stock builds
  (the menu standby starts it), plus the new rules and constants.
- CHANGELOG: the PIN does cross the air on one path, PINGET on a local
  start; the remote-session end, PIN-sent notice, PIN regeneration and
  SRESET behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTPVvtL3ZVDj7UBcAthaQk

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants