Add BLE layer: pairing, security, stress via mastodon host (Phase 3)
Wire the BLE HIL layer by orchestrating the existing on-server Linux harness
from pytest, validated Stick S3 (DUT) <-> mastodon (BlueZ central).
- host/linux_ble.py: LinuxBleHarness wraps scripts/qa/linux-harness/run.py
(which already does SSH + tar-sync + .env forwarding + the interface-pinning
safety model) and parses its machine-greppable *_OK/*_FAIL/*_SKIP + SUITE_SUMMARY
contract into a HarnessResult; output is run through the redactor.
- host/ssh_host.py: SshHost (ssh BatchMode) for the host preflight; coords
resolve from the QA config, falling back to the populated Linux-harness .env so
a lab already running the harness needs no extra config.
- host/plus1.py: documented embedded fallback stub (mastodon preferred).
- debug_console.py: BLE staging (ble_autoaccept, bletype [not BLEPAIR — see the
firmware doc discrepancy], blesend, blestop, ble_forget_all, ble_own_addr).
- conftest: host fixture (SSH + hciconfig UP preflight, skips cleanly if the host
is down); staged_dut fixture (unlock, ensure a credential, begin advertising,
yield the BLE address, BLESTOP on teardown).
- tests/ble/: test_ble_pairing (happy path), test_ble_security (E3 sc-reject),
test_ble_stress (reconnect cycles), test_harness_parse (no-hardware parser).
Gate: 3 BLE HIL tests green (sticks3 + mastodon) — happy-path pairing (LE-SC
numeric comparison, HOGP keystroke decode), E3 Secure-Connections-only reject,
and a 2-cycle reconnect stress; the debug PIN never appears in harness output;
no-hardware suite green (26 passed); ruff + black clean.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X
Feed the caller's watchdog while the vault worker runs a flash-length job
A vault wipe erases the whole 1.9 MB partition and, since the capacity
grew to 150 slots, takes 6-8 s on a populated vault. Every synchronous
caller of the vault worker hard-blocked on the completion semaphore for
that whole time — and the Core-1 loop task is TWDT-subscribed, so a wipe
from PIN-entry brute-force, the portal commit, or the debug console
panicked the device MID-WIPE at the 5 s watchdog, leaving LittleFS
half-formatted. Verified on silicon: a 13-credential wipe now completes
in 7.7 s where it previously rebooted at 5.
The fix lives at the one seam every caller shares: dispatchRequest waits
in one-second slices and feeds the calling task's watchdog between them.
resetCurrentTask() is a silent no-op for unsubscribed callers, so paths
that were never at risk pay nothing.
The BLE QA that exposed this gets three rig-truth fixes of its own:
- The vault-wipe bond test erases the DUT side of a bond by design, which
orphans the LTK BlueZ keeps; it now forgets the host side symmetrically
(setup and finally) via a new LinuxBleHarness.forget_dut() that also
sweeps RPA-keyed stray records, so the test cannot poison its own
reruns or the tests after it.
- ble_normal.py canonicalizes to the identity address after a fresh
pairing: BlueZ re-keys the device record once the IRK arrives, and the
RPA the scan found stops resolving — the uhid lookup and the reconnect
both used the dead address.
- The test now asserts on nimble_bonds (the keystore factoryResetBonds
actually erases) instead of the lazily-flushed bond shadow, and stages
itself on AUTH rather than the HOGP typing step, which races BlueZ
5.66's HoG input plugin and is covered by test_happy_path anyway.
The pairing prerequisite of that test remains flaky on the mastodon rig
(uhid/HoG race); the wipe-erases-bonds property itself is verified over
serial: BONDS? reports count=0 nimble_bonds=0 after a populated wipe.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Harden BLE bond lifecycle and land full on-device QA coverage
Defensive BLE audit of src/ble/ (audit dossier: docs/security/audit-ble.md).
The stack was sound; this closes the one actionable finding plus the QA gaps.
BLE-L1 (the finding): BLE bonds survived a vault wipe / factory reset — the
NimBLE keystore (nimble_bond NVS), the kleidos_blnames name blob, and its RTC
mirror all live in NVS, which wipeAll() never touched, so a previous owner's
bonded hosts and the device IRK persisted across a factory reset. Add
BondStore::factoryReset() (erases all three backing stores, radio OFF) exposed
via Facade::factoryResetBonds() and wired into every wipe path (pin_state
brute-force wipe, settings wipe, boot recovery, debug WIPEVAULT/lockout). Clearing
the RTC mirror is essential — reconcile() would otherwise resurrect the bonds
after the post-wipe reboot. Validated on-device. Native regression in
test_bond_store / test_name_store.
QA coverage: implement and validate the eight proposed BLE QA tests —
nc-reject (QA-1), conn-param (QA-2), disconnect (QA-4), factory-reset regression
(QA-5), adv-minimization (QA-7) on the mastodon Linux/BlueZ central; redaction
(QA-6) + UTF-8 robustness (QA-8) in CI; and N4 eviction (QA-3) end-to-end on the
DUT via the ESP32 Plus1 central. New mastodon scenarios (adv-scan,
disconnect-mid-type, conn-param, evict, restore) in ble_attack.sh + ble_probe.py.
Plus1 harness: unify the separate _blehost/_wifihost binaries into one
command-switchable BLE+WiFi binary (m5stickc_plus1_host, MODE BLE|WIFI|NONE).
Add an OWNADDR command that rotates the central's LE random-static identity
(fills the DUT bond table for N4 eviction, no BlueZ). Fix two firmware bugs: the
harness was mute without KLEIDOS_USB_SERIAL_CONSOLE (serial facade compiled to
no-op stubs), and identity rotation broke discovery until OWNADDR/FORGET also
clear the resolving list (ble_hs_resolv_list_clear_all).
CVE maintenance: register CVE-2025-53470/53477 (NimBLE HCI, low-reachability)
and record the advertising-report/Mesh/BlueDroid/BluFi CVEs as not-applicable;
2026 re-check found no new NimBLE CVEs. Plus readability + file:line doc-drift
fixes across ble.md / cve-register.md.
Gates: native 233/233, sticks3 + m5stickc_plus2 + m5stickc_plus1_host build
clean, pio check no defects, clang-format/check_layout/check_variants/typos/
gitleaks/ruff/editorconfig all pass.
Claude-Session: https://claude.ai/code/session_01MyjYtwfLCwUUfGVYuTQqSv