Mark web administration complete, centralize current contracts and acceptance evidence, and remove superseded slice records. Update roadmap, architecture notes, and test references without changing firmware sources.
32 KiB
Architecture
Purpose and system shape
This ESP32-S3 firmware exposes one MAX3243-backed UART1 RS-232 port through three bounded transport families:
- native USB CDC-ACM, which is local and unauthenticated;
- authenticated HTTPS with a browser WebSocket terminal;
- authenticated SSH.
UART0 remains a separate trusted administration and recovery console. A local OLED and three buttons provide status and a deliberately limited control surface. Persistent application configuration and security material are stored in versioned NVS blobs.
The central data-path invariant is one serial writer with multiple observers:
USB CDC ---------\
WebSocket --------> session broker <--> serial service <--> UART1 <--> MAX3243
SSH role=user ---/ |
+-- one writer lease
+-- bounded output per observer
SSH role=admin ------> shared administration dispatcher <------ UART0
(does not join the broker)
Typed Network settings
web_network_settings admits bounded current-admin operations to the existing ID-only dispatcher; wifi_manager remains radio/event/reannouncement owner and mdns_service owns independent hostname persistence. Zero-wait secret-free projections never copy saved PSKs onto HTTPD. Conditional compare/merge preserves omitted secrets, queue admission precedes Wi-Fi publication, and edits require explicit Save. Wi-Fi Load is stored-only; mDNS may load deterministic defaults. SSIDs remain byte-reversible; AP clear is denied. One login-bound slot and one-second timer bound queued secret retention to 30 seconds plus scheduling latency, not cancellation after owner admission. accepted is not online/DNS completion. See Network contracts.
Startup and initialization
app_main() in src/main.c is the composition root. The implemented order matters:
- Report PSRAM and initialize the sole project-owned application DRBG before Wi-Fi or other radio use.
- Initialize boot-critical RGB LED state, RS-232 ownership/static-safe hardware, diagnostics, and the shared administration dispatcher resources. RGB LED initialization is currently guarded by
ESP_ERROR_CHECKand is therefore boot-fatal. - Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds.
- Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure.
- Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here.
- Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed.
- Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task.
- Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
- Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects
main.cafter accepted legacy-credential cleanup. - Start the local status/control task if button initialization succeeded.
- Construct ESP-IDF's UART REPL to initialize
esp_console, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher.
Several core initializers use ESP_ERROR_CHECK; optional display and network/security paths generally log failure while retaining UART0 administrative recovery and network-independent UART1 access through USB. SSH starts before command registration, so role-user sessions can be admitted in that interval while role-admin sessions are rejected until the administration frontend is ready.
Serial service and physical ownership
serial_service owns the UART1 driver while running. It exposes bounded RX/TX streams and a task that:
- continuously drains UART RX, even if UART event notification is incomplete;
- moves pending TX to the UART FIFO without blocking indefinitely on CTS;
- discards and accounts queued traffic during shutdown/reconfiguration.
rs232_port_owner separately protects the physical UART/MAX3243 resource:
NONE: available;PHASE0: hardware diagnostics own it;SERVICE: serial service owns it;FAULT: cleanup could not establish a safe state; reboot is required.
The owner is cooperative rather than an interceptor for UART/GPIO APIs. Active diagnostic commands claim PHASE0, and the running service claims SERVICE; boot-time static-safe GPIO initialization and service-owned restoration of that static mode are explicit exceptions. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.
Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure. Stop/reconfiguration discards and accounts serial-service RX/TX streams and task-local pending TX, but does not disconnect broker clients or clear their writer lease, events, or already-fanned output. An open USB session retries service start after a stop; existing WebSocket and role-user SSH sessions do not independently restart it. Persistence is explicit through save/reset commands.
Session broker and data flow
The permanent session_broker task is the intended sole consumer/producer of serial-service data.
RS-232 to clients
UART RX -> serial-service RX stream -> broker task
-> independent bounded output stream for every connected client
-> USB / WebSocket / SSH transport output
The broker drains serial input even with no clients. A full client output stream drops only that client's copy and updates drop counters; it does not block UART reception or other clients.
Binary serial WebSocket output uses the IDF-5.5.0-pinned web_httpd_ws_send_binary adapter: one owner-only session-override send of a bounded header+payload copy (516 bytes of local scratch, 512-byte payload). Existing generation validation and one outstanding work item per slot remain required. Non-full sends immediately replace that session's send override with a reject-only guard, mark it closing and shut down the socket before deferred cleanup; this prevents automatic SDK control replies from reentering TLS after incomplete output. HTTPD retains TLS destruction ownership. Text/control and admin output retain the SDK sender. One send call does not imply one TLS record, packet or peer receipt; the user signed off drop-free230400-baud full-client-mix operation at160MHz. Runtime stack-margin validation remains a separate follow-up; evidence and acceptance scope are in current-state.md.
Active-client counter snapshots expose ID/type/pending/HWM/UART/queued/read/dropped. HWM is maintained independently of web tracing and counter clear seeds current occupancy. Read means transport handoff, not peer receipt. Disconnect removes the client row while global totals retain traffic and unread-output discards. Independent default-disabled web performance capture uses two fixed slot records and nonwrapping epoch/generation fences; toggles fence in-flight samples, disable freezes aggregates, clear preserves enable state. Binary-only timestamps bound reservation-entry to callback-entry before locking, synchronous send calls and completion to broker-read return; idle and intervening work prevent scheduler-only/backlog-at-completion claims. No new instrumentation allocations or scheduling/buffer changes. Exact fields, overhead and capture contracts: throughput diagnostics.
Clients to RS-232
transport input -> broker write check -> serial-service TX stream -> UART TX
Only the generation-safe client ID holding the current writer lease may enqueue input. All connected clients, including the writer, observe UART output. Normal requests acquire the lease only when free; disconnect releases it. Administrative APIs can force reassignment or compare-and-release an expected writer.
Broker events are advisory bounded notifications. Transports reconcile against authoritative snapshots because an event queue can overflow. DTR_ON_CONNECT follows whether any broker client is connected, not writer ownership.
The broker currently enters nonblocking serial read/write APIs while holding its mutex and takes the serial-service state mutex during first-connect/last-disconnect DTR changes. Keep this ordering acyclic: serial-service code must not call broker APIs while holding its state mutex.
Transport architecture
USB CDC
usb_cdc_transport has a permanent transport task and TinyUSB callbacks. Attached plus host DTR asserted is treated as open. Opening starts the serial service if necessary, creates the usb-cdc broker client, and opportunistically requests writer ownership; otherwise USB observes.
TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. The line-coding callback records the latest host setting for diagnostics only. It never reconfigures UART1: physical framing and speed remain controlled by the explicit serial configuration, regardless of USB writer ownership.
HTTPS, WebSocket, and web serial
web_server owns HTTPS on port 443 with a persisted self-signed P-256 identity. web_serial_transport mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. Authentication contract.
web_httpd_adapter is the sole private IDF 5.5.0 boundary for duplicate headers, admission-before-101, consumed-scratch wiping, staged optional URI registration, combined binary sends and owner-only idle sweeps. Re-audit its version guard on SDK upgrades. HTTPD debug logging must not expose headers/tickets. web_diagnostics independently observes public post-TLS callbacks using six metadata records and a default-disabled 32-event ring; it cannot see preaccept/in-progress/failed TLS. Admission diagnostics.
web_httpd_idle uses one one-second timer, six rows and at most one queued owner probe. Current-owner shutdown follows 15 seconds of observed ordinary idle, exempting actual WebSockets/async/pending input. Owner delays prevent hard timeout guarantees; accepted-but-lost work stays reserved until successful destruction, failed stop retains ownership. Idle lifecycle contract.
web_ui/web_login_ui own authored documents/scripts and hash-bound CSP loaders; authentication documents/app are no-store. Checked-in generated xterm/logo assets are compiled, not regenerated by ordinary builds. Browser Serial/Admin/Settings navigation changes view/input only, preserving serial client/lease and hidden output draining. Session-identity changes require a clean document; pagehide/restore revalidates before exposing buffers. One shared quick-settings host/controller preserves drafts, stale selections and pending uncertainty. Terminal and console contract.
Browser administration and HTTPS lifecycle
web_admin_transport/web_admin_tickets add one optional admin socket, two session-bound tickets and admission to the same two remote-console slots shared with SSH, never a broker client. HTTPD owns a 1,552-byte PSRAM-only payload and IO; a 20 ms timer queues at most one poll. Current-owner shutdown avoids queued reusable socket pointers. Detach fences submissions and only successful HTTPD stop retires old work. Console/owner checks enforce currentness before sensitive work; unsupported parsed shell commands reject before effects. Browser-shell policy.
web_lifecycle_settings uses one original-login slot and send-return → nonreused-ID HTTPD callback → existing dispatcher for self-cutting HTTPS/reboot actions. Two-second ACK and 30-second dequeue bounds precede admission, not receipt/completion. Accepted-but-lost callbacks retain one reservation through failed stop; only callback/successful destruction retires it. Conditional stop/restart reserve saturated lifecycle generation; restart retains ownership through stop/start. Conditional reboot invokes canonical esp_restart() outside locks, never HTTPD self-stop or console-cleanup waits.
web_server_replace_identity reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. HTTPS ownership, generation and recovery contract.
SSH
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. ssh_transport_replace_identity reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. SSH contracts.
ssh_transport uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls wolfSSH_Init() in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by ssh_security.
Routing follows the authenticated role:
user: start the serial service if necessary, then create a broker-backed binary-transparent serial stream and opportunistically request writer ownership;admin: bounded administration console, with no broker client or writer lease.
A shell request is required, but project code does not explicitly require a PTY. Exec and subsystem requests are rejected, and there is no project SFTP, SCP, agent-forwarding, or TCP-forwarding route.
Authentication, authorization, and revocation
user_database is a fixed-capacity, mutex-protected store: at most eight accounts and three authorized keys per account. Accounts have user or admin role, random account ID, and authentication generation. Passwords are salted PBKDF2-HMAC-SHA256 verifiers; plaintext passwords are not retained in the database.
Network code holds copied, secret-free principals rather than pointers into database records. Principal currentness requires matching username, account ID, role, and authentication generation. Password, role, or key changes increment the generation; deletion/recreation also changes the account ID.
Revocation has two layers:
- after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; notification failure does not roll back the mutation;
- transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails.
The final administrator cannot be deleted or demoted. UART0 establishes the first administrator through normal user add <username> admin and owns explicit unavailable-database recovery to empty. Recovery refuses a healthy database. No bootstrap API or command remains. Authenticated admin SSH can run the operational registry but is denied recovery; other secret-bearing commands are remotely available unless their handlers deny them.
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials and TLS/SSH private keys remain recoverable under physical flash extraction.
Typed Accounts and Serial settings
web_account_settings owns one login-bound slot for create/password/role/delete/key operations; HTTPD reads compact zero-wait metadata and queues only IDs. The database compares target username/account-ID/auth-generation inside its mutation lock and shares canonical invariant/commit logic. Successful commands target-revoke, including self; result loss is uncertain. A one-second timer wipes queued non-executing credentials after 30 seconds plus scheduling latency; admitted work wipes locals on return. Generated password delivery is a separate no-retained-retrieval POST, not mutation; key listing is fingerprint-only with stable sparse indices. Accounts contracts.
web_serial_settings queues bounded typed operations to the same dispatcher, retaining one original-login result and a 30-second dequeue check. Apply/Defaults are RAM-only; Save persists device working state. Reconfiguration can discard serial-service pending bytes while broker clients/lease/output remain. Snapshot reads are zero-wait and consistent; /api/status reports unavailable running state as null. Settings navigation preserves both terminals; bounded completion checks and manual uncertainty recovery never replay mutations. Typed API/lifetime.
Console architecture
UART0 and admin SSH share canonical command implementations:
UART0 linenoise frontend --\
> fixed request queue -> one dispatcher -> esp_console_run()
admin SSH line editor ----/ |
+-> registered *_console handlers
admin_ssh_console creates the dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. An admin SSH connection during that boot window is rejected rather than racing an incomplete registry.
The dispatcher is the sole caller of esp_console_run(), serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
The transport-neutral boundary retains admin_ssh_console_open_owned() and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. SELF_CLOSE is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner is_current checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
For SSH, standard output/error is redirected to the invoking session's bounded output ring. console_input routes visible or hidden prompts to UART0 or the active SSH session. exit and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-user SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
Admin SSH exit, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable dispatcher_actions mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed, but browser self/generated/key/recovery and restricted network/SSH shell actions are not; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. Browser policy and recovery.
Wi-Fi and persistence
wifi_config owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy off, fallback, or always. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
wifi_manager is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. mdns_service initializes the responder at most once after a validated STA GOT_IP; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM enabled_at_boot field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
Persistent namespaces/blobs include:
serial/config;wifi_app/config;mdns_cfg/config;local_ui/config;web_sec/material;user_db/database;ssh_sec/material.
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in partitions.csv; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
Typed Display and Broker settings
web_display_settings queues IDs; local_status_ui owns a nonwrapping configuration generation and zero-wait writer reservation shared with CLI/legacy Apply. NVS runs outside critical sections. Save stabilizes RAM, Load preserves fallback behavior, Reset commits before RAM publication. Buttons/diagnostic holds change independent activity state, not config generation. Settings need an available UI task, not an attached OLED. Display contract.
web_broker_settings exposes compact zero-wait rows and confirmed writer assignment through one login-isolated slot. The broker atomically snapshots clients/writer/lease version and compares selected target/version inside the force-writer lock before effects. Three-bit slot/29-bit client generations retire rather than wrap; a separate saturated 32-bit lease generation advances before advisory event delivery and survives counter clear. Saturation blocks conditional assignment, not ordinary request/release/disconnect/recovery force. Contextual refresh never silently rebases explicit selections or clears sticky stale/absence latches; deliberate reselection is required. Broker/context contracts.
Local UI and hardware boundaries
board_pins.h centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to main.c, and native USB uses platform wiring. local_display solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
When button GPIO initialization succeeds, local_status_ui starts a firmware-lifetime low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer.
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The display configuration commands depend on the UI task. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.
Concurrency and lifecycle constraints
- Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task.
- Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
- Transport slot generations and account authentication generations solve different stale-reference problems; preserve both.
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, the SSH owner task owns post-initialization wolfSSH runtime calls, and the console dispatcher owns
esp_console_run(). - Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
Legacy credential removal storage boundary
user_database_init(load_result) has no credential input. Missing storage is persisted empty; user_database_recover_empty() is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private v1_admin_marker retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
web_security owns TLS only. A private reader validates 1,392-byte v1 web_sec/material, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: legacy compatibility.