Finish the admin portal surface and give destruction one bar everywhere
The six endpoint groups the portal rendered nothing for are now firmware,
one module per file, each behind the auth policy its usage demands
(polled reads are SessionReadOnly so a dashboard cannot hold the radio up):
- admin_system_api: /api/system{,/partitions,/hardware}, selftest, beep,
reboot and sleep through the FSM flag seam. Hardware rows carry PROBED
chip identities published from Core 1 through atomics — a build flag
selects a code path, it does not name a part (the StickS3 PMIC lesson).
- admin_totp_api: live one-time codes gated on clock trust before any
vault I/O, answering the whole validity window (remaining_s) so the
page polls once per period instead of once per second.
- admin_credentials_api: the list gains user/favorite/order/hasTotp, and
every delete — single or batch — is prepare (unlock secret) → commit
(one-shot nonce), executed as ONE meta commit and ONE index rewrite by
VaultStore::deleteCredentialBatch. The bare DELETE route is gone.
- admin_ble_api: device name + per-bond rename (POST, not PATCH — the
HTTP facade deliberately stays GET/POST/DELETE), with a bounded
name-store flush so a rename survives a power cycle.
- SD restore wizard: stage → gated commit (unlock secret + imported PIN +
X-Confirm-Restore) → idempotent abort, with foreign work factors
clamped to [1000, 4x local] and session teardown on any failure past
the point of no return. The ungated one-shot import route is removed.
- admin_telemetry_api: on-demand battery sampler with a columnar chunked
body, ownership-guarded so a portal claim can never stop a
debug-console capture, drained on the UI core with a bounded loop.
Destruction now has one bar everywhere: credentials, TOTP entries (new),
SD keyfile/format, wipe, factory reset and the restore commit all demand
proof of the unlock secret through admin_confirm.h. BLE bonds stay the
deliberate exception — re-pairing is recoverable.
The device side gains the owner-facing half of the pinned admin network:
a "Fixed admin network" toggle (OptionId::AdminNetworkFixed, key
ap_fixed) that arms a pin request; the password is still drawn only in
generateApPassword(), before esp_wifi_start(), where the bootloader
entropy source is sound — the toggle records intent, the draw stays in
its one proven-safe place.
The SPA follows every contract (columnar telemetry, the restore 401-with-
restored:true success screen, per-id delete outcomes), the mock mirrors
them, and portal_checks gains seven second-wave HIL checks including
"the retired routes stay retired" and "secretless destruction is refused
before any KDF".
The batch pays its own DRAM bill: the fleet build caught core2_v13_debug
overflowing dram0 by 344 bytes (classic-ESP32 debug links with a few
hundred bytes of headroom). Three trims inside the new modules return
~405 bytes of permanent .bss: the telemetry channel registry is sized to
what the fleet registers (8 slots, not 24), the staged delete batch is
heap-allocated on first use, and the device-information observations
(power snapshot, chip probes, storage figures) live in one heap block
allocated on first touch — they are session observations, not boot
state. The 32 KB vault state and 12.5 KB app context stay deliberately
static: key material must not reach PSRAM, and their fixed reservation
is the linker's honest canary.
Native: 2705/2705. Builds: full 43-env fleet (the two native test envs
that pio run cannot build alone are the known Unity flake), then
core2_v13_debug, sticks3_debug, m5stickc_plus1_debug and core2_v13
re-verified after the DRAM trims. Guards and pre-commit hooks all green.
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
Make BLE bond deletion persist across reboot
A forgotten bond reloaded from NimBLE's own NVS store after a reboot
(only an `nvs` partition erase cleared it), so a deleted host stayed
pairable — a security-relevant gap. Two defects combined:
- Facade::removeBond cleared the NameStore identity record before the
async runtime delete could read the address *type*, so the unpair fell
back to a public-address delete. ble_gap_unpair never matched a
random-identity peer, leaving its key in NimBLE's keystore.
- removeRuntime only unpaired while the host was initialized, so a delete
requested while the radio was off (admin portal / menu) never reached
NimBLE's store at all, and the next boot's seed re-imported the bond.
Fix both: carry the identity address type in the RAM bond shadow so the
runtime unpair always uses the correct type (no public fallback), and arm
a durable pending-unpair tombstone in NameStore so a delete requested with
the radio off is applied at the next host start-up (drainPendingUnpairs)
before the shadow is seeded — and the seed skips still-pending identities
so a forgotten bond is never resurrected. The tombstone survives reconcile.
Adds native coverage for the correct-type unpair, the deferred-unpair
drain (apply on success, retain on failure), the no-resurrect seed guard,
and the NameStore tombstone lifecycle.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X
Finish the admin portal surface and give destruction one bar everywhere
The six endpoint groups the portal rendered nothing for are now firmware,
one module per file, each behind the auth policy its usage demands
(polled reads are SessionReadOnly so a dashboard cannot hold the radio up):
- admin_system_api: /api/system{,/partitions,/hardware}, selftest, beep,
reboot and sleep through the FSM flag seam. Hardware rows carry PROBED
chip identities published from Core 1 through atomics — a build flag
selects a code path, it does not name a part (the StickS3 PMIC lesson).
- admin_totp_api: live one-time codes gated on clock trust before any
vault I/O, answering the whole validity window (remaining_s) so the
page polls once per period instead of once per second.
- admin_credentials_api: the list gains user/favorite/order/hasTotp, and
every delete — single or batch — is prepare (unlock secret) → commit
(one-shot nonce), executed as ONE meta commit and ONE index rewrite by
VaultStore::deleteCredentialBatch. The bare DELETE route is gone.
- admin_ble_api: device name + per-bond rename (POST, not PATCH — the
HTTP facade deliberately stays GET/POST/DELETE), with a bounded
name-store flush so a rename survives a power cycle.
- SD restore wizard: stage → gated commit (unlock secret + imported PIN +
X-Confirm-Restore) → idempotent abort, with foreign work factors
clamped to [1000, 4x local] and session teardown on any failure past
the point of no return. The ungated one-shot import route is removed.
- admin_telemetry_api: on-demand battery sampler with a columnar chunked
body, ownership-guarded so a portal claim can never stop a
debug-console capture, drained on the UI core with a bounded loop.
Destruction now has one bar everywhere: credentials, TOTP entries (new),
SD keyfile/format, wipe, factory reset and the restore commit all demand
proof of the unlock secret through admin_confirm.h. BLE bonds stay the
deliberate exception — re-pairing is recoverable.
The device side gains the owner-facing half of the pinned admin network:
a "Fixed admin network" toggle (OptionId::AdminNetworkFixed, key
ap_fixed) that arms a pin request; the password is still drawn only in
generateApPassword(), before esp_wifi_start(), where the bootloader
entropy source is sound — the toggle records intent, the draw stays in
its one proven-safe place.
The SPA follows every contract (columnar telemetry, the restore 401-with-
restored:true success screen, per-id delete outcomes), the mock mirrors
them, and portal_checks gains seven second-wave HIL checks including
"the retired routes stay retired" and "secretless destruction is refused
before any KDF".
The batch pays its own DRAM bill: the fleet build caught core2_v13_debug
overflowing dram0 by 344 bytes (classic-ESP32 debug links with a few
hundred bytes of headroom). Three trims inside the new modules return
~405 bytes of permanent .bss: the telemetry channel registry is sized to
what the fleet registers (8 slots, not 24), the staged delete batch is
heap-allocated on first use, and the device-information observations
(power snapshot, chip probes, storage figures) live in one heap block
allocated on first touch — they are session observations, not boot
state. The 32 KB vault state and 12.5 KB app context stay deliberately
static: key material must not reach PSRAM, and their fixed reservation
is the linker's honest canary.
Native: 2705/2705. Builds: full 43-env fleet (the two native test envs
that pio run cannot build alone are the known Unity flake), then
core2_v13_debug, sticks3_debug, m5stickc_plus1_debug and core2_v13
re-verified after the DRAM trims. Guards and pre-commit hooks all green.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Finish the admin portal surface and give destruction one bar everywhere
The six endpoint groups the portal rendered nothing for are now firmware,
one module per file, each behind the auth policy its usage demands
(polled reads are SessionReadOnly so a dashboard cannot hold the radio up):
- admin_system_api: /api/system{,/partitions,/hardware}, selftest, beep,
reboot and sleep through the FSM flag seam. Hardware rows carry PROBED
chip identities published from Core 1 through atomics — a build flag
selects a code path, it does not name a part (the StickS3 PMIC lesson).
- admin_totp_api: live one-time codes gated on clock trust before any
vault I/O, answering the whole validity window (remaining_s) so the
page polls once per period instead of once per second.
- admin_credentials_api: the list gains user/favorite/order/hasTotp, and
every delete — single or batch — is prepare (unlock secret) → commit
(one-shot nonce), executed as ONE meta commit and ONE index rewrite by
VaultStore::deleteCredentialBatch. The bare DELETE route is gone.
- admin_ble_api: device name + per-bond rename (POST, not PATCH — the
HTTP facade deliberately stays GET/POST/DELETE), with a bounded
name-store flush so a rename survives a power cycle.
- SD restore wizard: stage → gated commit (unlock secret + imported PIN +
X-Confirm-Restore) → idempotent abort, with foreign work factors
clamped to [1000, 4x local] and session teardown on any failure past
the point of no return. The ungated one-shot import route is removed.
- admin_telemetry_api: on-demand battery sampler with a columnar chunked
body, ownership-guarded so a portal claim can never stop a
debug-console capture, drained on the UI core with a bounded loop.
Destruction now has one bar everywhere: credentials, TOTP entries (new),
SD keyfile/format, wipe, factory reset and the restore commit all demand
proof of the unlock secret through admin_confirm.h. BLE bonds stay the
deliberate exception — re-pairing is recoverable.
The device side gains the owner-facing half of the pinned admin network:
a "Fixed admin network" toggle (OptionId::AdminNetworkFixed, key
ap_fixed) that arms a pin request; the password is still drawn only in
generateApPassword(), before esp_wifi_start(), where the bootloader
entropy source is sound — the toggle records intent, the draw stays in
its one proven-safe place.
The SPA follows every contract (columnar telemetry, the restore 401-with-
restored:true success screen, per-id delete outcomes), the mock mirrors
them, and portal_checks gains seven second-wave HIL checks including
"the retired routes stay retired" and "secretless destruction is refused
before any KDF".
The batch pays its own DRAM bill: the fleet build caught core2_v13_debug
overflowing dram0 by 344 bytes (classic-ESP32 debug links with a few
hundred bytes of headroom). Three trims inside the new modules return
~405 bytes of permanent .bss: the telemetry channel registry is sized to
what the fleet registers (8 slots, not 24), the staged delete batch is
heap-allocated on first use, and the device-information observations
(power snapshot, chip probes, storage figures) live in one heap block
allocated on first touch — they are session observations, not boot
state. The 32 KB vault state and 12.5 KB app context stay deliberately
static: key material must not reach PSRAM, and their fixed reservation
is the linker's honest canary.
Native: 2705/2705. Builds: full 43-env fleet (the two native test envs
that pio run cannot build alone are the known Unity flake), then
core2_v13_debug, sticks3_debug, m5stickc_plus1_debug and core2_v13
re-verified after the DRAM trims. Guards and pre-commit hooks all green.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Fix branch-clone, rounding, cast, and remaining long-tail findings
- bugprone-branch-clone (12): fold genuinely identical switch arms
(typing status map, PM1 power levels, wake-cause names, glyph
fallback), merge the duplicated small-panel atlas ladder, rebuild
scaleFor as per-panel constexpr scale tables (lookup-table idiom),
restructure the modal confirm poll around explicit hold/press
predicates (also clears the DeMorgan finding), and NOLINT the one
per-SoC gated ADC calibration switch with rationale.
- bugprone-casting-through-void (13): use direct reinterpret_cast (the
documented pro-type-reinterpret-cast deviation) for byte/sockaddr
views; keep the SPI slot two-step through void* with rationale (it
exists to satisfy -Werror=cast-align next to the static_asserts).
- bugprone-incorrect-roundings (9): std::lroundf instead of +0.5 casts.
- cert-dcl50-cpp (5): rationale NOLINTs on the printf-style formatters
(a parameter pack cannot produce the va_list vsnprintf needs).
- cppcoreguidelines-pro-type-const-cast: const-correct the WordReader
ctx (API + trampoline + tests, const_cast deleted); NOLINT the two
std <cstring>-convention mutable overloads.
- Long tail: explicit void* casts + sizeof(T) rationale in rtos_queue,
range-for loop conversions, 0x80-mask instead of int8_t reinterpret
for the AXP2101 gauge, uint8_t slot subscript, parenthesized -1 pin
macros, extended the __wrap_log_printf ABI suppression to the
reserved-identifier aliases, integer-center tween targets.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Style: group-open/close consistency (matching multi-line vs triple-slash)
Round 2 of the Doxygen group-marker pass (post b0dd0b0).
The previous round converted 60 /// @} -> /** @} */ but had a state-
machine bug: it converted closings whose openings were still triple-slash
(/// @name X + /// @{), leaving them mismatched. It also missed the
inverse case: /// @} closings whose openings were multi-line
(/** @name X + * @{\n */).
This pass fixes both directions using a stack-based state machine that
walks the source top-to-bottom:
- Push a frame on each @{ opening, tagged with the opening comment
style (triple-slash or multi-line) and a 'force triple-slash' flag
(true for @addtogroup / @defgroup openings).
- On each @} close, pop the top frame and rewrite the close to match
the expected style. @addtogroup / @defgroup closings are always
triple-slash, per the project rule.
Changes:
- 60 /// @} reverted to /** @} */ (round-1 over-conversions)
- 43 /// @} converted to /** @} */ (missed multi-line openings)
---
103 conversions across 29 files; idempotent on second run.
Scope: src/**/*.{h,cpp,hpp} minus brands/generated/, strings_gen*,
index_html_gz.h, and tools/. No function body, signature, include, or
Doxygen block content was touched. doxygen Doxyfile still exits 0.