# Phase 8D.0 — Baseline and M1 browser contract Status: **documentation/source audit, baseline build and user-provided runtime memory baseline recorded; remaining target validation and numeric runtime budgets pending.** This is a plan for M1, not implemented authentication. Stop at 8D.0; do not start 8D.1 without the missing evidence or an explicit user decision. See [execution plan](phase8d_plan.md) and [acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). ## Reproducible build baseline - Recorded 2026-09-05 on `devel`, revision `af89dd1bd96cdd97d8d57eee7a29f68e3874506b` (`Define staged Phase 8D delivery plan`). Working tree was clean before measurement. No experimental-branch inspection/import, firmware edits, asset regeneration, upload, erase, or device reconfiguration. - `pio run` passed in 36.08 seconds (120-second timeout). Release environment `esp32-s3-devkitc-1-n16r8`, `platformio/espressif32@6.12.0`, ESP-IDF 5.5.0, Xtensa toolchain 14.2.0+20241119. Existing framework Kconfig invalid-bool-default notes appeared; they did not fail the build. - N16R8: 16 MB QIO flash, 8 MB octal PSRAM at 80 MHz, custom partitions. Application flash budget is 4,194,304 bytes, not the full chip. Wi-Fi/lwIP allocations prefer external RAM; the configured mbedTLS allocator is PSRAM-only, without internal fallback. Software crypto configuration is retained. - `pio device list` completed with no ports listed. No target/browser connection was available to this task. Historical target reports are not fresh evidence for this revision. | Measurement | This build | Historical mDNS reference | Difference | |---|---:|---:|---:| | Linked static RAM | 94,532 B | 94,532 B | 0 B | | Program flash reported by PlatformIO | 1,599,973 B | 1,599,765 B | +208 B | The flash difference predates this documentation-only change; its cause was not diagnosed. Linked RAM is not runtime heap headroom. No runtime delta can be inferred from these figures. SHA-256 identification (configuration and generated binary, not secrets): | File | SHA-256 | |---|---| | `platformio.ini` | `65ca5103c7aaf36b685a05371a856294ba6f7f5f53209c68d61a828d3beb78e5` | | `sdkconfig.defaults` | `af8fb8a9866888a12219a9d4ff5148d9f45f17c40067dce90fbc55c0b75986e5` | | `sdkconfig.esp32-s3-devkitc-1-n16r8` | `c9c8c08b18027e959ba8131686f0e42573e507574079be3cf8fb72a9e1955905` | | `partitions.csv` | `9107a2aab52f02633aea72683ffede979d9db2050e95ba4788cd0f8580c7f0ff` | | `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin` | `c5f10b2137d2cbffad59cda025274dd82aad1a42cdea2144d31424fd918b4e6d` | ## Source-verified current behavior and capacities Authoritative paths: `src/web_server.c`, `src/web_serial_transport.{h,c}`, `src/web_ui.c`, `src/user_database.h`, `src/system_console.c`, `src/session_broker.{h,c}`, `src/ssh_transport.{h,c}`, `src/admin_ssh_console.c`. Configuration-dependent defaults below were also checked against installed IDF 5.5 HTTPD/HTTPS headers and implementation and targeted generated sdkconfig entries. - Both roles currently use Basic authentication for the same serial/status UI; there is no login page, cookie session, logout, or browser admin route. All five app assets require authentication. Basic header buffers are wiped after verification. The four-entry keyed-digest cache has a 300-second sliding TTL and rechecks principal currentness; cache expiry is not browser logout. - Ticket issuance accepts only an empty POST body. Serial upgrade consumes a one-use, principal-bound ticket; supplied Origin must match `https://`, but missing Origin is currently accepted. Four tickets, 30-second lifetime, 24 random bytes encoded to 32 characters, digest-only storage. Full ticket storage currently evicts the earliest-expiring entry. - Serial admission opportunistically requests writer ownership. Explicit browser Disconnect closes the socket and pauses reconnect; Connect resumes it. Binary data and existing `request-writer`/`release-writer` messages remain unchanged in M1. Status polling runs every five seconds. Current fetch failures do not distinguish expired login from transport failure; cutover must change this. - Principals copy account ID, authentication generation, role, method and username. Database limits are eight accounts, username capacity 16 bytes, password capacity 64 bytes. Mutation/recreation invalidates principal currentness; transport notification supplements, not replaces, that authority. ### Allocation and execution inventory These are **source array capacities/configured stacks**, not measured allocator costs or complete `sizeof` totals. Do not add stack-local scratch to the stack allocation again. | Resource | Existing bounded allocation or limit | |---|---| | HTTPS owner | One dynamic internal 10,240 B stack; serialized request handlers; dynamically allocated server/socket/route/TLS state | | HTTP request/response scratch | Status JSON 3,072 B on stack; Basic header 115 B, decoded credentials 81 B, digest 32 B on stack; startup certificate/key scratch 1,024 + 256 B | | HTTP parser/response headers | Request headers 1,024 B, URI 512 B, eight additional response-header slots; implementation must count cookie + security headers before cutover | | TLS records | Configured input/output content limits 16,384/4,096 B per TLS connection; external allocation, dynamic resizing disabled; contexts, certificates, TCP and allocator overhead additional; HTTPS TLS resumption tickets disabled | | Web serial | Two static slots, each 1,024 B RX + 512 B TX (3,072 B payload total), principals and metadata; four ticket records; one static 6,144 B stack + TCB; at most one pending HTTPD TX work item per slot | | Broker | Eight preallocated clients, 4,096 B usable output/client (32,776 B backing storage including sentinel bytes); 16 events/client, additional `8 × 16 × sizeof(session_broker_event_t)` storage; payloads PSRAM-preferred with internal fallback, controls internal; one dynamic 4,096 B stack | | SSH | Two total slots shared by serial/admin/handshakes, 512 B RX + 512 B TX each (2,048 B payload total), plus snapshots/metadata; one dynamic 20,480 B owner stack pinned to core 1; wolfSSH allocations PSRAM-preferred with internal fallback | | Shared administration | Two static remote states with 5,899 B explicit byte arrays each (including 4,096 B output and four history entries); additional 1,024 B SSH and 1,024 B UART completion formatters; command queue four, deferred-control queue two; 256-character commands, ten parsed arguments | | Admin tasks | Dynamic dispatcher 12,288 B, deferred control 4,096 B, UART frontend 6,144 B; no task per remote session | | Serial service | 16 KiB RX + 8 KiB TX usable stream payloads PSRAM-preferred with internal fallback; UART driver/control/task storage additional | The seven listed HTTPD/web/broker/SSH/admin task stacks total **63,488 B**; this is not a whole-firmware task inventory. Other existing tasks, including Wi-Fi, USB, serial, UI, mDNS and system tasks, contribute to the measured baseline. No new task is allowed for M1. Internal fallback is a worst-case reserve cost, not free PSRAM capacity. Exact padded structure sizes, per-TLS/SSH handshake peaks and fragmentation remain unmeasured. ### Supported concurrency and socket accounting Configured capacity is not evidence that every combination has passed this revision's target tests. | Resource | Baseline ceiling / intended workload | |---|---| | Broker clients | Eight, exactly one writer; normal physical-transport maximum is USB + two web serial + two user SSH = five | | Web serial | Two simultaneously connected serial WebSockets, independently of four outstanding tickets | | SSH | Two connections total; baseline full mixed workload uses one user and one admin | | HTTPD routes | Nine registered / nine slots: `/`, `/api/status`, `/api/ws-ticket`, `/ws/serial`, five `/assets/` routes; no spare route slot | | HTTPS connections | Six accepted clients total, including WebSockets; two serial sockets leave four HTTP/TLS slots, not four parallel handlers | | HTTPD infrastructure | Three more descriptors: listen, UDP control receive, UDP control send; full HTTPS therefore uses nine | | SSH descriptors | Listener + two sessions = three; combined HTTPS/SSH subtotal twelve of sixteen lwIP sockets | | Remaining socket allowance | Four before other services (including mDNS), network diagnostics and transient accept/reject costs; not four guaranteed spare connections | HTTPS has LRU purge **enabled**, one-second send/receive waits and five-second handshake timeout; SSH has a two-connection listen backlog and 15-second handshake deadline. Before M2, admission must protect retained serial sockets from LRU eviction; do not claim current settings already guarantee that protection. M2 must budget two browser sockets (serial + admin) per dual-mode browser: two such browsers would occupy four of the six HTTPS slots, leaving only two for ordinary HTTPS. This is arithmetic, **not approval of a future admin capacity**. Do not raise lwIP/HTTPD limits or reduce existing serial/SSH capacities to conceal pressure. ## Minimal M1 browser contract (planned) Policy constants below are bounded functional choices, **not approved memory reserve floors**. No settings API, admin shell, generic command endpoint, new permanent task, Basic compatibility path, stored browser passwords/localStorage credentials/remember-me tokens, or generated-asset changes are included. ### Routes and small wire schema All API JSON is UTF-8, length-bounded and safely encoded. Login accepts only `application/json`; no form/query credentials, duplicate/unknown fields, embedded NULs, oversized bodies, or unbounded parser allocations. Maximum login body: **512 bytes**, decoded username/password limits **16/64 bytes**, using existing database validation. Reject excessive bodies before buffering. New authentication JSON responses have a **512-byte serialized ceiling**; the existing status schema/buffer is unchanged. `X-CSRF-Token` is the only CSRF request header. | Route | Access and contract | |---|---| | `GET /login` | Public minimal standalone login document; inline bounded CSS/script with matching CSP hash, no dependency on protected app assets. No automatic login or credential verification. | | `GET /api/login-challenge` | Public same-origin pre-login bootstrap, empty body; returns `{ "csrf": string, "expires_in": integer }` (remaining whole seconds, at most 120) and pre-login cookie described below. | | `POST /api/login` | Strict Origin + pre-login cookie + CSRF header; body `{ "username": string, "password": string }`; success `200 { "authenticated": true }` and new session cookie. Browser navigates to `/`. | | `GET /api/session` | Session required; returns `{ "username": string, "role": "user" or "admin", "csrf": string, "expires_in": integer }`, remaining whole seconds. No raw session token or account/verifier internals. | | `POST /api/logout` | Session + strict Origin + CSRF; empty body. Invalidate current session before `204`, expire cookie, browser navigates to `/login`. Never a state-changing GET. | | `GET /`, five existing `/assets/` routes | Session protected. Unauthenticated document navigation to `/` receives `303 /login`; assets/API receive `401`, never login HTML masquerading as JS/JSON. | | `GET /api/status` | Session required; existing status for either role. | | `POST /api/ws-ticket` | Session + strict Origin + CSRF; empty body; preserve existing ticket response fields/protocol, bind ticket to originating session as well as principal. | | `GET /ws/serial` upgrade | Session cookie + strict Origin + one-use matching-session ticket; no Basic fallback. Currentness and expiry checked before attach. | Five new handlers mean **14 route slots total** at cutover if using these exact routes. This is a separately accounted bounded route-table increase, not a socket increase. Preflight/CORS access is unsupported. Other methods must not bypass policy. API failures use a bounded `{ "error": code }` without echoing submitted input; existing non-auth errors may remain plain text and the browser must tolerate both. ### Sessions, CSRF bootstrap and admission - **Four authenticated sessions globally**, independent of two serial sockets and four outstanding serial tickets. New login issues 32 random bytes as 64 lower-case hex characters; store only a SHA-256 token digest with copied principal, origin binding, CSRF state, monotonic deadline and generation-safe session identity. Use `secure_random`; fail closed on RNG/init failure. Keep synchronization explicit for HTTPD lookups versus console invalidation; no cross-subsystem callbacks while holding session storage locks. - Cookie: `__Host-sak-session=; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`, no Domain. **Absolute lifetime one hour; idle expiry disabled in M1.** Polling, traffic and refresh never extend it. No sliding renewal or refresh token; reauthentication after expiry issues a new identity. This deliberately simple policy will interrupt a long-running serial session at expiry and must be visible to the user. Reboot/web-server stop clears sessions and tickets. Browser cookie lifetime is not server authority. - CSRF state: independent 32 random bytes encoded as 64 hex characters, retained only in bounded session state and browser memory. Return via `/api/session` on page load; never localStorage, URL or logs. It does not substitute for the HttpOnly session cookie. Refresh does not rotate it or invalidate another tab. - **Four independent pre-login challenges**, 120-second absolute lifetime. Bootstrap issues `__Host-sak-prelogin` with the same cookie attributes and `Max-Age=120`, backed by token digest, origin binding, separate random CSRF value and deadline. `/login` itself allocates nothing. Bootstrap reuses an unexpired matching challenge for that browser without reissuing the cookie or extending its lifetime, returning remaining seconds; otherwise reclaim expired slots and reject at capacity. Require a same-origin custom header `X-Login-Bootstrap: 1` on the bootstrap fetch; reject cross-site Fetch Metadata when present, and validate any supplied Origin. No CORS headers: another origin cannot read a challenge or make that custom-header request. This handles same-origin GETs where browsers omit Origin without weakening POST checks. - Login consumes a valid challenge **on every credential attempt**, including wrong passwords; clear its cookie, wipe request/password scratch on all exits, and fetch a new challenge for a user-initiated retry. Concurrent tabs share the cookie: a stale form gets a recoverable challenge-expired error, not a login loop. Successful login always generates a fresh authenticated token (no fixation). Already authenticated browsers receive `409 already_authenticated` instead of silently replacing an account/session; account switching requires logout. - Reclaim expired/stale records first; full live session/challenge/ticket tables return `503 capacity` with `Retry-After: 5`, with **no live-session/ticket eviction**. Thus ticket overflow intentionally changes from baseline earliest-expiry eviction to explicit rejection at cutover. A session never reserves a serial socket; full serial capacity can reject Connect without discarding login or disturbing an existing writer. - Login throttle: one global bounded monotonic bucket allowing **five credential-verification attempts per 60-second window**, successes included; further attempts get `429 throttled` with seconds until window reset in `Retry-After`. Invalid CSRF/Origin never reaches password verification. No per-username/IP table, sleep in HTTPD, secret retention or persistent lockout. Tradeoff: a hostile client can exhaust shared login capacity; UART0/USB and established sessions remain usable. Do not claim comprehensive DoS resistance; measure PBKDF2 blocking/TLS pressure on target. ### Origin, expiry and revocation rules - Every mutation (login, logout, ticket issuance) and WS upgrade requires a single non-null HTTPS Origin equal to the request's validated Host authority after lower-casing host and normalizing default port 443. Reject absent/malformed/oversized Origin, userinfo, paths, query/fragment, comma lists, invalid Host or non-443 port. Use bounded host/origin buffers; do not trust forwarded headers. A Referer is not a substitute. Bind challenge/session/ticket to that canonical origin. No arbitrary return URLs. - Direct IP and `sak-.local` are separate host-only cookie origins, even on one device. Login/logout on one does not affect the other's unchanged session; no alias cookie sharing. Both require trusted/accepted device TLS in the browser. Hostname changes do not migrate cookies. - Lookup/admission, ticket mint/consume and sensitive WS input recheck session liveness plus principal currentness. Extend existing owner-task periodic checks to enforce expiry/revocation for idle sockets, with a planned **at most one-second detection-latency acceptance target**, no new task. This bound is not established by the current loop; implementation and target validation must include lock contention and concurrent load. Mark invalid immediately and request generation-safe close through HTTPD ownership; network delivery/close completion can take longer. Failed notification must not allow more serial input or revive an expired session. - Logout invalidates only the originating session and its tickets/serial sockets (and later admin sockets); other sessions for the same unchanged account survive. Logout in one tab affects all tabs sharing that cookie. Account password/role/key changes, deletion/recreation and explicit account revocation invalidate all that account's sessions/tickets/sockets across origins, not unrelated accounts. Preserve distinct account, web-session and transport generations. Wipe retired records; late cleanup cannot close reused slots. - All login/session/auth errors, protected documents, API and ticket responses use `Cache-Control: no-store`; preserve nosniff, no-referrer, frame denial and restrictive CSP. Existing immutable vendored asset policy can remain; no auth-dependent data in those assets. Count response headers against HTTPD's eight-slot default, including Set-Cookie (two on login); adjust only if the exact count requires it. Never log cookies, CSRF, ticket query strings, passwords, verifier or private-key material. ### Browser recovery behavior - `401`: stop polling/reconnect work, close local socket, clear in-memory CSRF, navigate once to `/login`. On login submission, `401 invalid_credentials` stays on the form with a generic usable error; it must not reveal account existence. No `WWW-Authenticate`, cached Basic header authorization or browser credential-reset instructions after cutover. - `403`: report CSRF/origin failure without retrying a mutation automatically; allow challenge/session reload and explicit retry. `400`/`413`/`415`: show input/request error. `409`: offer return to app/logout. `429`/`503`: display bounded retry information and do not retry credentials automatically. - Transport/network error: show connection failure without claiming logout succeeded. A lost logout response can follow successful invalidation: check session status when reachable; `401` confirms login is needed. Do not rely solely on client cookie deletion (HttpOnly). - Refresh/back navigation validates `/api/session` before ticket/reconnect; expired cookies, reboot and previously Basic-authenticated profiles must all land on usable login. Explicit Disconnect still pauses reconnect while leaving login valid. Capacity and network failure must not look like bad credentials. Cancel pending work on logout/page exit and ignore late responses via generation checks. ## Runtime measurements, reserve gates and exact target checklist **The user-provided concurrent-session sample below is the runtime memory baseline. Other workload measurements remain pending, not zero.** `memory` reports free/minimum-free/largest-block for internal 8-bit, internal DMA and PSRAM. Record all nine values at every point. DMA overlaps internal heap: do not sum them. Minimum-free is the conservative sum of per-region lifetime minima, not a synchronized low-water sample. Only SSH currently exposes stack minimum-free via `ssh status`; HTTPD/web/broker/admin/UART task margins lack instrumentation and remain pending (no telemetry changes in 8D.0). | Workload point | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free | |---|---|---|---|---| | Settled boot, 60 seconds, network services up | Pending | Pending | Pending | Pending | | Basic-authenticated browser/status, serial explicitly disconnected | Pending | Pending | Pending | Pending | | **Baseline: one browser serial + user SSH + admin SSH (user-provided)** | **39,668 / 13,876 / 18,432 B** | **31,912 / 6,120 / 18,432 B** | **8,138,380 / 8,107,324 / 7,995,392 B** | Pending | | Two browser serial + USB DTR + user SSH + admin SSH | Pending | Pending | Pending | Pending | | After five serial disconnect/reconnect cycles | Pending | Pending | Pending | Pending | | After 15-minute concurrent soak, then 60-second settled cleanup | Pending | Pending | Pending | Pending | | Cookie login/logout and browser admin connected | Not implemented | Not implemented | Not implemented | Not implemented | ### Runtime baseline provenance and observed workload Recorded from the user's administrative SSH transcript supplied on 2026-09-05, at the user's request to use it as the memory baseline. Commands were `broker clients`, `ssh sessions`, `web status`, then `memory`; these are sequential snapshots, not an atomic sample or an agent-executed test. - HTTPS initialized/running on port 443, `last-error=ESP_OK`, HTTP Basic via the user database (four users, two admins). - One of two web serial slots active, zero outstanding tickets: role-`user` password-authenticated browser, broker client **8**, **writer**; no pending TX or closing state reported. - Both SSH slots active, public-key authenticated: role-`user` SSH session **5**, broker client **9**, **observer**; role-`admin` SSH session **6**, administrative console, no broker client. The admin command was running with two output bytes and TX pending during `ssh sessions`, consistent with collecting the sample through admin SSH. - Broker listed exactly two clients, both with zero pending bytes/events. No USB broker client was present; physical USB attachment/DTR was not reported. This is not the planned two-browser-plus-USB full workload. - Flashed revision/configuration, browser/version and origin, baud/framing/flow, uptime, traffic volume, soak duration, cycle counts and stack margins were not supplied. The transcript is accepted as the runtime baseline, but does not independently tie the device to the build hash above or prove serial data integrity, both-role browser login, recovery/USB operation or lifecycle stability. The lifetime minimum-free values may reflect earlier workloads in the same boot. This one sample does not establish a leak/fragmentation trend, per-connection cost or safe allocation allowance. In particular, **6,120 B minimum-free DMA** and **13,876 B minimum-free internal 8-bit heap** are observed lows, not approved reserve floors. Preserve all nine exact values for future like-for-like comparisons. Resource gates: | Gate | Budget / status | |---|---| | 8D.0 firmware allocation/route/task/socket change | **0**; documentation only | | Baseline internal free/minimum/largest-block reserve floors | **Pending reserve analysis and remaining workload measurements**; the recorded sample is not a safety floor | | PSRAM reserve and largest-block floor | **Pending**, including TLS/SSH connection and handshake peaks | | Stack reserve floors | **Pending** SSH measurement and missing owner-task telemetry | | 8D.1 incremental budget | **Pending** target reserve + actual padded four-session storage/lock/counter sizes; coexistence with Basic cache must be counted | | 8D.2 incremental budget | **Pending** exact generation/session bindings added to four tickets/two slots and cleanup integration | | 8D.3 incremental and cumulative M1 budget | **Pending** four challenge records, throttle, five handlers, parser/CSRF scratch, minus removed Basic cache/key; no new task/socket allowance | | M2 admin allocation/socket budget | **Pending** retained-serial admission design and target M1 measurements; not approved here | To close these gates, record each chunk's actual linked/static and allocated structure sizes, internal-versus-PSRAM placement and worst-case fallback, then measure peak incremental handshakes/traffic and post-cleanup fragmentation under the same workloads. Set numeric reserve floors sufficient for measured recovery/USB/SSH needs and the largest required allocation, with an explicitly justified margin; subtract these floors before approving incremental/cumulative budgets. A percentage of total RAM or a source payload subtotal is not a reserve. Stop on monotonic leak/declining largest block, watchdog/stack fault, lost lease or inaccessible login. Target operator checklist (the user-provided sample above partially covers status/memory collection and concurrent web/SSH admission; other checks remain unverified, and none were executed by the agent): 1. Identify flashed revision/configuration against this record; record browser/version, IP/mDNS origin, Wi-Fi mode, serial framing/flow/baud and attached peer/fixture. Use existing provisioned accounts; do not expose credentials in evidence. Firmware upload is a separate user-controlled action; no erase is required for this documentation task. 2. On UART0 run `memory`, `serial status`, `broker status`, `broker clients`, `usb status`, `web status`, `ssh status` at settled boot and each table point. Record output without secrets. Use `pio device monitor -b 115200` once a port is available; this is interactive, not a completed automated test. 3. In separate browser profiles authenticate an existing admin and user through **current Basic auth**; verify protected assets/status and bidirectional serial bytes, single writer/request/release, and observer isolation. Test direct-IP and mDNS separately where STA mDNS is available. If baseline login fails, stop and diagnose in a separate task, not during auth replacement. 4. Verify native USB UART1 with host DTR, user SSH serial and admin SSH console, while UART0 remains usable. Open two browser serial sockets plus USB, one user SSH and one admin SSH; collect the full-workload row. This is four broker clients and two SSH sessions; admin SSH is not a broker client. Also check the alternative two-user-SSH mix (five broker clients) without increasing any limit. 5. Run **five** explicit browser serial Disconnect/Connect cycles per role, observing paused reconnect, old broker cleanup, writer/observer correctness and memory after each cycle. Baseline has no logout: do not report Basic-cache reset as a logout test. M1 will repeat five login/serial/logout cycles per role instead. 6. Run a **15-minute** concurrent soak at **115200 baud, 8N1, no flow control**, using a safe known peer/fixture and a recorded binary pattern/byte count. Transfer writer ownership explicitly among transports, collect memory/status every minute, and record bytes/drops, latency and any watchdog/reset. Repeat a separately recorded **five-minute 460800-baud** stress run with the same mix; historical drops at that load are not permission for new exhaustion or lease loss. These workloads do not reduce supported baud rates. Record any fixture limitation rather than inventing results. 7. Disconnect network clients and USB DTR, wait 60 seconds and capture cleanup memory/status. Compare free/largest-block recovery and lifetime minima with boot/full-load rows. Record SSH stack margin; explicitly retain other stack floors as pending until instrumentation is separately scoped. 8. Fill measured reserve/incremental budget cells with justified numbers, review failures and confirm 8D.0's runtime gate before 8D.1 (or record the user's explicit decision to proceed with that gate still pending). M1/M2 milestone pauses remain mandatory.