at main
8 folders
7 files
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
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
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
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
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
Let a stack measurement start from a known point, and run it at the real work factor
Right-sizing the task stacks needs two things the tree did not have.
FreeRTOS derives a high-water mark by scanning the fill pattern below the deepest
stack pointer a task ever reached, so the figure is cumulative since the task was
created and the kernel offers no way to reset it. Measuring one workload rather
than everything since boot therefore means re-filling the dead region, and doing
that safely means doing it from inside the target task, where its own stack
pointer is readable. A new platform helper does exactly that, reached through
each worker's existing trampoline — the vault executor, the admin-crypto runner,
the BLE command queue — with a new operation added to the PBKDF2 worker, whose
request type had no generic entry point. It refuses at runtime unless a wide
margin below the current stack pointer is free, because getting that wrong
corrupts a live frame. It also scrubs residual PIN and key bytes out of the dead
region, which is worth having on its own.
The second is a work factor that means something. Debug builds force PBKDF2 to a
single iteration, so a derivation measured there does not hold the deep call
chain for a representative length of time. A `<variant>_stress` env extends
`_debug`, keeping the console and the reset command, and drops that override so
the board's production count applies through the variant's own #ifndef default:
175,000 on the S3, 40,000 on classic. Wired for sticks3 and m5stickc_plus2.
Invariant 19 keeps the shape honest: a stress env must extend its `_debug` twin
rather than re-derive it, and must not carry the debug iteration override.
Verified: sticks3_debug and sticks3_stress build clean, check_variants passes
across 40 environments, check_layout is green and native tests pass 2861/2861.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Let a stack measurement start from a known point, and run it at the real work factor
Right-sizing the task stacks needs two things the tree did not have.
FreeRTOS derives a high-water mark by scanning the fill pattern below the deepest
stack pointer a task ever reached, so the figure is cumulative since the task was
created and the kernel offers no way to reset it. Measuring one workload rather
than everything since boot therefore means re-filling the dead region, and doing
that safely means doing it from inside the target task, where its own stack
pointer is readable. A new platform helper does exactly that, reached through
each worker's existing trampoline — the vault executor, the admin-crypto runner,
the BLE command queue — with a new operation added to the PBKDF2 worker, whose
request type had no generic entry point. It refuses at runtime unless a wide
margin below the current stack pointer is free, because getting that wrong
corrupts a live frame. It also scrubs residual PIN and key bytes out of the dead
region, which is worth having on its own.
The second is a work factor that means something. Debug builds force PBKDF2 to a
single iteration, so a derivation measured there does not hold the deep call
chain for a representative length of time. A `<variant>_stress` env extends
`_debug`, keeping the console and the reset command, and drops that override so
the board's production count applies through the variant's own #ifndef default:
175,000 on the S3, 40,000 on classic. Wired for sticks3 and m5stickc_plus2.
Invariant 19 keeps the shape honest: a stress env must extend its `_debug` twin
rather than re-derive it, and must not carry the debug iteration override.
Verified: sticks3_debug and sticks3_stress build clean, check_variants passes
across 40 environments, check_layout is green and native tests pass 2861/2861.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
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
Right-size the flash layout and write the index once per import
Two capacity items that were blocking the raised per-variant caps.
The partition tables gave the app far more room than the largest image
needs while starving the vault: the 8 MB boards now run 2 x 0x2C0000 app
slots with a 0x260000 LittleFS, the 4 MB boards a 0x290000 app with
0x160000 of filesystem, and the 16 MB tables keep their geometry. The
loaded 8 MB build sits at 78 % of its app slot and the 4 MB ones near
70 %, so the headroom is real rather than asserted. Two rationales that
had gone stale — a ">1500 credentials" claim and an Arduino/ESP-TEE
headroom note — are gone.
Changing the layout means a device must be fully re-flashed and its vault
repopulated, which is the standing pre-release policy; the flashing and
variant docs now say so where someone upgrading would look.
The bulk import used to rewrite index.bin for every row, and each rewrite
materialized the whole capacity-scaled working set: at 500 credentials
that is a flash rewrite and ~89 KB of transient per credential. A batch
guard now defers the rows and rebuilds the index once at the end.
Deferral is only safe if a half-finished batch can never be mistaken for
a finished one, and review found three ways it could be:
- A transient key-derivation failure returned without closing the batch.
Every later index mutation then reported success while writing nothing,
favorites and ordering survived only in RAM, and the portal's
index-repair button reported success while doing nothing — until a
reboot. The batch now closes before the write, so a failed write cannot
leave mutators deferring into a buffer nobody will flush, and the
key-derivation path aborts the batch explicitly.
- If the pre-batch unlink failed — lfs_remove needs a metadata block, so
it fails on a full filesystem — the old index survived and the batch
deferred every row into oblivion, leaving a MAC-valid, complete-looking
index missing every imported credential with no path back. The batch
now refuses to open and per-row writes stand: slower, not wrong.
- The batch depth was atomic to survive the vault worker's inline-
execution fallback but the snapshot pointer was not, leaving a
use-after-free window in exactly that case.
Also: the capacity guard's ceiling search could two-cycle and return
whichever value the iteration budget's parity landed on, one credential
above the self-consistent ceiling — failing open in the direction it
exists to prevent. It now iterates to a real fixpoint and takes the lower
value on a cycle.
Verified: native 2860/2860, the variant-config guard over all 38
environments, and every one of the eight boards builds — including
m5core_ink, which had never been built directly before.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Capture globals in the debug core dump, not just stacks
A dump of task stacks alone says where execution was, not what put it there.
Most defects show up in global state — a driver state machine in a bad phase, a
queue that filled, the FSM context — and all of that lives in .data and .bss,
which the previous configuration excluded.
Enabling it is arithmetic, not preference. Measured on sticks3_debug: 35,580 B of
.data plus 86,336 B of .bss, with roughly 19 KB of task stacks on top, so about
140 KB against the 64 KB the partition had. The space comes from the two app
slots, which shrink to 2.625 MB on the 8 MB boards and 4.3125 MB on the 16 MB
boards and stay equal to each other so OTA semantics do not change; the images
measure 2.13 MB and 2.30 MB, leaving 19% and 47% headroom. The vault keeps its
offset and its size, which is the property worth protecting, and the top-of-flash
headroom the open tables reserve for the secure layout goes back to being free.
One correction to the earlier rationale: CONFIG_ESP_COREDUMP_CAPTURE_DRAM does
not capture the heap. Verified in ESP-IDF 6.0.2
(components/espcoredump/src/core_dump_common.c, s_memory_sections[]): it adds the
_data_start.._data_end and _bss_start.._bss_end ranges only, and the heap lives
past _bss_end. The security consequence is real regardless, and more direct than
the heap argument was: AppContext is a function-local static, so vault.masterKey
sits in .bss and lands in the image. A stored dump is a copy of the unlocked
vault session and stays in flash until erased. That is why the feature is
debug-only, why the partition exists only on debug tables, why invariant 17
forbids it on every shipping target, and why the boards carrying it must hold
throwaway vaults and have their dumps erased after extraction.
Verified: the generated partition table reads coredump at 0x550000 sized 256K
with littlefs untouched at 0x590000, sticks3_debug and tdeck_debug both build
clean, and check_variants passes.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Right-size the flash layout and write the index once per import
Two capacity items that were blocking the raised per-variant caps.
The partition tables gave the app far more room than the largest image
needs while starving the vault: the 8 MB boards now run 2 x 0x2C0000 app
slots with a 0x260000 LittleFS, the 4 MB boards a 0x290000 app with
0x160000 of filesystem, and the 16 MB tables keep their geometry. The
loaded 8 MB build sits at 78 % of its app slot and the 4 MB ones near
70 %, so the headroom is real rather than asserted. Two rationales that
had gone stale — a ">1500 credentials" claim and an Arduino/ESP-TEE
headroom note — are gone.
Changing the layout means a device must be fully re-flashed and its vault
repopulated, which is the standing pre-release policy; the flashing and
variant docs now say so where someone upgrading would look.
The bulk import used to rewrite index.bin for every row, and each rewrite
materialized the whole capacity-scaled working set: at 500 credentials
that is a flash rewrite and ~89 KB of transient per credential. A batch
guard now defers the rows and rebuilds the index once at the end.
Deferral is only safe if a half-finished batch can never be mistaken for
a finished one, and review found three ways it could be:
- A transient key-derivation failure returned without closing the batch.
Every later index mutation then reported success while writing nothing,
favorites and ordering survived only in RAM, and the portal's
index-repair button reported success while doing nothing — until a
reboot. The batch now closes before the write, so a failed write cannot
leave mutators deferring into a buffer nobody will flush, and the
key-derivation path aborts the batch explicitly.
- If the pre-batch unlink failed — lfs_remove needs a metadata block, so
it fails on a full filesystem — the old index survived and the batch
deferred every row into oblivion, leaving a MAC-valid, complete-looking
index missing every imported credential with no path back. The batch
now refuses to open and per-row writes stand: slower, not wrong.
- The batch depth was atomic to survive the vault worker's inline-
execution fallback but the snapshot pointer was not, leaving a
use-after-free window in exactly that case.
Also: the capacity guard's ceiling search could two-cycle and return
whichever value the iteration budget's parity landed on, one credential
above the self-consistent ceiling — failing open in the direction it
exists to prevent. It now iterates to a real fixpoint and takes the lower
value on a cycle.
Verified: native 2860/2860, the variant-config guard over all 38
environments, and every one of the eight boards builds — including
m5core_ink, which had never been built directly before.
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
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
Capture globals in the debug core dump, not just stacks
A dump of task stacks alone says where execution was, not what put it there.
Most defects show up in global state — a driver state machine in a bad phase, a
queue that filled, the FSM context — and all of that lives in .data and .bss,
which the previous configuration excluded.
Enabling it is arithmetic, not preference. Measured on sticks3_debug: 35,580 B of
.data plus 86,336 B of .bss, with roughly 19 KB of task stacks on top, so about
140 KB against the 64 KB the partition had. The space comes from the two app
slots, which shrink to 2.625 MB on the 8 MB boards and 4.3125 MB on the 16 MB
boards and stay equal to each other so OTA semantics do not change; the images
measure 2.13 MB and 2.30 MB, leaving 19% and 47% headroom. The vault keeps its
offset and its size, which is the property worth protecting, and the top-of-flash
headroom the open tables reserve for the secure layout goes back to being free.
One correction to the earlier rationale: CONFIG_ESP_COREDUMP_CAPTURE_DRAM does
not capture the heap. Verified in ESP-IDF 6.0.2
(components/espcoredump/src/core_dump_common.c, s_memory_sections[]): it adds the
_data_start.._data_end and _bss_start.._bss_end ranges only, and the heap lives
past _bss_end. The security consequence is real regardless, and more direct than
the heap argument was: AppContext is a function-local static, so vault.masterKey
sits in .bss and lands in the image. A stored dump is a copy of the unlocked
vault session and stays in flash until erased. That is why the feature is
debug-only, why the partition exists only on debug tables, why invariant 17
forbids it on every shipping target, and why the boards carrying it must hold
throwaway vaults and have their dumps erased after extraction.
Verified: the generated partition table reads coredump at 0x550000 sized 256K
with littlefs untouched at 0x590000, sticks3_debug and tdeck_debug both build
clean, and check_variants passes.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Right-size the flash layout and write the index once per import
Two capacity items that were blocking the raised per-variant caps.
The partition tables gave the app far more room than the largest image
needs while starving the vault: the 8 MB boards now run 2 x 0x2C0000 app
slots with a 0x260000 LittleFS, the 4 MB boards a 0x290000 app with
0x160000 of filesystem, and the 16 MB tables keep their geometry. The
loaded 8 MB build sits at 78 % of its app slot and the 4 MB ones near
70 %, so the headroom is real rather than asserted. Two rationales that
had gone stale — a ">1500 credentials" claim and an Arduino/ESP-TEE
headroom note — are gone.
Changing the layout means a device must be fully re-flashed and its vault
repopulated, which is the standing pre-release policy; the flashing and
variant docs now say so where someone upgrading would look.
The bulk import used to rewrite index.bin for every row, and each rewrite
materialized the whole capacity-scaled working set: at 500 credentials
that is a flash rewrite and ~89 KB of transient per credential. A batch
guard now defers the rows and rebuilds the index once at the end.
Deferral is only safe if a half-finished batch can never be mistaken for
a finished one, and review found three ways it could be:
- A transient key-derivation failure returned without closing the batch.
Every later index mutation then reported success while writing nothing,
favorites and ordering survived only in RAM, and the portal's
index-repair button reported success while doing nothing — until a
reboot. The batch now closes before the write, so a failed write cannot
leave mutators deferring into a buffer nobody will flush, and the
key-derivation path aborts the batch explicitly.
- If the pre-batch unlink failed — lfs_remove needs a metadata block, so
it fails on a full filesystem — the old index survived and the batch
deferred every row into oblivion, leaving a MAC-valid, complete-looking
index missing every imported credential with no path back. The batch
now refuses to open and per-row writes stand: slower, not wrong.
- The batch depth was atomic to survive the vault worker's inline-
execution fallback but the snapshot pointer was not, leaving a
use-after-free window in exactly that case.
Also: the capacity guard's ceiling search could two-cycle and return
whichever value the iteration budget's parity landed on, one credential
above the self-consistent ceiling — failing open in the direction it
exists to prevent. It now iterates to a real fixpoint and takes the lower
value on a cycle.
Verified: native 2860/2860, the variant-config guard over all 38
environments, and every one of the eight boards builds — including
m5core_ink, which had never been built directly before.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r