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.