diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index ae9103e..ef01abc 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -154,6 +154,8 @@ NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi cre ## Typed Accounts settings (8D.10) +**8D.11 extension:** Authorized-key operations share the Accounts slot/dispatcher and canonical database key transactions with mutation-lock account ID/generation checks. A zero-wait per-account projection returns only key slot/type/SHA256 fingerprint. Independently optional admin/Origin/CSRF JSON POST `/api/settings/accounts/keys` reads this projection; existing operation POST admits bounded public-key text or key-delete/key-clear. Three stable slots may be sparse. Successful mutations target-revoke, including self; uncertain acknowledgement never triggers automatic replay. 24 handlers, unchanged socket/task/stack-size/queue budgets. Runtime stack margins remain pending. See `docs/phase8d11_implementation.md`; older no-8D.11 statements below are historical. + Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0-only bootstrap/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`. **Historical slice 1 architecture (superseded scope/counts, retained evidence):** diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index e243cf3..35e42aa 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -58,6 +58,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## Web and WebSocket serial +- **8D.11:** `web_account_settings.{c,h}` extends Accounts with fingerprint-only POST `/api/settings/accounts/keys` and key-add/key-delete/key-clear on the existing operation endpoint/dispatcher. `user_database.{c,h}` owns zero-wait target-checked snapshots and canonical conditional key mutations. `web_ui.c` handles confirmations, sparse stable indices and self-revocation uncertainty. 24 handlers, six sockets; no new task/stack/queue depth. Host-tested/build-verified, target pending. Contracts/tests/checklist: `docs/phase8d11_implementation.md`. + **Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients. - Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}` diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 878e518..9260127 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.11 partial target evidence (2026-09-08):** User supplied 60-second fresh-boot/full-mix telemetry; no functional/sign-off claim. Full mix eventually established (two SSH public-key sessions, SSH writer + USB/two web observers, both admins), but last browser admission again required retries. Three TLS errors `-0x0050` verified against installed mbedTLS as NET_CONN_RESET, not allocation failure; cause unknown. Loaded internal/DMA free 32,596/24,840 B, lifetime minima 5,468/532 B, largest 22,528 B; per-region minima are conservative/non-simultaneous. Settled free is similar to prior 8D.10, minima much lower; no causal regression attribution. SSH stack minimum-free 18,468 boot/16,276 loaded B. Web send failure/close 1, expired tickets 3; SSH handshake/auth failures zero; no broker drop counters. Exact memory/counters/evidence limits in `docs/phase8d11_implementation.md`. Previously accepted admission issue recurred, not fixed; investigate timing/socket occupancy/allocation correlation before capacity changes. Key algorithms/new UI provenance, mutation checklist, HTTPD/dispatcher margins, cleanup/soak and target sign-off remain pending. Documentation-only update; no source/build/device action. + +- **8D.11 implemented (2026-09-08), host-tested/build-verified; target sign-off pending:** Accounts now supports fingerprint listing and bounded Ed25519/P256 public-key import/delete/clear. Conditional database wrappers check target identity under the canonical lock; existing dispatcher/account slot and target-only revocation remain. Optional POST `/api/settings/accounts/keys` brings handler budget to 24, six sockets/tasks/stacks/queue depth unchanged. Sparse stable key slots are supported; review finding fixed and regressed. Parent canonical accounts, cookie accounts, UI, lifecycle (16), build and diff checks passed; final agent UI extension passes 68 groups plus CSP. Final production build 14.85 s, 96,076 B RAM / 1,703,685 B flash (+168/+9,448 vs 8D.10). Exact API, evidence attribution and pending target checklist: `docs/phase8d11_implementation.md`. No device/assets/commit action, reserve approval, full M3 claim or 8D.12 work. Supersedes the older wait-for-8D.11 instruction below, not accepted 8D.8–8D.10/M2. + - **8D.8–8D.10 implemented scope signed off by user (2026-09-08):** User provides settled boot/full-mix telemetry and reports thorough Serial parameter display/settings and user/account testing: “Implemented work can be signed off.” Accepts 8D.8, 8D.9 including UX refinement, and both implemented 8D.10 slices, superseding pending-signoff/target-blocker statements below without inventing individual checklist passes. Current source/handoff confirms credential/self workflows and generated route complete. Exact flashed revision/browser/durations/reboot/fault-injection details not separately supplied. Full mix at **230400 8N1 RTS/CTS, DTR active**: SSH writer8, USB observer9, web observers26/11, SSH admin and web admin, two cookie sessions; no broker drop counters supplied. Boot internal/DMA/PSRAM free **69,928/62,172/8,246,368 B**, loaded final **32,556/24,800/8,087,656 B**, loaded minima **19,228/11,472/8,065,444 B**, largest **23,552/23,552/7,995,392 B**. SSH minimum-free stack **18,476 boot / 16,284 loaded B**. One SSH handshake failure/broker revocation, one WebSocket send failure/close and one invalid login retained without diagnosis. All six loaded samples, counters, scope and evidence limits: `docs/phase8d10_implementation.md`. Numeric reserves, HTTPD/dispatcher stack margins, cleanup/soak evidence and earlier accepted admission issue remain followups, not reopening sign-off. Browser-shell restrictions/UART0 recovery and M2 stand; no full M3 claim. **Wait for separately requested 8D.11.** This update documentation-only, no new build/test/device/source/config/asset/commit action; preserve existing user `platformio.ini` edit. The following implementation entries predate target sign-off and retain historical build/test attribution; their target-pending status is superseded above. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index 5e87853..af07415 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -108,6 +108,8 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab ## Typed account selection is checked inside the database mutation lock +**8D.11:** Apply the same conditional target identity contract to authorized-key add/delete/clear, sharing canonical CLI validation/commit paths. Expose fingerprint metadata only through a zero-wait snapshot; never return stored key blobs. Treat key indices as stable, potentially sparse slots, not response-array positions. Listing uses protected JSON POST to reuse bounded target admission, not a new query parser. Public-key import is bounded to 384 decoded text bytes within the existing 768-byte body, with canonical blob/curve validation on the existing dispatcher. Target revocation/self uncertainty and browser-shell restrictions are unchanged. See `docs/phase8d11_implementation.md`. + **Current 8D.10 slice 2:** Extend conditional identity checks to password replacement; create uses canonical duplicate/capacity/commit policy. Keep generation separate from commit: the protected bodyless generated-value POST returns one transient value, performs no mutation and retains no retrieval state. Browser saved acknowledgement is context-bound UX, not delivery proof or server authorization. Queued credentials require a one-second periodic timer to cancel/wipe non-executing work at the 30-second deadline plus scheduling latency; execution copies then wipes shared inputs, with local wiping after admitted database work returns. Neither timer nor logout cancels admitted commits. Self password/role/delete uses immediate canonical target revocation, not deferred acknowledgement: 401/disconnect is uncertain and requires relogin/inspection before any explicit retry. Generation is independently optionally registered, preserving failure isolation and restart behavior at 23 handlers. No shell restriction change, secret result/history, new executor or 8D.11 work. Implementation is complete, host-tested/build-verified, not target accepted; timer runtime costs remain unmeasured. See contracts, build and attributed test evidence in `docs/phase8d10_implementation.md`. The following first-slice exclusions are historical and superseded by slice 2: diff --git a/docs/phase8d11_implementation.md b/docs/phase8d11_implementation.md new file mode 100644 index 0000000..6cacea3 --- /dev/null +++ b/docs/phase8d11_implementation.md @@ -0,0 +1,60 @@ +# Phase 8D.11 — SSH authorized keys + +## Status (2026-09-08) + +Implemented on user request; host-tested and firmware-build verified. Target validation/sign-off remains pending. Accepted 8D.8–8D.10 and M2 remain accepted; this is not M3 completion or authorization for 8D.12. + +## User-supplied target telemetry (2026-09-08) + +User supplies a fresh-boot sample at 60 seconds and a later full-client-mix sample. The last browser serial/admin connection again required several attempts. This records partial target evidence, not functional key-management acceptance or phase sign-off; exact flashed revision, browser, key algorithms, traffic duration and cleanup/soak results were not supplied. + +| Memory (bytes) | Boot free / minimum / largest | Loaded free / minimum / largest | +|---|---|---| +| Internal 8-bit | 69,584 / 58,076 / 31,744 | 32,596 / 5,468 / 22,528 | +| Internal DMA | 61,828 / 50,320 / 31,744 | 24,840 / 532 / 22,528 | +| PSRAM | 8,246,356 / 8,242,108 / 8,126,464 | 8,087,636 / 8,056,956 / 7,995,392 | + +Minima are conservative sums of per-region lifetime minima, not a simultaneous sample; internal/DMA capabilities overlap and must not be added. Low minima are a transient-pressure warning, not proof of allocation failure. Settled loaded free internal/DMA is comparable to the prior accepted 8D.10 sample (32,556 / 24,800), but lifetime minima are substantially lower (previously 19,228 / 11,472). Different connection attempts/workloads prevent attributing that difference to 8D.11. + +Boot: SSH/HTTPS started once without reported startup failures; all supplied network request/session counters zero; no broker clients, UART stopped, USB attached but host closed. mDNS announced with ESP_OK. Five accounts/two admins, no cookie sessions. SSH owner stack 20,480 bytes, minimum-free 18,468. + +Loaded: two successful SSH public-key sessions (admin console and user serial writer); two browser serial observers, USB observer and active browser admin. Broker writer 16, web observers 17/19, USB observer 10, all pending/events zero at the sample. UART running at 230400 8N1 RTS/CTS, DTR active, RX/TX queues empty; USB host open. Two cookie sessions; mDNS/SSH/HTTPS report operational, no transition/error. SSH minimum-free stack 16,276 bytes; HTTPD/dispatcher stack margins unreported. Public-key authentication works for these two sessions, but algorithms and whether keys were imported through the new UI are unspecified. + +Admission evidence: HTTPS handshake attempts logged from 197688 through 379338 ms, with three errors at 377948/378298/378658 ms: `mbedtls_ssl_handshake returned -0x0050`, TLS session creation and HTTPD accept failure. Installed mbedTLS `net_sockets.h` defines this as `MBEDTLS_ERR_NET_CONN_RESET`, not allocation failure. It does not establish why the connection reset or whether transient heap pressure caused delays. The previously accepted intermittent last-browser admission issue has recurred and remains unresolved. + +Loaded counters: SSH TCP connections/handshake success/auth attempts 2/2/2; handshake/auth/timeouts/capacity/IO failures zero. SSH broker connect 1, writer request/grant 1/1, revocations 1; admin admission 1/no failures, stream RX/accepted/rejected 7/7/0, TX 5,977, session revocations zero. Browser admin tickets issued/consumed 2/2, connected/disconnected 2/1, TX 272; rejection/capacity/protocol/backpressure/send/queue counters zero. Web requests 89 authenticated/zero auth failures, root 2/status 76/tickets 8/assets 2, response errors zero; tickets issued/consumed/expired 8/5/3. Serial WS connect/disconnect 5/3 with zero admission/service/broker failures; RX zero; TX 218 binary frames/21,200 bytes and 16 control frames/1,322 bytes; writer requests/grants/denials 5/1/4. One send failure/close, zero queue/protocol failures. Cookie login attempts 2, invalid/throttled/capacity/CSRF/logout zero. No broker drop-counter output supplied; zero pending queues is not proof of lossless traffic. Application counters do not account for every pre-HTTP TLS failure. + +Documentation-only evidence update: no new build, source/config edits, upload, erase, device commands or sign-off. Next diagnostic evidence should correlate connection attempts with heap/allocation failures and browser network timing/socket occupancy rather than assume heap exhaustion or increase socket capacity blindly. Key-workflow checklist and reserve approval remain open. + +## Scope and contracts + +- Accounts settings lists selected-account key types, stable slot indices and SHA256 fingerprints; imports OpenSSH Ed25519/P-256 public-key text and explicitly deletes one slot or clears all keys. +- `user_database.{c,h}` provides a zero-wait fingerprint-only snapshot and conditional key mutation wrappers. Username/account ID/auth generation are checked under the canonical database mutation lock. Existing CLI mutation, persistence, duplicate/capacity, curve validation and SSH authentication semantics are retained. +- `web_account_settings.{c,h}` admits strict JSON and uses the existing account-operation slot and administration dispatcher. Successful mutations best-effort revoke only the target's web/SSH sessions; currentness checks remain authoritative. Self changes can invalidate the response/result login: 401/disconnect proves neither success nor cancellation. Inspect after relogin before retrying. +- Optional **POST `/api/settings/accounts/keys`** accepts `{username,user_id,auth_generation}` and returns `{username,user_id,auth_generation,keys:[{index,type,fingerprint}]}`. This read uses POST to retain the existing bounded JSON/Origin/CSRF policy rather than introduce query parsing. Stale/absent selection returns 409; unavailable snapshot returns 503. No public-key blobs, verifier material or private keys are returned. +- Existing POST `/api/settings/account-operation` adds `key-add` with `public_key`, `key-delete` with `key_index` (0–2), and `key-clear`. All carry target identity. Request bodies remain **768 bytes/four receives**, public-key text at most **384 decoded bytes**, decoded SSH blob at most **128 bytes**, three keys per account. Envelope/base64/schema rejection occurs at admission; canonical blob/curve validation runs on the dispatcher. Results remain bounded, secret-free, replaceable and session-bound; no mutation auto-retry. +- UI confirms mutations, clears pasted text on submission/context changes, uses text-only rendering and fences stale navigation/session/target work. Completion uses existing bounded polling followed by account/key refresh. Stable key slots may be sparse: option values and deletion confirmation resolve by actual index, not array position. +- `web_server.c` registers the listing route independently through the allocation-safe optional POST helper. **24 handlers**, six sockets/no LRU; no new tasks, stack-size increases, queue-depth changes or broker lease changes. Larger operation/local structures still have runtime stack costs requiring target measurement. +- No private-key upload/export, SSH host-identity management, browser-shell restriction changes, UART0 recovery changes or generated asset regeneration. + +## Validation + +Parent integrated runs passed: + +- `python3 tests/admin_console_boundary/accounts.py`: canonical key validation/authorization, Ed25519/P256, malformed/off-curve/truncated keys, zero-wait snapshots, stale ID/generation/recreation, duplicate/capacity, sparse slots, failed persistence and CLI parity. Crypto host adapters use OpenSSL; this is not target SSH validation. +- `python3 tests/web_cookie_auth/run.py --accounts`: shared authentication policy and account/key route admission/execution tests. +- `python3 tests/web_ui_session/run.py`: initial integrated 64 groups and subsequent sparse-slot production fix, plus renderer/HTML/CSP checks. Final test-only extension independently passed **68 browser groups** (agent-run), including sparse `[1]`/`[0,2]`, deletion/automatic refresh, and invalid/duplicate indices. +- `python3 tests/web_admin_transport/server_lifecycle.py`: **16 groups**, including isolated optional-route failure and restart recovery (dependencies faked). +- `pio run`: final production build **PASS, 14.85 s**, **96,076 B RAM / 1,703,685 B flash**; **+168 B RAM / +9,448 B flash** versus recorded final 8D.10. The later four UI test groups change no firmware source. +- `git diff --check` passed for the integrated production change; the final test extension also passed its scoped check. + +Backend agent additionally reports passing console boundary `run.py` and cookie `--serial-settings`. Independent source review identified the sparse-slot UI assumption; corrected production selection/validation and added dedicated regressions. No other concrete review findings were reported. Host tests are not real network/concurrency/stack-margin proof. + +## Pending target checklist + +1. Import real Ed25519 and P-256 public keys, compare fingerprints with CLI/ssh-keygen, authenticate with corresponding private keys, and verify existing password authentication remains available. +2. Exercise maximum input, malformed/private-key text rejection, duplicates and full three-key capacity. Delete slot 0 while later slots survive; list/delete/clear sparse slots and re-add successfully. +3. Mutate another account through browser and CLI; verify target revocation, unrelated sessions retained, persisted keys after reboot, stale selection rejection and failed-operation recovery. +4. Exercise own-account key changes: immediate revocation and uncertain lost acknowledgement, relogin/inspect without automatic replay. Confirm last-key removal does not imply password removal. +5. Run full supported serial/admin mix, preserving one writer and isolated observers; record heap/internal-DMA largest blocks, HTTPD/dispatcher stack margins and cleanup/repeated-operation behavior. +6. Obtain user target sign-off separately. No upload, erase, hardware test, sanitizer run or commit was performed by this task. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 6a8490b..f45729a 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,5 +1,7 @@ # Phase 8D — Incremental web administration plan +**Latest implementation (2026-09-08):** User-requested **8D.11 implemented, host-tested/build-verified; target sign-off pending**. Accounts fingerprint listing and Ed25519/P256 import/delete/clear use canonical target-checked APIs and the existing dispatcher. Sparse-slot selection regression fixed; 24 handlers/six sockets, no new task/stack-size/queue expansion. Final build 96,076 B RAM / 1,703,685 B flash. [8D.11 record](phase8d11_implementation.md) contains API/bounds, test attribution and pending hardware checklist. Supersedes historical wait-for-8D.11 instructions below; 8D.8–8D.10/M2 remain accepted. No M3 completion or 8D.12 work. + **Latest target sign-off (2026-09-08):** User reports thorough Serial parameter display/settings and user/account testing, supplies settled boot/full-client-mix evidence, and explicitly says implemented work can be signed off. **Implemented 8D.8–8D.10 are accepted**, including 8D.9 UX and both 8D.10 slices. Supersedes target/signoff-pending statements below, not build/test evidence or restrictions. [8D.10 target acceptance record](phase8d10_implementation.md) contains all six loaded samples, client mix, counters and evidence limits. Full mix at 230400 baud confirms SSH sole writer + USB/two web observers with both admin routes. Final internal/DMA free 32,556/24,800 B, lifetime minima 19,228/11,472 B, largest 23,552 B; isolated failures retained without diagnosis. Exact revision/browser/durations/individual cases are unspecified; no leak-free-soak or reserve approval inferred. M2 stands; full M3/browser-shell parity is not claimed. **Next is 8D.11 only when separately requested; this sign-off authorizes no implementation.** **Current slice 2 completion (2026-09-08):** **8D.10 implementation is complete, host-tested/build-verified, not target accepted; target validation/full signoff remain pending.** Create/password/self workflows use bounded 768-byte admission and periodic credential cleanup (30-second deadline plus one-second timer/scheduling latency); admitted executing work is not cancelled. Protected generation is separate before commit, with no retained retrieval. Self revocation may prevent results: 401/disconnect is uncertain, never grounds for automatic retry. Browser-shell restrictions remain unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage, **23 handlers/six sockets**. Parent PASS canonical accounts/boundary, parser **294**, cookie accounts **9 plus shared**, serial-settings **10**, transport **25**/tickets **12**, store/serial and diff check. UI agent **57 plus CSP** and route agent lifecycle **15** pass; these are not claims of the parent's additional UI/lifecycle reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. Timer runtime costs and heap/stack margins remain unmeasured. [Current 8D.10 record](phase8d10_implementation.md) contains contracts/evidence/target checklist. No sanitizer validation, assets/device/commit/8D.11 work, M2 reopening, prior-phase signoff or reserve approval inferred. diff --git a/src/user_database.c b/src/user_database.c index 5136c3d..a201454 100644 --- a/src/user_database.c +++ b/src/user_database.c @@ -1091,6 +1091,44 @@ static bool target_matches_locked(const uint8_t *username, size_t length, s_database.users[index].auth_generation == expected->auth_generation; } +esp_err_t user_database_get_account_keys(const user_database_account_t *expected, + user_database_user_snapshot_t *snapshot) +{ + if (!snapshot) return ESP_ERR_INVALID_ARG; + memset(snapshot, 0, sizeof(*snapshot)); + if (!expected) return ESP_ERR_INVALID_ARG; + size_t length = strnlen(expected->username, sizeof(expected->username)); + if (!user_database_username_valid((const uint8_t *)expected->username, length)) + return ESP_ERR_INVALID_ARG; + if (!s_initialized || !s_mutex) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + esp_err_t error = ESP_ERR_NOT_FOUND; + if (target_matches_locked((const uint8_t *)expected->username, length, expected)) { + const stored_user_t *user = &s_database.users[find_user(&s_database, + (const uint8_t *)expected->username, length)]; + snapshot->active = true; + snapshot->user_id = user->user_id; + snapshot->auth_generation = user->auth_generation; + snapshot->role = (user_role_t)user->role; + snapshot->username_length = length; + memcpy(snapshot->username, user->username, length); + snapshot->public_key_count = user->key_count; + for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) { + const stored_key_t *key = &user->keys[i]; + if (!key->active) continue; + user_database_key_snapshot_t *out = &snapshot->public_keys[i]; + out->active = true; + out->index = (uint8_t)i; + out->key_type_length = key->type_length; + memcpy(out->key_type, key->type, key->type_length); + memcpy(out->sha256_fingerprint, key->fingerprint, sizeof(out->sha256_fingerprint)); + } + error = ESP_OK; + } + xSemaphoreGive(s_mutex); + return error; +} + static esp_err_t delete_user(const uint8_t *username, size_t username_length, const user_database_account_t *expected) { @@ -1245,11 +1283,11 @@ esp_err_t user_database_generate_password( return error; } -esp_err_t user_database_add_ssh_key( +static esp_err_t add_ssh_key( const uint8_t *username, size_t username_length, const uint8_t *key_type, size_t key_type_length, const uint8_t *key_blob, size_t key_blob_length, - uint8_t *key_index) + uint8_t *key_index, const user_database_account_t *expected) { if (!s_initialized || s_mutex == NULL || key_index == NULL || !user_database_key_valid(key_type, key_type_length, key_blob, key_blob_length)) { @@ -1257,7 +1295,8 @@ esp_err_t user_database_add_ssh_key( } xSemaphoreTake(s_mutex, portMAX_DELAY); int user_index; - esp_err_t error = mutate_user_begin(username, username_length, &user_index); + esp_err_t error = target_matches_locked(username, username_length, expected) + ? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND; if (error == ESP_OK) { stored_user_t *user = &s_candidate->users[user_index]; int free_index = -1; @@ -1308,9 +1347,9 @@ esp_err_t user_database_add_ssh_key( return error; } -esp_err_t user_database_remove_ssh_key(const uint8_t *username, - size_t username_length, - uint8_t key_index) +static esp_err_t remove_ssh_key(const uint8_t *username, + size_t username_length, uint8_t key_index, + const user_database_account_t *expected) { if (!s_initialized || s_mutex == NULL || key_index >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) { @@ -1318,7 +1357,8 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username, } xSemaphoreTake(s_mutex, portMAX_DELAY); int user_index; - esp_err_t error = mutate_user_begin(username, username_length, &user_index); + esp_err_t error = target_matches_locked(username, username_length, expected) + ? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND; if (error == ESP_OK) { stored_user_t *user = &s_candidate->users[user_index]; if (user->keys[key_index].active == 0U) { @@ -1339,15 +1379,17 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username, return error; } -esp_err_t user_database_clear_ssh_keys(const uint8_t *username, - size_t username_length) +static esp_err_t clear_ssh_keys(const uint8_t *username, + size_t username_length, + const user_database_account_t *expected) { if (!s_initialized || s_mutex == NULL) { return ESP_ERR_INVALID_STATE; } xSemaphoreTake(s_mutex, portMAX_DELAY); int user_index; - esp_err_t error = mutate_user_begin(username, username_length, &user_index); + esp_err_t error = target_matches_locked(username, username_length, expected) + ? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND; if (error == ESP_OK) { stored_user_t *user = &s_candidate->users[user_index]; if (user->key_count == 0U) { @@ -1367,3 +1409,47 @@ esp_err_t user_database_clear_ssh_keys(const uint8_t *username, xSemaphoreGive(s_mutex); return error; } + +esp_err_t user_database_add_ssh_key(const uint8_t *username, size_t length, + const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length, + uint8_t *index) +{ + return add_ssh_key(username, length, type, type_length, blob, blob_length, index, NULL); +} + +esp_err_t user_database_remove_ssh_key(const uint8_t *username, size_t length, uint8_t index) +{ + return remove_ssh_key(username, length, index, NULL); +} + +esp_err_t user_database_clear_ssh_keys(const uint8_t *username, size_t length) +{ + return clear_ssh_keys(username, length, NULL); +} + +static bool key_target_valid(const user_database_account_t *expected) +{ + return expected && user_database_username_valid((const uint8_t *)expected->username, + strnlen(expected->username, sizeof(expected->username))); +} + +esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected, + const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length, + uint8_t *index) +{ + if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG; + return add_ssh_key((const uint8_t *)expected->username, strlen(expected->username), + type, type_length, blob, blob_length, index, expected); +} + +esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected, uint8_t index) +{ + if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG; + return remove_ssh_key((const uint8_t *)expected->username, strlen(expected->username), index, expected); +} + +esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected) +{ + if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG; + return clear_ssh_keys((const uint8_t *)expected->username, strlen(expected->username), expected); +} diff --git a/src/user_database.h b/src/user_database.h index 1746eab..351d675 100644 --- a/src/user_database.h +++ b/src/user_database.h @@ -116,6 +116,16 @@ typedef struct { user_database_account_t users[USER_DATABASE_MAX_USERS]; } user_database_accounts_t; esp_err_t user_database_get_accounts(user_database_accounts_t *accounts); +/* Zero-wait, identity-conditional projection; fingerprints only, no key blobs. + * Output is cleared on failure; absent/stale identity returns NOT_FOUND. */ +esp_err_t user_database_get_account_keys(const user_database_account_t *expected, + user_database_user_snapshot_t *snapshot); +esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected, + const uint8_t *key_type, size_t key_type_length, + const uint8_t *key_blob, size_t key_blob_length, uint8_t *key_index); +esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected, + uint8_t key_index); +esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected); /* Compare target identity under the mutation lock, before candidate/commit. * ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */ esp_err_t user_database_delete_current(const user_database_account_t *expected); diff --git a/src/web_account_settings.c b/src/web_account_settings.c index ad88f16..dddea80 100644 --- a/src/web_account_settings.c +++ b/src/web_account_settings.c @@ -8,6 +8,7 @@ #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "secure_random.h" +#include "mbedtls/base64.h" #include "ssh_transport.h" #include "web_cookie_auth.h" #include "web_auth_parse.h" @@ -16,8 +17,9 @@ enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL }; static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"}; -typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD } account_action_t; -static const char *const s_actions[] = {"role", "delete", "create", "password"}; +typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD, + ACTION_KEY_ADD, ACTION_KEY_DELETE, ACTION_KEY_CLEAR } account_action_t; +static const char *const s_actions[] = {"role", "delete", "create", "password", "key-add", "key-delete", "key-clear"}; typedef struct { uint32_t id; web_session_id_t session; @@ -30,6 +32,10 @@ typedef struct { bool executing; uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U]; size_t password_length; + char key_type[USER_DATABASE_SSH_KEY_TYPE_CAPACITY + 1U]; + uint8_t key_blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY]; + size_t key_blob_length; + uint8_t key_index; } account_operation_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static account_operation_t s_operation; @@ -48,6 +54,10 @@ static void wipe_input(account_operation_t *operation) secure_wipe(&operation->target, sizeof(operation->target)); secure_wipe(operation->password, sizeof(operation->password)); operation->password_length = 0; + secure_wipe(operation->key_type, sizeof(operation->key_type)); + secure_wipe(operation->key_blob, sizeof(operation->key_blob)); + operation->key_blob_length = 0; + operation->key_index = 0; } static void expire_secret(void *unused) @@ -80,37 +90,77 @@ static bool ensure_secret_timer(void) return true; } -/* Exact flat schemas. Only password accepts JSON escapes; canonical database - * policy validates the decoded bytes. No coercion/unknown/duplicate fields. */ -static bool parse(const char *body, size_t length, account_operation_t *operation) +/* OpenSSH text envelope only. The canonical database parser validates the SSH + * blob (including the P256 point) on the dispatcher, not the HTTPD stack. */ +static bool parse_public_key(const char *text, size_t length, account_operation_t *operation) { - const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password"}; + size_t type_length = 0; + while (type_length < length && text[type_length] != ' ' && text[type_length] != '\t') ++type_length; + if (!((type_length == 11 && !memcmp(text, "ssh-ed25519", 11)) || + (type_length == 19 && !memcmp(text, "ecdsa-sha2-nistp256", 19)))) return false; + size_t start = type_length; + while (start < length && (text[start] == ' ' || text[start] == '\t')) ++start; + size_t end = start; + while (end < length && text[end] != ' ' && text[end] != '\t') ++end; + size_t encoded_length = end - start; + if (!encoded_length || encoded_length > 172 || encoded_length % 4) return false; + for (size_t i = start; i < end; ++i) { + unsigned char c = (unsigned char)text[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '+' || c == '/' || + (c == '=' && i >= end - 2))) return false; + } + for (size_t i = end; i < length; ++i) + if ((text[i] < ' ' || text[i] > '~') && text[i] != '\t') return false; + if (mbedtls_base64_decode(operation->key_blob, sizeof(operation->key_blob), + &operation->key_blob_length, (const uint8_t *)text + start, encoded_length) != 0) return false; + /* Round-trip rejects noncanonical padding and unused base64 bits. */ + unsigned char encoded[173]; + size_t written = 0; + if (mbedtls_base64_encode(encoded, sizeof(encoded), &written, operation->key_blob, + operation->key_blob_length) != 0 || written != encoded_length || + memcmp(encoded, text + start, written)) return false; + memcpy(operation->key_type, text, type_length); + return true; +} + +/* Exact flat schemas. Password/public_key accept JSON escapes; canonical database + * policy validates the decoded bytes. No coercion/unknown/duplicate fields. */ +static bool parse_request(const char *body, size_t length, account_operation_t *operation, bool keys_only) +{ + const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password", "public_key", "key_index"}; unsigned seen = 0; size_t pos = 0; #define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos #define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) TAKE('{'); - for (unsigned field = 0; field < 6; ++field) { + for (unsigned field = 0; field < 8; ++field) { if (field) { TAKE(','); } TAKE('"'); size_t start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned key = 0; - for (; key < 6; ++key) + for (; key < 8; ++key) if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; - if (key == 6 || (seen & (1U << key))) return false; + if (key == 8 || (seen & (1U << key))) return false; ++pos; TAKE(':'); SPACE(); uint32_t number = 0; char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0}; - if (key == 2 || key == 3) { + if (key == 2 || key == 3 || key == 7) { start = pos; while (pos < length && body[pos] >= '0' && body[pos] <= '9') { unsigned digit = (unsigned)(body[pos++] - '0'); if (number > (UINT32_MAX - digit) / 10U) return false; number = number * 10U + digit; } - if (!number || pos == start || (pos - start > 1 && body[start] == '0')) return false; + if ((!number && key != 7) || pos == start || (pos - start > 1 && body[start] == '0')) return false; + if (key == 7 && number >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) return false; + } else if (key == 6) { + char text[385] = {0}; + size_t text_length = 0; + if (!web_auth_parse_json_string(body, length, &pos, (uint8_t *)text, + sizeof(text), &text_length) || !parse_public_key(text, text_length, operation)) return false; } else if (key == 5) { if (!web_auth_parse_json_string(body, length, &pos, operation->password, sizeof(operation->password), &operation->password_length) || @@ -140,6 +190,7 @@ static bool parse(const char *body, size_t length, account_operation_t *operatio case 2: operation->target.user_id = number; break; case 3: operation->target.auth_generation = number; break; case 4: if (!user_role_parse(value, &operation->role)) return false; break; + case 7: operation->key_index = (uint8_t)number; break; } seen |= 1U << key; SPACE(); @@ -148,8 +199,13 @@ static bool parse(const char *body, size_t length, account_operation_t *operatio TAKE('}'); SPACE(); #undef TAKE #undef SPACE - const unsigned schemas[] = {31U, 15U, 51U, 47U}; - return pos == length && seen == schemas[operation->action]; + const unsigned schemas[] = {31U, 15U, 51U, 47U, 79U, 143U, 15U}; + return pos == length && seen == (keys_only ? 14U : schemas[operation->action]); +} + +static bool parse(const char *body, size_t length, account_operation_t *operation) +{ + return parse_request(body, length, operation, false); } void web_account_settings_execute(uint32_t id) @@ -178,6 +234,17 @@ void web_account_settings_execute(uint32_t id) error = user_database_create((const uint8_t *)operation.target.username, strlen(operation.target.username), operation.role, operation.password, operation.password_length); break; + case ACTION_KEY_ADD: + error = user_database_add_ssh_key_current(&operation.target, + (const uint8_t *)operation.key_type, strlen(operation.key_type), + operation.key_blob, operation.key_blob_length, &operation.key_index); + break; + case ACTION_KEY_DELETE: + error = user_database_remove_ssh_key_current(&operation.target, operation.key_index); + break; + case ACTION_KEY_CLEAR: + error = user_database_clear_ssh_keys_current(&operation.target); + break; case ACTION_PASSWORD: error = user_database_set_password_current(&operation.target, operation.password, operation.password_length); break; @@ -185,8 +252,10 @@ void web_account_settings_execute(uint32_t id) secure_wipe(operation.password, sizeof(operation.password)); operation.password_length = 0; state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE : - error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE : PROTECTED) : - error == ESP_ERR_NO_MEM && operation.action == ACTION_CREATE ? FULL : FAILED; + error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE : + operation.action <= ACTION_PASSWORD ? PROTECTED : FAILED) : + error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY && operation.action == ACTION_KEY_ADD ? DUPLICATE : + error == ESP_ERR_NO_MEM && (operation.action == ACTION_CREATE || operation.action == ACTION_KEY_ADD) ? FULL : FAILED; if (error == ESP_OK && operation.action != ACTION_CREATE) { size_t length = strlen(operation.target.username); (void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length); @@ -237,6 +306,80 @@ static esp_err_t list_accounts(httpd_req_t *request) return respond(request, "200 OK", body); } +static bool read_request(httpd_req_t *request, account_operation_t *operation, bool keys_only) +{ + char type[40] = {0}, body[768]; + size_t received = 0; + bool valid = request->content_len && request->content_len <= sizeof(body) && + httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && + (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); + for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { + int count = httpd_req_recv(request, body + received, request->content_len - received); + if (count <= 0 || (size_t)count > request->content_len - received) valid = false; + else received += (size_t)count; + } + valid = valid && received == request->content_len && + (keys_only ? parse_request(body, received, operation, true) : parse(body, received, operation)); + secure_wipe(body, sizeof(body)); + return valid; +} + +esp_err_t web_account_keys_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + account_operation_t operation = {0}; + user_database_user_snapshot_t snapshot = {0}; + bool allowed = false; + esp_err_t error = web_cookie_auth_require_json(request, 768, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + goto done; + } + if (strcmp(request->uri, "/api/settings/accounts/keys") || !read_request(request, &operation, true)) { + error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}"); + goto done; + } + error = user_database_get_account_keys(&operation.target, &snapshot); + if (error != ESP_OK) { + error = error == ESP_ERR_NOT_FOUND ? respond(request, "409 Conflict", "{\"error\":\"stale\"}") : + respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}"); + goto done; + } + char body[512]; + int written = snprintf(body, sizeof(body), + "{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"keys\":[", + snapshot.username, snapshot.user_id, snapshot.auth_generation); + if (written < 0 || (size_t)written >= sizeof(body)) { error = ESP_FAIL; goto done; } + size_t used = (size_t)written; + bool comma = false; + for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) { + const user_database_key_snapshot_t *key = &snapshot.public_keys[i]; + if (!key->active) continue; + unsigned char fingerprint[45]; + size_t length = 0; + if (mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &length, + key->sha256_fingerprint, sizeof(key->sha256_fingerprint)) != 0 || length != 44) { + error = ESP_FAIL; goto done; + } + fingerprint[43] = 0; /* OpenSSH SHA256 fingerprints omit base64 padding. */ + written = snprintf(body + used, sizeof(body) - used, + "%s{\"index\":%u,\"type\":\"%s\",\"fingerprint\":\"SHA256:%s\"}", + comma ? "," : "", key->index, key->key_type, (const char *)fingerprint); + if (written < 0 || (size_t)written >= sizeof(body) - used) { error = ESP_FAIL; goto done; } + used += (size_t)written; + comma = true; + } + if (used + 3 > sizeof(body)) { error = ESP_FAIL; goto done; } + memcpy(body + used, "]}", 3); + error = respond(request, "200 OK", body); +done: + secure_wipe(&operation, sizeof(operation)); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} + esp_err_t web_account_generate_password_handler(httpd_req_t *request) { web_session_view_t view = {0}; @@ -290,19 +433,7 @@ esp_err_t web_account_settings_handler(httpd_req_t *request) goto done; } if (mutation) { - char type[40] = {0}, body[768]; - size_t received = 0; - bool valid = request->content_len && request->content_len <= sizeof(body) && - httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && - (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); - for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { - int count = httpd_req_recv(request, body + received, request->content_len - received); - if (count <= 0 || (size_t)count > request->content_len - received) valid = false; - else received += (size_t)count; - } - valid = valid && received == request->content_len && parse(body, received, &operation); - secure_wipe(body, sizeof(body)); - if (!valid) { + if (!read_request(request, &operation, false)) { wipe_input(&operation); error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}"); goto done; diff --git a/src/web_account_settings.h b/src/web_account_settings.h index fd3efe1..0863bbc 100644 --- a/src/web_account_settings.h +++ b/src/web_account_settings.h @@ -7,6 +7,21 @@ * results are replaceable, not durable history or an idempotent retry API. */ esp_err_t web_account_settings_handler(httpd_req_t *request); void web_account_settings_execute(uint32_t id); +/* POST /api/settings/accounts/keys: admin cookie + Origin/CSRF, JSON exactly + * {username,user_id,auth_generation}. Read-only zero-wait snapshot, 512-byte + * response bound: {username,user_id,auth_generation,keys:[{index,type,fingerprint}]}. + * Fingerprints are OpenSSH SHA256: base64 without padding, never key blobs. + * Stale/absent target: 409 {error:"stale"}; busy DB: 503 accounts_unavailable. + * Register independently as an optional POST route. + * + * Existing account-operation POST adds key-add (+public_key, OpenSSH text <=384 + * decoded bytes), key-delete (+key_index integer 0..2), key-clear. All require + * username/user_id/auth_generation. Exact schemas, <=768 body bytes/4 receives. + * Text/base64 errors: 400; canonical SSH blob/curve validation runs on dispatcher + * (failed result). Duplicate/full/stale use existing named result states. + * Success target-revokes immediately, including self; lost response/401 remains + * uncertain, never proof of cancellation. No automatic mutation retries. */ +esp_err_t web_account_keys_handler(httpd_req_t *request); /* POST /api/settings/accounts/generate-password; bodyless admin cookie + * Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */ esp_err_t web_account_generate_password_handler(httpd_req_t *request); diff --git a/src/web_server.c b/src/web_server.c index fbfa159..12a2b6f 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -398,6 +398,10 @@ static const httpd_uri_t s_account_operation_get_uri = { static const httpd_uri_t s_account_operation_post_uri = { .uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler, }; +static const httpd_uri_t s_account_keys_uri = { + .uri = "/api/settings/accounts/keys", .method = HTTP_POST, + .handler = web_account_keys_handler, +}; static const httpd_uri_t s_account_generate_password_uri = { .uri = "/api/settings/accounts/generate-password", .method = HTTP_POST, .handler = web_account_generate_password_handler, @@ -591,7 +595,7 @@ esp_err_t web_server_start(void) config.httpd.max_open_sockets = 6; config.httpd.max_uri_handlers = sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + - sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 9U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 10U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -647,6 +651,7 @@ esp_err_t web_server_start(void) web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET); (void)web_httpd_register_optional(server, &s_account_generate_password_uri); + (void)web_httpd_register_optional(server, &s_account_keys_uri); } if (error != ESP_OK) { web_cookie_auth_stop(); diff --git a/src/web_ui.c b/src/web_ui.c index 776e8e7..a7ec56e 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -230,6 +230,13 @@ static const char s_index_html[] = "
" "" "
" + "

Authorized SSH public keys

Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.

" + "" + "

"
+    ""
+    ""
+    "
" + "
" "
" "" "" @@ -418,10 +425,36 @@ static const char s_app_js[] = " }\n" "}\n" "let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n" + "let keysAbort = null, accountKeys = [], keysIdentity = '';\n" + "function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n" + "function clearAccountKeys() {\n" + " if (keysAbort) keysAbort.abort(); keysAbort = null; accountKeys = []; keysIdentity = '';\n" + " element('account-public-key').value = ''; element('account-keys-list').textContent = ''; element('account-keys-detail').textContent = 'Read keys for the selected account before deleting or clearing.';\n" + " for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n" + "}\n" + "async function refreshAccountKeys() {\n" + " if (!accountsLive() || accountsAbort || accountPending || keysAbort || !keyIdentity()) return;\n" + " clearAccountSecret(); clearAccountKeys();\n" + " const identity = keyIdentity(), t = accounts[Number(element('account-target').value)], controller = new AbortController(), generation = workGeneration; keysAbort = controller; accountButtons();\n" + " const current = () => keysAbort === controller && identity === keyIdentity() && accountsLive();\n" + " element('account-keys-detail').textContent = 'Reading selected account keys...';\n" + " try {\n" + " if (!await loadSession(generation, controller.signal, false) || !current()) return;\n" + " const {payload: p} = await api('/api/settings/accounts/keys', generation, {method: 'POST', body: JSON.stringify({username:t.username,user_id:t.user_id,auth_generation:t.auth_generation}), signal:controller.signal, limit:768, current});\n" + " if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n" + " if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n" + " accountKeys = p.keys; keysIdentity = identity;\n" + " element('account-keys-list').textContent = accountKeys.map(k => k.index + ': ' + k.type + ' ' + k.fingerprint).join('\\n');\n" + " for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n" + " element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n" + " } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n" + " finally { if (keysAbort === controller) { keysAbort = null; accountButtons(); } }\n" + "}\n" "let secretEpoch = 0, secretAbort = null, secretTimer = null, generatedPassword = '', generatedContext = '', savedContext = '', secretExpires = 0;\n" "function secretContext() { const t = accounts[Number(element('account-target').value)]; return JSON.stringify([element('account-purpose').value, element('account-username').value, element('account-create-role').value, t?.username, t?.user_id, t?.auth_generation]); }\n" "function invalidateSecretRequest() { ++secretEpoch; if (secretAbort) secretAbort.abort(); secretAbort = null; savedContext = ''; element('account-password-saved').checked = false; }\n" "function clearAccountSecret() {\n" + " element('account-public-key').value = '';\n" " invalidateSecretRequest(); window.clearTimeout(secretTimer); secretTimer = null; secretExpires = 0; generatedPassword = generatedContext = '';\n" " for (const id of ['account-password','account-password-confirm','account-generated']) element(id).value = '';\n" " element('account-generated-panel').hidden = true; element('account-secret-detail').textContent = '';\n" @@ -444,8 +477,10 @@ static const char s_app_js[] = "element('account-purpose').value = 'create'; element('account-create-role').value = 'user';\n" "function accountsLive() { return selected === 'settings' && settingsDomain === 'accounts' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n" "function accountButtons() {\n" - " const busy = !!accountsAbort, target = accounts[Number(element('account-target').value)];\n" + " const busy = !!accountsAbort || !!keysAbort, target = accounts[Number(element('account-target').value)];\n" " const blocked = busy || accountPending || !target;\n" + " element('account-keys-refresh').disabled = element('account-key-add').disabled = element('account-public-key').disabled = blocked;\n" + " element('account-key-delete').disabled = element('account-key-clear').disabled = element('account-key-index').disabled = blocked || !accountKeys.length || keysIdentity !== keyIdentity();\n" " element('account-delete').disabled = element('account-change-role').disabled = blocked;\n" " element('account-target').disabled = element('account-role').disabled = busy || accountPending || !accounts.length;\n" " element('account-result').disabled = element('refresh-accounts').disabled = busy;\n" @@ -456,7 +491,7 @@ static const char s_app_js[] = " for (const id of ['account-purpose','account-username','account-create-role','account-password','account-password-confirm','account-password-saved']) element(id).disabled = busy || accountPending;\n" "}\n" "function clearAccounts() {\n" - " clearAccountSecret();\n" + " clearAccountSecret(); clearAccountKeys();\n" " if (accountsAbort) accountsAbort.abort();\n" " accountsAbort = null; accounts = []; element('accounts-list').textContent = '';\n" " for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = ''; option.hidden = option.disabled = true; }\n" @@ -472,7 +507,8 @@ static const char s_app_js[] = "}\n" "async function refreshAccounts() {\n" " if (!accountsLive() || accountsAbort) return;\n" - " clearAccountSecret();\n" + " clearAccountSecret(); clearAccountKeys();\n" + " const previous = accounts[Number(element('account-target').value)];\n" " const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n" " const current = () => accountsAbort === controller && accountsLive();\n" " element('accounts-detail').textContent = 'Reading accounts; previous list may be stale.';\n" @@ -486,13 +522,15 @@ static const char s_app_js[] = " clearAccountSecret(); accounts = payload.users;\n" " element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n" " for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n" - " element('account-target').value = '0'; element('account-role').value = accounts[0]?.role || 'user';\n" + " const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n" + " element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n" " element('accounts-detail').textContent = accountPending ? 'List may be stale while operation outcome is pending or unknown.' : 'Account list refreshed. Select an account before changing it.';\n" + " return true;\n" " } catch (error) { if (live(generation) && current()) element('accounts-detail').textContent = (error.status ? error.message : 'Account list unavailable or invalid.') + ' List stale. Refresh to retry.'; }\n" " finally { if (current()) { accountsAbort = null; accountButtons(); } }\n" "}\n" "async function accountOperation(action) {\n" - " if (!accountsLive() || accountsAbort || (action && accountPending)) return;\n" + " if (!accountsLive() || accountsAbort || keysAbort || (action && accountPending)) return;\n" " let body;\n" " if (action) {\n" " const target = accounts[Number(element('account-target').value)], role = element('account-role').value;\n" @@ -508,6 +546,12 @@ static const char s_app_js[] = " request = action === 'create' ? {action, username, role: initialRole, password} : {action, username, user_id: target.user_id, auth_generation: target.auth_generation, password};\n" " body = JSON.stringify(request);\n" " } finally { clearAccountSecret(); if (request) request.password = ''; request = null; }\n" + " } else if (['key-add','key-delete','key-clear'].includes(action)) {\n" + " let publicKey = element('account-public-key').value; const index = Number(element('account-key-index').value);\n" + " const valid = target && (action === 'key-add' ? encoder.encode(publicKey).length <= 384 && /^(?:ssh-ed25519|ecdsa-sha2-nistp256) [A-Za-z0-9+/]+={0,2}(?: [^\\r\\n\\x00]*)?$/.test(publicKey) : keysIdentity === keyIdentity() && accountKeys.length && (action !== 'key-delete' || Number.isInteger(index) && accountKeys.some(k => k.index === index)));\n" + " clearAccountSecret();\n" + " if (!valid) { publicKey = ''; element('account-operation-detail').textContent = 'Not submitted. Paste one OpenSSH Ed25519 or ECDSA P-256 public key within 384 UTF-8 bytes, or read current keys and select a valid index.'; return; }\n" + " body = JSON.stringify({action,username:target.username,user_id:target.user_id,auth_generation:target.auth_generation,...(action === 'key-add' ? {public_key:publicKey} : action === 'key-delete' ? {key_index:index} : {})}); publicKey = '';\n" " } else {\n" " clearAccountSecret(); if (!target || !['role','delete'].includes(action) || !['user','admin'].includes(role)) return;\n" " body = JSON.stringify({action, username: target.username, user_id: target.user_id, auth_generation: target.auth_generation, ...(action === 'role' ? {role} : {})});\n" @@ -515,19 +559,20 @@ static const char s_app_js[] = " if (encoder.encode(body).length > 768) { body = undefined; return; }\n" " const name = action === 'create' ? element('account-username').value : target.username;\n" " const warning = name === sessionIdentity.username ? ' ALL this account’s web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n" - " if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n" + " if (!window.confirm((action === 'key-add' ? 'Import public key for ' : action === 'key-delete' ? 'Delete key ' + accountKeys.find(k => k.index === Number(element('account-key-index').value)).fingerprint + ' for ' : action === 'key-clear' ? 'Clear ALL authorized keys for ' : action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n" " }\n" " const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n" " const current = () => accountsAbort === controller && accountsLive();\n" " controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n" - " const detail = element('account-operation-detail'); let deadline, refresh = false, until = Infinity;\n" + " const detail = element('account-operation-detail'); let deadline, refresh = false, refreshKeys = false, until = Infinity;\n" + " clearAccountKeys();\n" " detail.textContent = accountWarning + (action ? 'Submitting once...' : 'Reading latest result...');\n" " element('accounts-detail').textContent = 'List may be stale until operation completes and refresh succeeds.';\n" " const read = async (method, requestBody) => {\n" " const response = api('/api/settings/account-operation', generation, {method, body: requestBody, signal: controller.signal, limit: 96, current: () => current() && performance.now() < until}); requestBody = undefined;\n" " const {payload: result} = await response;\n" " if (performance.now() >= until) throw new Error('Check deadline');\n" - " if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password'].includes(result.action) ||\n" + " if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password','key-add','key-delete','key-clear'].includes(result.action) ||\n" " !['idle','pending','ok','failed','cancelled','stale','protected','duplicate','full'].includes(result.state) || ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n" " (method === 'POST' && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation result');\n" " if (method === 'POST') accountWarning = '';\n" @@ -535,6 +580,8 @@ static const char s_app_js[] = " else if (accountId && result.id !== accountId) accountWarning = 'Previous result replaced or unavailable; outcome unknown. ';\n" " accountId = result.id; accountPending = result.state === 'pending'; accountAwaitingAck = false;\n" " const messages = {duplicate: 'Username already exists. Refresh before retrying.', full: 'Account capacity full. Inspect accounts before retrying.', idle: 'No retained result. Inspect accounts before retrying.', pending: 'Queued or executing...', ok: 'Account change completed and saved.', failed: 'Operation failed. Inspect accounts before retrying.', cancelled: 'Cancelled before execution: login or queue deadline stale.', stale: 'Account changed or was replaced. Refresh and select it again.', protected: 'Account is protected (including the final administrator), or database unavailable.'};\n" + " refreshKeys = result.action.startsWith('key-');\n" + " if (refreshKeys) { messages.duplicate = 'Public key already authorized. Inspect refreshed keys before retrying.'; messages.full = 'Authorized key capacity full (three). Inspect refreshed keys before retrying.'; }\n" " detail.textContent = accountWarning + result.action + ': ' + messages[result.state];\n" " return result.state;\n" " };\n" @@ -560,7 +607,7 @@ static const char s_app_js[] = " if (state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n" " refresh = state !== 'pending' && state !== 'idle';\n" " } catch (error) { if (live(generation) && current()) detail.textContent = accountWarning + (error.status ? error.message : 'Outcome unknown.') + ' Use Check Result and Refresh before an explicit retry. No automatic retry.'; }\n" - " finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh) await refreshAccounts(); } }\n" + " finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh && await refreshAccounts() && refreshKeys && live(generation)) await refreshAccountKeys(); } }\n" "}\n" "element('settings-serial').addEventListener('click', () => selectSettingsDomain('serial'));\n" "element('settings-accounts').addEventListener('click', () => selectSettingsDomain('accounts'));\n" @@ -573,7 +620,10 @@ static const char s_app_js[] = "element('account-password-saved').addEventListener('change', () => { savedContext = element('account-password-saved').checked && generatedPassword && element('account-password').value === generatedPassword && generatedContext === secretContext() && performance.now() < secretExpires ? generatedContext : ''; });\n" "for (const id of ['account-password','account-password-confirm']) element(id).addEventListener('input', () => { invalidateSecretRequest(); accountButtons(); });\n" "for (const id of ['account-purpose','account-username','account-create-role','account-role']) element(id).addEventListener(id === 'account-username' ? 'input' : 'change', () => { clearAccountSecret(); accountButtons(); });\n" - "element('account-target').addEventListener('change', () => { clearAccountSecret(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n" + "element('account-keys-refresh').addEventListener('click', refreshAccountKeys);\n" + "for (const action of ['key-add','key-delete','key-clear']) element('account-' + action).addEventListener('click', () => accountOperation(action));\n" + "element('account-key-index').addEventListener('change', clearAccountSecret);\n" + "element('account-target').addEventListener('change', () => { clearAccountSecret(); clearAccountKeys(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n" "let accountRole = 'user', selected = 'serial';\n" "let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n" "let adminGeneration = 0, adminTimer = null;\n" diff --git a/tests/admin_console_boundary/account_keys.c b/tests/admin_console_boundary/account_keys.c new file mode 100644 index 0000000..203bde7 --- /dev/null +++ b/tests/admin_console_boundary/account_keys.c @@ -0,0 +1,126 @@ +/* Included in accounts.py's canonical DB/console transaction harness. + * Production SSH parsing, with OpenSSL-backed curve/SHA adapters, not mbedTLS. */ +static user_database_account_t key_target(void) +{ + user_database_accounts_t accounts; + assert(user_database_get_accounts(&accounts)==ESP_OK); + return accounts.users[1]; +} +static size_t ssh_string(uint8_t *out, const void *value, size_t length) +{ + out[0]=out[1]=out[2]=0; out[3]=(uint8_t)length; + memcpy(out+4,value,length); return length+4; +} +static void stale_keys(const user_database_account_t *target, const uint8_t *blob, size_t length) +{ + stored_database_t before=s_database; + unsigned saved=commits; + uint8_t index=0; + user_database_user_snapshot_t snapshot; + assert(user_database_add_ssh_key_current(target,s_ed25519_type,11,blob,length,&index)==ESP_ERR_NOT_FOUND); + assert(user_database_remove_ssh_key_current(target,0)==ESP_ERR_NOT_FOUND); + assert(user_database_clear_ssh_keys_current(target)==ESP_ERR_NOT_FOUND); + memset(&snapshot,0xff,sizeof(snapshot)); + assert(user_database_get_account_keys(target,&snapshot)==ESP_ERR_NOT_FOUND); + assert(all_zero(&snapshot,sizeof(snapshot)) && commits==saved); unchanged(&before); +} +static void typed_key_tests(void) +{ + reset(); + uint8_t ed[128]={0}, p256[128]={0}, point[65], public[32]={1}; + size_t en=ssh_string(ed,s_ed25519_type,11); en+=ssh_string(ed+en,public,32); + EC_GROUP *group=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); + assert(group && EC_POINT_point2oct(group,EC_GROUP_get0_generator(group),POINT_CONVERSION_UNCOMPRESSED,point,sizeof(point),NULL)==65); + EC_GROUP_free(group); + size_t pn=ssh_string(p256,s_ecdsa_type,19); + pn+=ssh_string(p256+pn,s_ecdsa_curve,8); pn+=ssh_string(p256+pn,point,65); + assert(user_database_key_valid(s_ed25519_type,11,ed,en)); + assert(user_database_key_valid(s_ecdsa_type,19,p256,pn)); + for (size_t n=0;n +#include +#include +typedef EC_GROUP *mbedtls_ecp_group; +typedef struct { EC_POINT *point; } mbedtls_ecp_point; +#define MBEDTLS_ECP_DP_SECP256R1 1 +static void mbedtls_ecp_group_init(mbedtls_ecp_group *g) { *g=NULL; } +static void mbedtls_ecp_point_init(mbedtls_ecp_point *p) { p->point=NULL; } +static int mbedtls_ecp_group_load(mbedtls_ecp_group *g, int id) { + assert(id==1); *g=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); return *g ? 0 : -1; +} +static int mbedtls_ecp_point_read_binary(mbedtls_ecp_group *g, mbedtls_ecp_point *p, const uint8_t *b, size_t n) { + p->point=EC_POINT_new(*g); return p->point && EC_POINT_oct2point(*g,p->point,b,n,NULL)==1 ? 0 : -1; +} +static int mbedtls_ecp_check_pubkey(mbedtls_ecp_group *g, mbedtls_ecp_point *p) { + return EC_POINT_is_at_infinity(*g,p->point)==0 && EC_POINT_is_on_curve(*g,p->point,NULL)==1 ? 0 : -1; +} +static void mbedtls_ecp_point_free(mbedtls_ecp_point *p) { EC_POINT_free(p->point); } +static void mbedtls_ecp_group_free(mbedtls_ecp_group *g) { EC_GROUP_free(*g); } static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) { - (void)p; (void)n; (void)h; (void)mode; assert(!"keys outside slice"); return -1; + assert(mode==0); return SHA256(p,n,h) ? 0 : -1; } ''' header = strip_includes((ROOT / "src/user_database.h").read_text()) state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")] fakes = r''' -bool user_database_key_valid(const uint8_t *t, size_t tn, const uint8_t *b, size_t bn) { - (void)t; (void)tn; (void)b; (void)bn; assert(!"keys outside slice"); return false; -} + static stored_database_t candidate_storage; static user_principal_t actor; static bool admin_ssh_console_dispatch_is_remote(void) { return remote; } @@ -133,16 +150,11 @@ esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r esp_err_t user_database_generate_password(const uint8_t *u,size_t n,user_database_generated_password_t *p) { (void)u; (void)n; (void)p; assert(!"generated credential"); return ESP_FAIL; } -esp_err_t user_database_remove_ssh_key(const uint8_t *u,size_t n,uint8_t i) { - (void)u; (void)n; (void)i; assert(!"key mutation"); return ESP_FAIL; -} -esp_err_t user_database_clear_ssh_keys(const uint8_t *u,size_t n) { - (void)u; (void)n; assert(!"key mutation"); return ESP_FAIL; -} + size_t esp_console_split_argv(char *, char **, size_t); ''' db_names = ["constant_time_equal", "all_zero", "user_database_username_valid", - "user_database_password_valid", + "user_database_password_valid", "read_ssh_string", "user_database_key_valid", "user_role_to_string", "user_role_parse", "set_record_password", "find_user", "find_free_user", "stored_keys_equal", "validate_database", "recount", "next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user", @@ -151,7 +163,11 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid", "user_database_delete", "user_database_set_role", "user_database_get_accounts", "user_database_delete_current", "user_database_set_role_current", "set_password", "user_database_set_password", "user_database_set_password_current", - "user_database_generate_password_value"] + "user_database_generate_password_value", + "user_database_get_account_keys", "add_ssh_key", "remove_ssh_key", "clear_ssh_keys", + "user_database_add_ssh_key", "user_database_remove_ssh_key", "user_database_clear_ssh_keys", + "key_target_valid", "user_database_add_ssh_key_current", "user_database_remove_ssh_key_current", + "user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key"] console_names = ["print_usage", "revoke_user_network_sessions", "read_password", "show_generated_password", "mutation_currentness", "add_user", "change_password", "parse_key_index", "command_user_inner", "command_user"] @@ -159,15 +175,21 @@ unit = prelude + header + "\n" + state + fakes unit += "\n".join(function(db, n) for n in db_names) unit += function(admin, "admin_ssh_console_web_user_command_allowed") unit += "\n".join(function(console, n) for n in console_names) -unit += (ROOT / "tests/admin_console_boundary/accounts.c").read_text() +account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text() +key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text() +account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)') +account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();') +assert ' typed_key_tests();' in account_tests +unit += account_tests with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory: path = Path(directory) (path / "test.c").write_text(unit) subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable", str(path / "test.c"), str(IDF / "components/console/split_argv.c"), - "-o", str(path / "test")], check=True, timeout=30) + "-lcrypto", "-o", str(path / "test")], check=True, timeout=30) result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True) assert "test-password" not in result.stdout assert "Generated password for" not in result.stdout + print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)") print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)") print("PASS: canonical parsed accounts + production DB transactions: nonself isolation, prompt revocation/cancel/mismatch, currentness, persistence/RNG/derive failures, final-admin invariants, self/generated/key/recovery traps; no password output") diff --git a/tests/web_admin_transport/server_lifecycle.py b/tests/web_admin_transport/server_lifecycle.py index 7b006f2..2eccdea 100644 --- a/tests/web_admin_transport/server_lifecycle.py +++ b/tests/web_admin_transport/server_lifecycle.py @@ -37,8 +37,8 @@ def define(path, name): uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};', source, re.M | re.S) # Non-array declarations have no brackets; explicit shape avoids silent omission. -if len(uri_tables) != 20: - raise RuntimeError('Review URI extraction: expected 18 descriptors and two tables') +if len(uri_tables) != 21: + raise RuntimeError('Review URI extraction: expected 19 descriptors and two tables') state = source[source.index('static SemaphoreHandle_t s_server_mutex;'): source.index('static esp_err_t ensure_mutex(void)')] header = (ROOT / 'src/web_server.h').read_text() @@ -109,7 +109,9 @@ HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handle HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler) HANDLER(serial_settings_handler) HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler) -HANDLER(web_account_generate_password_handler) +HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler) +static unsigned keys_calls; +static bool keys_fail; static unsigned account_calls, account_fail_at; static unsigned generation_calls; static bool generation_fail; @@ -124,7 +126,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) { assert(!locked && auth_live && !ssl_live); ++ssl_starts; assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable); - assert(config->httpd.max_uri_handlers == 23 && config->port_secure == 443); + assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443); assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1); assert(config->tls_handshake_timeout_ms == 5000); assert(config->servercert_len == 1 && config->servercert[0] == 1); @@ -165,6 +167,14 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u return httpd_register_uri_handler(s, uri); } static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) { + if (uri->handler == web_account_keys_handler) { + assert(s == SERVER && auth_live && ssl_live && !locked); + assert(!strcmp(uri->uri, "/api/settings/accounts/keys") && uri->method == HTTP_POST); + assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx); + ++keys_calls; + if (keys_fail) return ESP_ERR_NO_MEM; + registered[registered_count++] = uri; return ESP_OK; + } if (uri->handler == web_account_generate_password_handler) { assert(s == SERVER && auth_live && ssl_live && !locked); assert(!strcmp(uri->uri, "/api/settings/accounts/generate-password") && uri->method == HTTP_POST); @@ -247,7 +257,8 @@ static void reset(void) { registration_calls = registration_fail_at = registered_count = unregister_calls = 0; unregister_fail = settings_fail = false; settings_calls = 0; clear_events(); operation_calls = operation_fail_at = 0; - account_calls = account_fail_at = generation_calls = 0; generation_fail = false; + account_calls = account_fail_at = generation_calls = keys_calls = 0; + generation_fail = keys_fail = false; } static void fresh_registration(void) { registration_calls = registered_count = 0; } static void start(void) { @@ -281,9 +292,10 @@ int main(void) { } puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment"); - reset(); start(); assert(registered_count == 23 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2); + reset(); start(); assert(registered_count == 24 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2); assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler); assert(route("/api/settings/serial")->handler == serial_settings_handler); + assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler); const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin"); assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket); assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket); @@ -334,7 +346,7 @@ int main(void) { assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned); assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops); assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures); - assert(registered_count == 21 && unregister_calls == failure - 17); + assert(registered_count == 22 && unregister_calls == failure - 17); for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin")); assert(route("/ws/serial")->handler == websocket_handler); @@ -343,13 +355,13 @@ int main(void) { clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH")); assert(!admin_detaches && !admin_stoppeds); registration_fail_at = 0; fresh_registration(); start(); - assert(registered_count == 23 && admin_attaches == 1 && s_counters.starts == 2); + assert(registered_count == 24 && admin_attaches == 1 && s_counters.starts == 2); assert(web_server_stop() == ESP_OK && admin_stoppeds == 1); } puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart"); reset(); registration_fail_at = 18; unregister_fail = true; - assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 22); + assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 23); assert(auth_live && ssl_live && serial_live && s_serial_transport_attached); assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned); ticket = route("/api/admin/ws-ticket"); @@ -361,7 +373,7 @@ int main(void) { clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH")); assert(!admin_detaches && !admin_stoppeds); unregister_fail = false; registration_fail_at = 0; fresh_registration(); start(); - assert(registered_count == 23 && admin_attaches == 1 && web_server_stop() == ESP_OK); + assert(registered_count == 24 && admin_attaches == 1 && web_server_stop() == ESP_OK); puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart"); reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL; @@ -383,7 +395,7 @@ int main(void) { assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops); puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection"); reset(); settings_fail = true; start(); - assert(settings_calls == 1 && registered_count == 22); + assert(settings_calls == 1 && registered_count == 23); assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK); settings_fail = false; fresh_registration(); start(); assert(route("/api/settings/serial")->handler == serial_settings_handler); @@ -391,7 +403,7 @@ int main(void) { puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers"); for (unsigned failure = 1; failure <= 2; ++failure) { reset(); operation_fail_at = failure; start(); - assert(registered_count == 21 && operation_calls == failure && unregister_calls == failure - 1); + assert(registered_count == 22 && operation_calls == failure && unregister_calls == failure - 1); assert(auth_live && serial_live && admin_owned); for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation")); assert(web_server_stop() == ESP_OK); @@ -399,24 +411,26 @@ int main(void) { puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports"); for (unsigned failure = 1; failure <= 3; ++failure) { reset(); account_calls = 0; account_fail_at = failure; start(); - assert(account_calls == failure && registered_count == (failure == 1 ? 20 : 21)); + assert(account_calls == failure && registered_count == (failure == 1 ? 21 : 22)); + assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler); assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler); assert(auth_live && serial_live && admin_owned); for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/account-operation")); assert(web_server_stop() == ESP_OK); account_fail_at = 0; account_calls = 0; fresh_registration(); start(); - assert(registered_count == 23 && account_calls == 3); + assert(registered_count == 24 && account_calls == 3); assert(web_server_stop() == ESP_OK); } reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start(); - assert(registered_count == 22 && auth_live && serial_live && admin_owned); + assert(registered_count == 23 && auth_live && serial_live && admin_owned); for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET); assert(web_server_stop() == ESP_OK); account_fail_at = 0; puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)"); reset(); generation_fail = true; start(); - assert(generation_calls == 1 && registered_count == 22 && account_calls == 3); + assert(generation_calls == 1 && registered_count == 23 && account_calls == 3); + assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler); assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures); assert(route("/api/settings/accounts")->handler == web_account_settings_handler); unsigned account_mutations = 0; @@ -427,11 +441,31 @@ int main(void) { } assert(account_mutations == 1 && web_server_stop() == ESP_OK); generation_fail = false; fresh_registration(); start(); - assert(generation_calls == 2 && registered_count == 23); + assert(generation_calls == 2 && registered_count == 24); assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler); assert(web_server_stop() == ESP_OK); puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers"); - puts("15 lifecycle groups passed (16 required fatal positions, 9 optional routes, plus failed unregister)"); + reset(); keys_fail = true; start(); + assert(keys_calls == 1 && registered_count == 23 && account_calls == 3 && generation_calls == 1); + assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures); + assert(route("/api/settings/accounts")->handler == web_account_settings_handler); + assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler); + assert(route("/api/session")->handler == web_cookie_auth_handler); + assert(route("/ws/serial")->handler == websocket_handler); + assert(route("/ws/admin")->handler == web_admin_transport_upgrade_handler); + account_mutations = 0; + for (unsigned i = 0; i < registered_count; ++i) { + assert(strcmp(registered[i]->uri, "/api/settings/accounts/keys")); + if (!strcmp(registered[i]->uri, "/api/settings/account-operation") && registered[i]->method == HTTP_POST) + ++account_mutations; + } + assert(account_mutations == 1 && web_server_stop() == ESP_OK); + keys_fail = false; fresh_registration(); start(); + assert(keys_calls == 2 && registered_count == 24); + assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler); + assert(web_server_stop() == ESP_OK); + puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers"); + puts("16 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)"); return 0; } ''' diff --git a/tests/web_cookie_auth/account_settings_test.c b/tests/web_cookie_auth/account_settings_test.c index f869821..045088b 100644 --- a/tests/web_cookie_auth/account_settings_test.c +++ b/tests/web_cookie_auth/account_settings_test.c @@ -41,6 +41,9 @@ static void check_slot_wiped(void) { zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length); zero(&s_operation.principal,sizeof(s_operation.principal)); zero(&s_operation.target,sizeof(s_operation.target)); + zero(s_operation.key_blob,sizeof(s_operation.key_blob)); + zero(s_operation.key_type,sizeof(s_operation.key_type)); + assert(!s_operation.key_blob_length && !s_operation.key_index); } esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) { assert(!host_lock_depth && !dispatcher && id); @@ -88,6 +91,34 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) { esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) { assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL; } +static esp_err_t keys_error; +static unsigned key_lists; +esp_err_t user_database_get_account_keys(const user_database_account_t *target, user_database_user_snapshot_t *out) { + assert(!dispatcher && !host_lock_depth && target->user_id==7 && target->auth_generation==2); + assert(!strcmp(target->username,"carol")); ++key_lists; + memset(out,0,sizeof(*out)); + if (keys_error) return keys_error; + strcpy(out->username,target->username); out->user_id=target->user_id; out->auth_generation=target->auth_generation; + out->public_key_count=3; + for (unsigned i=0;i<3;++i) { + out->public_keys[i].active=true; out->public_keys[i].index=i; + strcpy(out->public_keys[i].key_type,i==0 ? "ssh-ed25519" : "ecdsa-sha2-nistp256"); + memset(out->public_keys[i].sha256_fingerprint,i,32); + } + return ESP_OK; +} +esp_err_t user_database_add_ssh_key_current(const user_database_account_t *target, + const uint8_t *type,size_t type_length,const uint8_t *blob,size_t blob_length,uint8_t *index) { + assert(type_length==11 && !memcmp(type,"ssh-ed25519",11)); + assert(blob_length==51 && blob[3]==11 && blob[18]==32); *index=0; + return user_database_delete_current(target); +} +esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *target,uint8_t index) { + assert(index<3); return user_database_delete_current(target); +} +esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *target) { + return user_database_delete_current(target); +} static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}"; static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}"; static void account_begin(const issued_t *identity,const char *body) { @@ -311,6 +342,110 @@ static void self_tests(void) { self_target=false; puts("PASS Accounts self role/delete/password: protected failures keep login, success immediately target-revokes all logins, stale result reads denied, unrelated user survives"); } +static void key_tests(void) { + auth_reset(); issued_t admin=mint(&alice), user=mint(&bob); receive_fragment=768; + const char *selection="{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}"; + for (unsigned mode=0;mode<13;++mode) { + account_begin(mode==0 ? NULL : mode==1 ? &user : &admin,selection); + req.uri="/api/settings/accounts/keys"; + if (mode==2) req.method=HTTP_GET; + if (mode==3) req.uri="/api/settings/accounts/keys?x=1"; + if (mode==4) add("Origin","https://evil.example"); + if (mode==5) add("X-CSRF-Token","duplicate"); + if (mode==6) add("Transfer-Encoding","chunked"); + if (mode==7) add("Sec-Fetch-Site","cross-site"); + if (mode==8) add("Content-Type","text/plain"); + if (mode==9) req.content_len=aux.remaining_len=769; + if (mode==10) stale_user=alice.user_id; + if (mode==11) db_fail=true; + if (mode==12) receive_fragment=1; + unsigned before=key_lists; (void)web_account_keys_handler(&req); + assert(response_status[0]=='4' && key_lists==before); + zero(scratch,sizeof(scratch)); stale_user=0; db_fail=false; receive_fragment=768; + } + admin=mint(&alice); + for (unsigned mode=0;mode<3;++mode) { + account_begin(&admin,selection); req.uri="/api/settings/accounts/keys"; + keys_error=mode==1 ? ESP_ERR_NOT_FOUND : mode==2 ? ESP_ERR_TIMEOUT : ESP_OK; + unsigned calls=mutations, ids=s_next_id; + assert(web_account_keys_handler(&req)==ESP_OK); + assert(!strcmp(response_status,mode==1 ? "409 Conflict" : mode==2 ? "503 Service Unavailable" : "200 OK")); + assert(calls==mutations && ids==s_next_id && strlen(output)<512); + if (!mode) { + assert(strstr(output,"\"index\":2") && strstr(output,"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"")); + assert(strstr(output,"ecdsa-sha2-nistp256") && !strstr(output,"password") && !strstr(output,"blob")); + bool no_store=false; + for (unsigned i=0;i +#include +static inline int mbedtls_base64_decode(unsigned char *out, size_t capacity, size_t *length, + const unsigned char *in, size_t n) { + unsigned char decoded[132]; + if (!n || n>172 || n%4) return -1; + int result=EVP_DecodeBlock(decoded,in,(int)n); + if (result<0) return -1; + if (in[n-1]=='=') --result; + if (in[n-2]=='=') --result; + if ((size_t)result>capacity) return -1; + memcpy(out,decoded,(size_t)result); *length=(size_t)result; return 0; +} +static inline int mbedtls_base64_encode(unsigned char *out, size_t capacity, size_t *length, + const unsigned char *in, size_t n) { + if (capacity < 4*((n+2)/3)+1) return -1; + *length=(size_t)EVP_EncodeBlock(out,in,(int)n); return 0; +} +""" HEADERS["esp_timer.h"] += """ #include typedef void *esp_timer_handle_t; diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs index 26f56fb..77bde57 100644 --- a/tests/web_ui_session/browser.cjs +++ b/tests/web_ui_session/browser.cjs @@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); }; function browser({onlyLoader = false, withLoader = false, role = 'user', username = ''} = {}) { const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = []; - const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []}; + const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': []}; const fits = []; let serial = 0, now = Date.now(); class Clock extends Date { static now() { return now; } } @@ -789,8 +789,144 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick(); return b; } + const keysPath = '/api/settings/accounts/keys'; + const fingerprint = 'SHA256:' + 'a'.repeat(43); + const keysReply = (extra = {}) => json({username:'carol',user_id:7,auth_generation:2,keys:[{index:0,type:'ssh-ed25519',fingerprint}],...extra}); + async function keyBrowser() { + const b = await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); + b.queues[keysPath].push(keysReply()); b.click('account-keys-refresh'); await tick(); return b; + } const accountPath = '/api/settings/account-operation'; const accountReply = (id, state, action = 'role') => json({id, state, action}); + await test('Key list exact protected identity POST, safe fingerprints and socket/lease isolation', async () => { + const b=await keyBrowser(), p=b.calls.find(c=>c.url===keysPath); + assert.deepEqual(JSON.parse(p.body),{username:'carol',user_id:7,auth_generation:2}); + assert.equal(p.headers['X-CSRF-Token'],token); assert.equal(p.headers['Content-Type'],'application/json'); + assert.match(b.nodes['account-keys-list'].textContent,/0: ssh-ed25519 SHA256:/); + assert.ok(!b.nodes['account-key-delete'].disabled); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); + const u=await connected(); u.click('account-keys-refresh'); u.click('account-key-add'); await tick(); assert.ok(!u.calls.some(c=>c.url===keysPath || c.url===accountPath)); + }); + await test('ECDSA P-256 lists and imports; key read timeout and 401 use existing session isolation', async () => { + const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:[{index:0,type:'ecdsa-sha2-nistp256',fingerprint}]})); b.click('account-keys-refresh'); await tick(); assert.match(b.nodes['account-keys-list'].textContent,/ecdsa-sha2-nistp256/); + b.nodes['account-public-key'].value='ecdsa-sha2-nistp256 AAAA comment'; b.queues[accountPath].push(accountReply(34,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key,'ecdsa-sha2-nistp256 AAAA comment'); + const t=await keyBrowser(); t.queues[keysPath].push(o=>new Promise((_,reject)=>o.signal.addEventListener('abort',()=>reject(new Error('timeout'))))); t.click('account-keys-refresh'); await tick(); t.fire(15000); await tick(); assert.ok(!t.nodes['account-keys-refresh'].disabled); assert.ok(t.nodes['account-key-delete'].disabled); assert.ok(t.sockets.every(s=>!s.closed)); + t.queues[keysPath].push(failure(401)); t.click('account-keys-refresh'); await tick(); assert.deepEqual(t.redirects,['/login']); assert.ok(t.sockets.every(s=>s.closed)); assert.equal(t.nodes['account-keys-list'].textContent,''); + }); + await test('Key import/delete/clear confirm exact body, single POST and refresh new generation keys', async () => { + for(const action of ['key-add','key-delete','key-clear']) { + const b=await keyBrowser(); const publicKey='ssh-ed25519 AAAA comment'; b.nodes['account-public-key'].value=publicKey; + b.window.confirm=()=>false; b.click('account-'+action); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath)); + b.nodes['account-public-key'].value=publicKey; let confirmation; b.window.confirm=m=>{confirmation=m; return true;}; + b.queues[accountPath].push(accountReply(30,'pending',action)); b.click('account-'+action); await tick(); + assert.equal(b.nodes['account-public-key'].value,''); assert.match(confirmation,/carol/); if(action==='key-delete') assert.ok(confirmation.includes(fingerprint)); + const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1); + assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='key-add'?{public_key:publicKey}:action==='key-delete'?{key_index:0}:{})}); + b.queues[accountPath].push(accountReply(30,'ok',action)); + b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]})); b.queues[keysPath].push(keysReply({auth_generation:3})); + b.fire(1000); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/); + assert.equal(JSON.parse(b.calls.filter(c=>c.url===keysPath).at(-1).body).auth_generation,3); assert.ok(!b.nodes['account-key-delete'].disabled); + assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); + } + }); + const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)}); + function assertKeySlots(b, indices) { + assert.equal(b.nodes['account-keys-list'].textContent, indices.map(index => `${index}: ssh-ed25519 ${slotKey(index).fingerprint}`).join('\n')); + assert.equal(b.nodes['account-key-index'].value, String(indices[0])); + for(let index=0;index<3;++index) { + const option=b.nodes['key-option-'+index], present=indices.includes(index); + assert.equal(option.value,String(index)); assert.equal(option.hidden,!present); assert.equal(option.disabled,!present); + assert.equal(option.textContent,present?`${index}: ${slotKey(index).fingerprint}`:''); + } + assert.ok(!b.nodes['account-key-delete'].disabled && !b.nodes['account-key-clear'].disabled); + } + for(const indices of [[1],[0,2]]) await test(`Sparse key slots [${indices}] render and delete by index, not array position`, async () => { + for(const selected of indices) { + const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(slotKey)})); + b.click('account-keys-refresh'); await tick(); assertKeySlots(b,indices); + let confirmation; b.window.confirm=m=>{confirmation=m;return true;}; + b.nodes['account-key-index'].value=String(selected); b.nodes['account-key-index'].change(); + b.queues[accountPath].push(accountReply(35,'pending','key-delete')); b.click('account-key-delete'); await tick(); + assert.ok(confirmation.includes(slotKey(selected).fingerprint)); + for(const other of indices.filter(index=>index!==selected)) assert.ok(!confirmation.includes(slotKey(other).fingerprint)); + const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1); + assert.deepEqual(JSON.parse(posts[0].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:2,key_index:selected}); + assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); + } + }); + await test('Key deletion automatically refreshes sparse survivors and uses their new identity for the next deletion', async () => { + for(const [before,removed,after] of [[[0,1],0,[1]],[[0,1,2],1,[0,2]]]) { + const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:before.map(slotKey)})); + b.click('account-keys-refresh'); await tick(); b.nodes['account-key-index'].value=String(removed); + b.queues[accountPath].push(accountReply(36,'pending','key-delete')); b.click('account-key-delete'); await tick(); + const reads=b.calls.filter(c=>c.url===keysPath).length; + b.queues[accountPath].push(accountReply(36,'ok','key-delete')); + b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]})); + b.queues[keysPath].push(keysReply({auth_generation:3,keys:after.map(slotKey)})); + b.fire(1000); await tick(); assertKeySlots(b,after); + assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/); + const keyReads=b.calls.filter(c=>c.url===keysPath); assert.equal(keyReads.length,reads+1); + assert.deepEqual(JSON.parse(keyReads.at(-1).body),{username:'carol',user_id:7,auth_generation:3}); + b.nodes['account-key-index'].value=String(removed); b.click('account-key-delete'); await tick(); + assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); + const selected=after.at(-1); let confirmation; b.window.confirm=m=>{confirmation=m;return true;}; + b.nodes['account-key-index'].value=String(selected); b.queues[accountPath].push(accountReply(37,'pending','key-delete')); + b.click('account-key-delete'); await tick(); assert.ok(confirmation.includes(slotKey(selected).fingerprint)); + const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,2); + assert.deepEqual(JSON.parse(posts[1].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:3,key_index:selected}); + assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); + } + }); + await test('Duplicate and out-of-range key slots reject the whole list and cannot authorize deletion', async () => { + for(const indices of [[1,1],[0,2,2],[-1],[3],[0,3],[1.5],['1']]) { + const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(index=>({...slotKey(0),index}))})); + b.click('account-keys-refresh'); await tick(); + assert.equal(b.nodes['account-keys-list'].textContent,''); assert.match(b.nodes['account-keys-detail'].textContent,/unavailable or invalid/); + assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled && b.nodes['account-key-index'].disabled); + let confirmations=0; b.window.confirm=()=>{++confirmations;return true;}; + b.nodes['account-key-index'].value='0'; b.click('account-key-delete'); b.click('account-key-clear'); await tick(); + assert.equal(confirmations,0); assert.ok(!b.calls.some(c=>c.url===accountPath)); + assert.equal(b.calls.filter(c=>c.url===keysPath).length,2); + assert.ok(![...b.timers.values()].some(t=>t.ms===1000)); + assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); + } + }); + await test('Key list rejects stale identities, invalid schema and optional endpoint failures without retry', async () => { + for(const response of [failure(409),failure(404),failure(503),keysReply({user_id:8}),keysReply({auth_generation:3}),keysReply({keys:[{index:3,type:'ssh-ed25519',fingerprint}]}),keysReply({keys:[{index:0,type:'ssh-ed25519',fingerprint:''}]}),keysReply({keys:Array(4).fill({})}),new Response(' '.repeat(769))]) { + const b=await keyBrowser(); b.queues[keysPath].push(response); b.click('account-keys-refresh'); await tick(); + assert.equal(b.nodes['account-keys-list'].textContent,''); assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled); + assert.match(b.nodes['account-keys-detail'].textContent,/stale|unavailable/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2); + b.click('account-key-delete'); await tick(); assert.ok(!b.calls.some(c=>c.url===accountPath)); + } + }); + await test('Pasted keys clear on contexts and late list headers/body cannot change new target', async () => { + for(const streamed of [false,true]) for(const mode of ['target','view','domain','refresh','pagehide','logout']) { + const b=await keyBrowser(), d=deferred(); let stream; + b.queues[keysPath].push(streamed?new Response(new ReadableStream({start(c){stream=c;}})):d.promise); + b.click('account-keys-refresh'); await tick(); const p=b.calls.filter(c=>c.url===keysPath).at(-1); b.nodes['account-public-key'].value='PASTED'; + if(mode==='target') { b.nodes['account-target'].value='0'; b.nodes['account-target'].change(); } + if(mode==='view') b.click('select-serial'); if(mode==='domain') b.click('settings-serial'); if(mode==='refresh') b.click('refresh-accounts'); if(mode==='pagehide') b.emit('pagehide'); if(mode==='logout') b.click('sign-out'); + await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(p.signal.aborted); + if(streamed) { try {stream.enqueue(new TextEncoder().encode(await keysReply().text())); stream.close();} catch {} } else d.resolve(failure(401)); + await tick(); assert.equal(b.nodes['account-keys-list'].textContent,''); if(mode!=='logout') assert.deepEqual(b.redirects,[]); + } + }); + await test('Key UTF-8 and JSON bounds reject private/multiline/oversize; cancellation clears paste', async () => { + for(const value of ['-----BEGIN OPENSSH PRIVATE KEY-----','ssh-ed25519 AAAA\nssh-ed25519 BBBB','ssh-ed25519 AAAA '+ 'é'.repeat(185),'ssh-ed25519 AAAA '+ 'x'.repeat(369),'ssh-ed25519 AAAA '+ '\\'.repeat(367)]) { + const b=await keyBrowser(); b.nodes['account-public-key'].value=value; b.click('account-key-add'); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath)); + } + const b=await keyBrowser(); b.nodes['account-public-key'].value='ssh-ed25519 AAAA '+ 'x'.repeat(367); b.queues[accountPath].push(accountReply(31,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(Buffer.byteLength(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key),384); + }); + await test('Key outcomes duplicate/full/stale/failed refresh, bounded polls and self 401 uncertainty', async () => { + for(const state of ['duplicate','full','stale','failed']) { + const b=await keyBrowser(); b.queues[accountPath].push(accountReply(32,state,'key-add')); b.queues[keysPath].push(keysReply()); b.click('account-result'); await tick(); + assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/Username already|Account capacity/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2); + } + const b=await keyBrowser(); b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.click('account-key-clear'); await tick(); + for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.elapse(1000); b.fire(1000); await tick(); } + assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); assert.match(b.nodes['account-operation-detail'].textContent,/stopped/); + const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick(); + assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/); + }); await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => { const u = await connected(); u.click('settings-accounts'); await tick(); assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts')); diff --git a/tests/web_ui_session/run.py b/tests/web_ui_session/run.py index 3331be0..d135f8c 100644 --- a/tests/web_ui_session/run.py +++ b/tests/web_ui_session/run.py @@ -84,6 +84,10 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t); assert 'id="account-password-saved" type="checkbox"' in rendered['html'] assert 'not applied yet' in rendered['html'] and 'no retrieval' in rendered['html'] assert 'JavaScript cannot securely zero strings' in rendered['html'] + assert 'id="account-public-key" maxlength="384" autocomplete="off" spellcheck="false"' in rendered['html'] + for action in ('add', 'delete', 'clear'): + assert f'id="account-key-{action}"' in rendered['html'] + assert 'no private-key upload, export or SSH host management' in rendered['html'] for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'): assert forbidden not in rendered['script'] + rendered['loader'], forbidden (tmp / 'rendered.json').write_text(json.dumps(rendered))