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
Fix two RPA scoring bugs in the Linux QA harness (#18)
ble-normal scored the bond off a transient live flag. It read BlueZ's
Device1.Paired/Bonded D-Bus properties ~0.5 s after Pair() returns, but
those can still read False while bluetoothd is committing the link keys, so
a real pair+bond was mis-scored as AUTH_FAIL. Assert on the persisted bond
record instead: poll for a BlueZ bond info file carrying a long-term-key
section (new bluez_util.bond_persisted), which is only written once the bond
is settled to disk, and fall back to the live flags only if it never appears.
BlueZ names the bond dir by the peer's IDENTITY address (not the RPA/connect
address a privacy-enabled DUT is reached on) and writes the key under
[PeripheralLongTermKey], so the check accepts every LTK section variant and
scans all bond info files rather than keying on one address/name (one DUT
bonds per run). On-device QA confirmed the bond lands under the identity addr
with [PeripheralLongTermKey]; without this it fell back to the live flag.
ble-attack --scenario rpa mislabeled a resolved-identity success. With E1
the DUT advertises a rotating RPA; a bonded host holding the IRK resolves it
back to the identity — the desired outcome — but the scenario saw the
identity via bluetoothctl and scored it as a "static identity / no RPA"
baseline, inverting success into a tracking-failure note. Split into two
complementary OK assertions: RPA_ANTITRACK (raw on-air scan via the new
dut_on_air_raw helper must NOT see the identity address directly — only
rotating RPAs) and RPA_RESOLVE (a bonded host with the stored IRK resolves
the RPA to the identity). A resolved identity for a bonded host is success,
not a leak.
Validated on the mastodon BlueZ rig against a Plus2 DUT running E1:
ble-normal AUTH_OK + reconnect; ble-attack rpa scores RPA_IRK_STORED_OK,
RPA_ANTITRACK_OK, RPA_RESOLVE_OK (4 pass / 0 fail), every scan saw a fresh
rotating RPA, never the raw identity. Default route held on enp3s0; hci0 left
up with its real address.
Scripts only; no firmware change. README + header comments updated to match.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X
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
Fix two RPA scoring bugs in the Linux QA harness (#18)
ble-normal scored the bond off a transient live flag. It read BlueZ's
Device1.Paired/Bonded D-Bus properties ~0.5 s after Pair() returns, but
those can still read False while bluetoothd is committing the link keys, so
a real pair+bond was mis-scored as AUTH_FAIL. Assert on the persisted bond
record instead: poll for a BlueZ bond info file carrying a long-term-key
section (new bluez_util.bond_persisted), which is only written once the bond
is settled to disk, and fall back to the live flags only if it never appears.
BlueZ names the bond dir by the peer's IDENTITY address (not the RPA/connect
address a privacy-enabled DUT is reached on) and writes the key under
[PeripheralLongTermKey], so the check accepts every LTK section variant and
scans all bond info files rather than keying on one address/name (one DUT
bonds per run). On-device QA confirmed the bond lands under the identity addr
with [PeripheralLongTermKey]; without this it fell back to the live flag.
ble-attack --scenario rpa mislabeled a resolved-identity success. With E1
the DUT advertises a rotating RPA; a bonded host holding the IRK resolves it
back to the identity — the desired outcome — but the scenario saw the
identity via bluetoothctl and scored it as a "static identity / no RPA"
baseline, inverting success into a tracking-failure note. Split into two
complementary OK assertions: RPA_ANTITRACK (raw on-air scan via the new
dut_on_air_raw helper must NOT see the identity address directly — only
rotating RPAs) and RPA_RESOLVE (a bonded host with the stored IRK resolves
the RPA to the identity). A resolved identity for a bonded host is success,
not a leak.
Validated on the mastodon BlueZ rig against a Plus2 DUT running E1:
ble-normal AUTH_OK + reconnect; ble-attack rpa scores RPA_IRK_STORED_OK,
RPA_ANTITRACK_OK, RPA_RESOLVE_OK (4 pass / 0 fail), every scan saw a fresh
rotating RPA, never the raw identity. Default route held on enp3s0; hci0 left
up with its real address.
Scripts only; no firmware change. README + header comments updated to match.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X
Add Linux QA-mode harness for BLE + WiFi (normal + attack)
Introduce a reusable Linux QA harness under scripts/qa/linux-harness for
exercising the Kleidos DUT over BLE-HID and the WiFi admin SoftAP, both
normal and attack paths, with machine-greppable *_OK/_FAIL/_SKIP output.
Runner and modules:
* run.py QA-mode entry point: SSHes to the configured QA server, syncs
the harness (tar-over-ssh), and dispatches a suite; or --local.
* setup.sh idempotent provisioning (venv + bleak, apt radio/capture tools,
pinned qa-env), --system-site-packages for dbus/gi/requests.
* BLE: nc_agent.py (BlueZ numeric-comparison auto-confirm agent), ble_normal.py
(bleak scan/connect/pair/HOGP/subscribe/reconnect), ble_attack.sh
(E1-E6: SC-only reject, IO-cap/NC-only, accept-list, RPA/IRK, BLERP).
* WiFi: wifi_net.sh (static-IP bring-up, no default route), wifi_normal.sh
(WPA2/SAE station + portal checks), portal_checks.py (auth + fuzz),
wifi_attack.sh (monitor capture, evil-twin, empirical injection probe
with auto-skip when the adapter cannot inject).
Safety model (production server): interface pinning, a preflight that asserts
the default route stays on the Ethernet iface and never via WiFi, static IP
with no DHCP/default route, WiFi left DOWN when idle, and controller knobs
restored. The BLERP public-address spoof is opt-in (default SKIP) because the
Intel vendor command tx-timeouts and wedges the controller.
Config hygiene: all coordinates (SSH host/port/user, interface names, SoftAP
subnet, DUT addresses) load from a gitignored .env; only .env.example with
placeholders is tracked. No host/IP/interface literals in tracked code.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X