Silence the shipping targets, and keep a crash record that carries no secrets
A _release build was not silent. CONFIG_LOG_MAXIMUM_LEVEL=0 compiles out the
ESP_LOG family, but the panic handler never goes through the log subsystem at
all: panic_print_char writes straight into the UART or USB-Serial/JTAG TX FIFO,
and esp_backtrace_print reaches esp_rom_printf with no log guard, so a Task-WDT
timeout printed a full backtrace on a shipped device.
A new sdkconfig.silent.defaults sets the five options that actually close every
path and is chained last on every _release and _secure env. That required
declaring a fragment chain on the _release envs, which had none: despite its
name, sdkconfig.release.defaults.opt-in is reached only by _secure and
_secure_dev, verified by resolving the extends chains rather than by reading the
file names. Invariant 18 re-derives the chain per environment and fails if any
shipping target does not resolve to all five symbols.
Silence would leave a field crash with nothing behind it, so the firmware now
keeps its own record. A --wrap hook on esp_panic_handler — ESP-IDF's own
mechanism, used the same way inside components/bt — captures the reset reason and
up to twelve PC/SP pairs of the faulting task into an RTC no-init ring, then
calls the real handler unchanged. Addresses only: no stack contents, no
registers carrying data, no heap. That distinction is the whole point, because a
task stack on this firmware holds the PIN across the PBKDF2 window and the HMAC
pads that are key-equivalent, and .bss holds the master key.
RTC memory survives a panic reboot but not a power cycle, and NVS cannot be
written from a panic context, so the ring is captured to RTC and mirrored to NVS
on the next normal boot, writing flash only when the record set actually changed.
It is surfaced at GET /api/system/crash behind the portal's existing session
auth, with counters on the polled /api/system, and CRASH? / CRASHCLEAR on debug
builds.
One clause of the ruling is not met and cannot be: the mask-ROM banner prints
before any firmware exists. ESP-IDF says so itself in the ESP_CONSOLE_NONE help.
The only lever is an eFuse, it does not exist at all on the four classic boards,
and burning it on the S3 would contradict the rule that only _secure touches
eFuses. Documented rather than worked around.
Verified: sticks3_release, m5stickc_plus2_release and sticks3_secure all build
clean, the generated sdkconfigs carry all five symbols on the S3 and the four
that exist on classic, native tests pass 2861/2861, and both guards are green.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Move the vault's list surface to PSRAM, and only the list surface
core2_v13 could not hold its dimensioned capacity: the four view caches
that scale with the credential count consume ~169 of the 173 bytes per
slot the board spends in internal DRAM, leaving an honest ceiling of 176
against a 500-slot pin. Give them their own file-scope homes in external
RAM and hold them by reference, and route the capacity-scaled heap
transients through memory::allocLarge so they stop competing with WiFi
and httpd for internal heap on the boards that have PSRAM at all.
Measured on core2_v13_debug at 500 credentials: 72,988 B of internal
DRAM used, 51,592 B free of 124,580 — twelve times the 4 KB slack bar the
dimensioning study set, so DRAM is no longer that board's constraint.
An earlier commit had taken a shortcut here that this change undoes.
Tagging the whole HomeStateHandler put every member in external RAM, and
a section attribute applies to the entire variable: transitively that
included the decrypted credential password and notes, the TOTP shared
secret, the new PIN while the change-PIN wizard holds it, and the WiFi
passphrase. Classic ESP32 has no external-RAM encryption unit even on a
secure build, so a passive tap on the quad-SPI lines would have read a
password the moment the user opened a credential. The handler is back in
internal DRAM and only display names, usernames, origin domains and menu
row descriptors leave the die. The security docs gain the allow and never
lists this boundary needs, since they had never mentioned PSRAM at all.
The placement itself was unsafe in a second, quieter way. `.ext_ram.bss`
is a NOLOAD section, so the compiler emits initializer bytes that the
linker discards and esp_psram_bss_init then zeroes — a tagged global with
a non-zero member initializer boots as zeros, on one board, silently. GCC
has exactly that check but applies it only to sections named `.bss.*`,
and the IDF macro's __COUNTER__ suffix escapes it. Definitions now go
through KLEIDOS_EXT_RAM_BSS_DEFINE, which emits an unreferenced twin into
a `.bss.`-named section so the compiler runs its check and --gc-sections
reclaims it; the raw attribute is private and the initializer is fixed by
the macro, so neither skipping the check nor supplying an initializer
compiles. Verified by injecting a non-zero member into MenuItemDesc: the
build fails at both definition lines, and the twin is absent from the
final image.
Also: guard every variant's capacity defines with #ifndef so a
-DVAULT_MAX_CREDENTIALS probe builds fleet-wide instead of on sticks3
alone.
Verified: native 2843/2843, core2_v13_debug, sticks3_debug, tdeck, and
cardputer_debug for the no-PSRAM path where the macro compiles away.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Raise the per-variant capacities, and stop shipping unusable vault UI
Every board now pins the capacity its flash and RAM actually support:
tdeck and cores3_se 700, core2_v13 500, sticks3 and cardputer 250,
m5stickc_plus2 180. The two 4 MB boards stay at 100 — their rekey-safe
ceiling is 139, and staging a second vault for a change-PIN on a fuller
one would run the filesystem out of space.
m5stickc_plus2 could not honestly hold 180: it had 3,132 B of internal
DRAM left against the 4,096 B bar. The slack came from a real absurdity.
Every button board compiled BOTH vault-menu layouts and picked one at
runtime from whether a third button existed, though a board's buttons are
fixed at manufacture. Each unused layout carries a row array sized by
capacity, so plus2 was paying 5 KB for a screen it can never draw. Boards
now declare which layout they use and only that one is compiled: plus2
goes to 9,196 B, sticks3 to 143,141, cores3_se to 78,985. core2_v13
recovers only the view object because its row arrays already live in
PSRAM, which is the placement working as intended.
The declaration is deliberate rather than inferred. Button count looked
like the discriminator only because today's fleet correlates: cardputer
and tdeck are wide-screen boards with no third button, and the old rule
would have handed them the narrow layout if their keyboards ever went
away. What actually decides the layout is width — a sliding pill
indicator is what fits 135 px, while a top tab bar plus an action bar
need room, and a past CoreInk header garble came from that view
hardcoding 320x240. Input decides only how you move between tabs. So the
variant states its choice, with no default, and a build-time tie rejects
a board declaring a layout its hardware cannot drive.
The names were lying too. Both layouts draw tabs, so "Tabbed"
distinguished nothing: they are now NarrowVaultView and WideVaultView,
after the constraint that actually separates them. KeyboardVaultView
keeps its name on purpose — cardputer is 240x135 and tdeck is 320x240,
one narrow and one wide, and both use it, so there the discriminator
really is the input device.
Review of the change caught a T-Deck pin that had never been raised while
five other artifacts already advertised 700, a NAV TOTP macro that
regressed on the wide boards so the screenshot suite filed a vault-list
capture as the TOTP screen, a selector whose "no silent default" promise
had a hole (an unknown token preprocesses to zero, which was the one
value exempted, so a typo compiled no view at all), and a variant guard
whose regex rejected correct declarations that carried a trailing
comment.
Verified: native 2861/2861, all three repo guards, and all eight boards
build with their declared layout confirmed present and its siblings
absent in each map.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r