Extend the record to its final shape, revive the dead panels, honor the index
Three firmware waves of the gap-report program, built in parallel
worktrees against the same tip and integrated with both-sides conflict
resolution (two stitch errors — an unopened Doxygen comment and a lost
closing brace — were caught by the compiler, as intended).
Wave 1 — the record's final shape. CredentialRecord gains flags (bit0 =
auto-submit: username, TAB, password, ENTER as one BLE action — and the
trailing ENTER is now conditional where it used to be unconditional),
three typed custom fields (label/value/hidden — hidden values ride
behind the same plaintext-consent header as passwords), a group id, and
TOTP entries gain an account label plus the algorithm byte everywhere.
Groups are a 100-slot encrypted name table (one littlefs block) managed
over GET/POST/DELETE /api/groups — deleting a group only clears labels,
never credentials. The index row carries group, a derived domain column
and real mtime, with names widened to 32 chars; brand is populated at
save time from the url, ending the per-row decrypt the device paid for
logos. URL capacity rises to 192 (the owner's real data maxed at 190),
and every save/import reports per-field truncation instead of silently
clamping. Backup, restore, the normalized importer and the CSV importer
round-trip all of it; the importer maps 1Password vaults and CSV
folder/tags/vault columns into groups server-side.
Wave 2 — six dead routes live: /api/ota/status (the whole Firmware
panel keyed on it), /api/vault/reindex, /api/time/diag (RTC coin-cell
health), the GPS trio (gated to boards that have one), NTP-sync-on-exit
through the AdminMode teardown choke point, and the staged-SD firmware
apply behind the confirm gate. Both full-replace restores now demand
the unlock secret (X-Unlock-Secret) — a backup passphrase proves
nothing about owning the live vault — and the restore body cap is
computed from the device's own backup bound instead of a 64 KB literal.
Wave 3 — the UI stops lying: the audit export honors Accept: text/csv
with real CSV, the web CSV importer honors the type column (Bitwarden
non-logins skipped and counted), LastPass and Proton Pass get real
header signatures, /api/runtime-stats is gone, AutoLockMode appears on
the six boards that hid a working handler, and the device vault lists
honor favorite-first plus manual order with an allocation-free
comparator.
Integration extras: the index envelope buffers and the rekey crypto
scratch now live on the heap (the rekey frame was already past the
32 KB worker stack at the current 200-slot caps — the latent overflow
the dimensioning study flagged); core2_v13's capacity-scaled view-cache
block moves to PSRAM .ext_ram.bss (keys and passwords stay in internal
DRAM), un-breaking its 48-byte link margin; the groups list
distinguishes a corrupt table from an empty one; custom:null is refused
rather than destructive; and the three amalgam suites learned the new
seams, including rebasing an unreachable handler-413 assertion onto the
gate-logic suite that actually pins the bound.
Builds: sticks3_debug, tdeck, cardputer, m5stickc_plus1_debug,
core2_v13_debug (now with SPIRAM bss). Native: full suite green with
nine new suites registered.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Widen the slot id to 16 bits so capacity can pass 255
Credential capacity is pinned per variant at up to 700 by the vault
dimensioning study, but the slot id was a u8 across the app, web and
persistence layers: every id above 255 would have aliased onto a live
slot. Widen the id and the counts derived from it to uint16_t through
vault, web, states and ui; TOTP and group ids deliberately stay u8
(their caps stay under 255) and are now pinned by co-located
static_asserts instead of by assumption.
The wire follows: index rows and the row count, and the slot id in the
envelope's authenticated context, become u16 little-endian, growing the
context prefix from 7 to 8 bytes. No version byte moves — the format is
pre-release and every board is reflashed — but the security docs that
quote the prefix byte-for-byte are updated so the audit dossier stays
verifiable against the source.
Adversarial review of the diff found four defects worth naming, all
fixed here:
- promoteAll() held two capacity-sized filename vectors live on the
32 KB vault-worker stack; at the 700-slot pin that is ~78 KB and a
guaranteed overflow on the first SD import or re-key. Both move to
the heap, matching the pattern the rest of the vault already uses.
- The widened staged-filename grammar accepted cred_007.bin as well as
cred_07.bin for the same slot, so an import deleted the canonical
record and wrote a path the repository never opens — silent slot
loss where the previous grammar had failed closed. The grammar is now
canonical-only and lives in one header both readers share.
- DevicePanel kept the last-viewed slot in editCredId_ after BACK, so
the standalone password generator saved into it, blanking that
credential's name, user and url. The id now resets to the invalid-slot
sentinel, and the guard that also made slot 0 unwritable is gone.
- The envelope test asserted the id's new high byte against its own
zero-initialized fixture, so it would have passed against a writer
that never wrote that byte at all.
Verified: native 2843/2843 (including a new wide-capacity suite that
exercises slot 260 end to end), sticks3_debug, cardputer_debug, tdeck
and core2_v13_debug, plus the merge gate — a clean -Werror build at
VAULT_MAX_CREDENTIALS=300, which is what proves no narrowing survives.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Extend the record to its final shape, revive the dead panels, honor the index
Three firmware waves of the gap-report program, built in parallel
worktrees against the same tip and integrated with both-sides conflict
resolution (two stitch errors — an unopened Doxygen comment and a lost
closing brace — were caught by the compiler, as intended).
Wave 1 — the record's final shape. CredentialRecord gains flags (bit0 =
auto-submit: username, TAB, password, ENTER as one BLE action — and the
trailing ENTER is now conditional where it used to be unconditional),
three typed custom fields (label/value/hidden — hidden values ride
behind the same plaintext-consent header as passwords), a group id, and
TOTP entries gain an account label plus the algorithm byte everywhere.
Groups are a 100-slot encrypted name table (one littlefs block) managed
over GET/POST/DELETE /api/groups — deleting a group only clears labels,
never credentials. The index row carries group, a derived domain column
and real mtime, with names widened to 32 chars; brand is populated at
save time from the url, ending the per-row decrypt the device paid for
logos. URL capacity rises to 192 (the owner's real data maxed at 190),
and every save/import reports per-field truncation instead of silently
clamping. Backup, restore, the normalized importer and the CSV importer
round-trip all of it; the importer maps 1Password vaults and CSV
folder/tags/vault columns into groups server-side.
Wave 2 — six dead routes live: /api/ota/status (the whole Firmware
panel keyed on it), /api/vault/reindex, /api/time/diag (RTC coin-cell
health), the GPS trio (gated to boards that have one), NTP-sync-on-exit
through the AdminMode teardown choke point, and the staged-SD firmware
apply behind the confirm gate. Both full-replace restores now demand
the unlock secret (X-Unlock-Secret) — a backup passphrase proves
nothing about owning the live vault — and the restore body cap is
computed from the device's own backup bound instead of a 64 KB literal.
Wave 3 — the UI stops lying: the audit export honors Accept: text/csv
with real CSV, the web CSV importer honors the type column (Bitwarden
non-logins skipped and counted), LastPass and Proton Pass get real
header signatures, /api/runtime-stats is gone, AutoLockMode appears on
the six boards that hid a working handler, and the device vault lists
honor favorite-first plus manual order with an allocation-free
comparator.
Integration extras: the index envelope buffers and the rekey crypto
scratch now live on the heap (the rekey frame was already past the
32 KB worker stack at the current 200-slot caps — the latent overflow
the dimensioning study flagged); core2_v13's capacity-scaled view-cache
block moves to PSRAM .ext_ram.bss (keys and passwords stay in internal
DRAM), un-breaking its 48-byte link margin; the groups list
distinguishes a corrupt table from an empty one; custom:null is refused
rather than destructive; and the three amalgam suites learned the new
seams, including rebasing an unreachable handler-413 assertion onto the
gate-logic suite that actually pins the bound.
Builds: sticks3_debug, tdeck, cardputer, m5stickc_plus1_debug,
core2_v13_debug (now with SPIRAM bss). Native: full suite green with
nine new suites registered.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Make the credential the full object the owner defined, everywhere
The ruling: a credential is user + password + web + notes + TOTP, the web
admin is the superset surface, and no surface may destroy what another
one wrote. This lands the investigation's NOW tier end to end.
The seam that closes a bug class: VaultStore::saveCredential now
read-modify-writes the stored extras, so a bare save from ANY surface —
the portal form, the device editors, a future caller — preserves notes,
brand and the embedded TOTP instead of silently rebuilding the record
from defaults. The portal edit no longer wipes notes (the GET now emits
them — behind the same X-Confirm-Plaintext consent as the password,
since notes hold recovery codes), and the device editors were moved off
the destructive path.
The TOTP write path exists at last: POST /api/credentials accepts an
optional totp object (base32 or a full otpauth:// URI; explicit null
detaches), the SPA credential form gains its 2FA section, and standalone
and embedded TOTP convert both ways. TOTP records gain an algorithm byte
(SHA-1 default, SHA-256 for the issuers that mandate it) verified
against the RFC 6238 Appendix B vectors, and every generate() call site
passes it.
Backups stop lying: exportAll/importAll round-trip the complete record —
notes, embedded TOTP, favorite, order, brand, timestamps — so the
portable paths (/api/backup, .kexp, SD) finally match what the page
promises. Credential saves stamp ctime/mtime from the trusted epoch when
the clock deserves it.
The 1Password import is real: the SPA import wizard parses .1pux in the
browser (a self-contained ZIP walker over DecompressionStream — the
12 MB export never touches the device; only export.data is read) and
vendor CSVs (1P8's Url header now matches; OTPAuth/Notes/Favorite/
Archived columns mapped), previews with per-vault selection, capacity
metering, dedup marks and an archived-skip toggle, then uploads through
the new POST /api/import/items — a merge importer that batches 32 items,
generates 20-character passwords on-device for passwordless rows when
asked, and reports per-item outcomes. Two flows that were dead on real
hardware come back with one-line fixes each: rekey read "new" where the
page sends "next", encrypted restore read X-Export-Passphrase where the
page sends X-Backup-Pass.
The drift class those two bugs belong to now has a test: qa/tests/portal/
test_spa_contract.py walks every SPA fetch against the firmware route
table, the mock and a key-name fixture (17 checks). The fixtures' two
documented drift entries were resolved the way they demanded.
The compressed-SPA budget rises to 64 KB by owner decision — the portal
is the device's full-featured surface now, and even the tightest 4 MB
board carries it with room (all five representative builds pass,
including both classic-ESP32 debug canaries).
Native: 2758/2758. Contract: 17/17. Builds: sticks3_debug, tdeck,
cardputer, m5stickc_plus1_debug, core2_v13_debug. Guards and hooks green.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Add the WiFi-station store and NTP clock-sync backend
From Settings (vault unlocked) the device can join a saved WiFi network
as a station, sync the clock via one-shot SNTP against trusted public
NTP servers, and always disconnect. Servers are LITERAL IPv4 only —
never a hostname, never DNS (esp_sntp is fed parsed addresses; a strict
dotted-quad validator gates every entry). Defaults are Cloudflare
(162.159.200.1, 162.159.200.123), Google (216.239.35.0) and NIST
(132.163.97.1), tried in order; the user can persist an override list
of up to four IPs so dead defaults never require a firmware update.
Saved networks (SSID+PSK, max 8) and the NTP server list live in a new
wifi.bin vault module using the standard envelope (AES-256-CBC +
HMAC-SHA256, encrypt-then-MAC, verify-before-decrypt, type-bound), only
readable with the vault unlocked, staged through the crash-safe rekey,
and crypto-erased by wipeAll. PSKs are zeroized after use and never
logged or echoed.
The sync engine runs on a Core-0 worker: BLE off, join the strongest
saved network, query servers in order, sanity-clamp the epoch and never
step backward past an armed lockout, commit via the rtc facade with a
new display-only src=ntp tag — clock TRUST is never elevated (NTP is
unauthenticated; the forward-jump lockout fail-closed model is the
spoofing defense) — then a RAII station guard tears WiFi down and
restores BLE on every exit path. The station facade stays a generic
transport for future consumers (on-device OTA).
Debug console gains WIFISCAN / WIFIADD / WIFILIST / WIFIDEL, NTPSYNC
(GPSSYNC-style blocking flow with machine-checkable teardown acks) and
NTPSERVERS? / NTPSERVERSET / NTPSERVERCLEAR — all DEBUG_SERIAL_BUTTONS
gated. HIL-verified end to end on the StickS3: real sync against the
default servers, user-list precedence across reboot, TEST-NET failure
paths, trust unchanged, BLE restored, store left clean.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4