Implement SSH authorized key management

This commit is contained in:
2026-09-08 16:37:47 +02:00
parent 22a7c7b0a5
commit 42f6423d4e
19 changed files with 930 additions and 83 deletions
+2
View File
@@ -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):**
+2
View File
@@ -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}`
+4
View File
@@ -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.88D.10/M2.
- **8D.88D.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.
+2
View File
@@ -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:
+60
View File
@@ -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.88D.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` (02), 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.
+2
View File
@@ -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.88D.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.88D.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.
+96 -10
View File
@@ -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);
}
+10
View File
@@ -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);
+159 -28
View File
@@ -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;
+15
View File
@@ -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);
+6 -1
View File
@@ -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();
+60 -10
View File
@@ -230,6 +230,13 @@ static const char s_index_html[] =
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>"
"<h3>Authorized SSH public keys</h3><p>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.</p>"
"<button id=\"account-keys-refresh\" class=\"button\" type=\"button\">Read selected account keys</button>"
"<p id=\"account-keys-detail\" role=\"status\"></p><pre id=\"account-keys-list\"></pre>"
"<label>Key to delete<select id=\"account-key-index\"><option id=\"key-option-0\" value=\"0\"></option><option id=\"key-option-1\" value=\"1\"></option><option id=\"key-option-2\" value=\"2\"></option></select></label>"
"<label>OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label>"
"<div class=\"serial-actions\"><button id=\"account-key-add\" class=\"button\" type=\"button\">Import public key</button>"
"<button id=\"account-key-delete\" class=\"button\" type=\"button\">Delete selected key</button><button id=\"account-key-clear\" class=\"button\" type=\"button\">Clear all authorized keys</button></div>"
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
@@ -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 accounts 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"
+126
View File
@@ -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<en;++n) assert(!user_database_key_valid(s_ed25519_type,11,ed,n));
for (size_t n=0;n<pn;++n) assert(!user_database_key_valid(s_ecdsa_type,19,p256,n));
assert(!user_database_key_valid(s_ed25519_type,11,ed,en+1));
assert(!user_database_key_valid(s_ecdsa_type,19,p256,pn+1));
assert(!user_database_key_valid(s_ed25519_type,11,p256,pn));
assert(!user_database_key_valid((const uint8_t *)"ssh-rsa",7,ed,en));
uint8_t bad[129]; memcpy(bad,p256,pn); bad[pn-65]=2;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad+pn-64,0,64); bad[pn-65]=4;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memcpy(bad,p256,pn); bad[27]='x';
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad,0xff,sizeof(bad));
assert(!user_database_key_valid(s_ed25519_type,11,bad,sizeof(bad)));
user_database_account_t target=key_target();
user_database_user_snapshot_t snapshot;
snapshot_busy=true; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_TIMEOUT && last_wait==0);
assert(all_zero(&snapshot,sizeof(snapshot))); snapshot_busy=false;
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && last_wait==0 && !snapshot.public_key_count);
uint8_t index=255;
stored_database_t invalid_before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,bad,sizeof(bad),&index)==ESP_ERR_INVALID_ARG);
unchanged(&invalid_before);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==0);
stale_keys(&target,ed,en); target=key_target();
user_principal_t authenticated; bool authorized=false;
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&authenticated,&authorized)==ESP_OK && authorized);
assert(authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY && authenticated.auth_generation==target.auth_generation);
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==1);
uint8_t digest[32]; assert(SHA256(ed,en,digest));
assert(snapshot.public_keys[0].active && !strcmp(snapshot.public_keys[0].key_type,"ssh-ed25519"));
assert(!memcmp(snapshot.public_keys[0].sha256_fingerprint,digest,32));
unsigned saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY && commits==saved);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target();
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && authorized);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"observer",8,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
ed[en-1]=2;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==2);
target=key_target(); saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY);
ed[en-1]=3;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_NO_MEM && commits==saved);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_FAIL); unchanged(&before);
assert(user_database_clear_ssh_keys_current(&target)==ESP_FAIL); unchanged(&before);
}
fail_stage=0;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_OK);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
stale_keys(&target,ed,en); target=key_target();
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==2);
assert(!snapshot.public_keys[1].active && snapshot.public_keys[2].index==2);
assert(user_database_remove_ssh_key_current(&target,1)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(&target,3)==ESP_ERR_INVALID_ARG);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target(); assert(user_database_clear_ssh_keys_current(&target)==ESP_OK);
stale_keys(&target,ed,en); target=key_target(); saved=commits;
assert(user_database_clear_ssh_keys_current(&target)==ESP_OK && commits==saved);
assert(user_database_delete_current(&target)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_role_current(&target,USER_ROLE_ADMIN)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_password_current(&target,(const uint8_t *)"test-password",13)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); target.user_id=0; stale_keys(&target,ed,en);
target=key_target(); target.auth_generation=0; stale_keys(&target,ed,en);
target=key_target(); memset(target.username,'x',sizeof(target.username));
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_INVALID_ARG);
assert(user_database_remove_ssh_key_current(&target,0)==ESP_ERR_INVALID_ARG);
assert(user_database_clear_ssh_keys_current(NULL)==ESP_ERR_INVALID_ARG);
assert(user_database_get_account_keys(NULL,&snapshot)==ESP_ERR_INVALID_ARG && all_zero(&snapshot,sizeof(snapshot)));
/* Legacy CLI APIs retain the exact transaction path and generation changes. */
target=key_target(); assert(user_database_add_ssh_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&index)==ESP_OK);
stale_keys(&target,ed,en);
assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK);
assert(user_database_clear_ssh_keys((const uint8_t *)"other",5)==ESP_OK);
s_initialized=false; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_INVALID_STATE && all_zero(&snapshot,sizeof(snapshot)));
}
+36 -14
View File
@@ -80,16 +80,33 @@ static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
}
return fail_stage==5 ? ESP_FAIL : ESP_OK;
}
#include <openssl/sha.h>
#include <openssl/ec.h>
#include <openssl/obj_mac.h>
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")
+52 -18
View File
@@ -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;
}
'''
+136 -1
View File
@@ -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<aux.resp_hdrs_count;++i)
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
assert(no_store);
}
}
keys_error=ESP_OK;
const char *bad_selection[]={"{}", "{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"username\":\"carol\"}",
"{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}",
"{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":0}"};
for (unsigned i=0;i<sizeof(bad_selection)/sizeof(*bad_selection);++i) {
account_begin(&admin,bad_selection[i]); req.uri="/api/settings/accounts/keys";
assert(web_account_keys_handler(&req)==ESP_OK && !strcmp(response_status,"400 Bad Request"));
}
const char *ed="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
char body[800], text[500];
account_operation_t parsed={0};
assert(parse_public_key("ecdsa-sha2-nistp256 AAAA",24,&parsed));
assert(!strcmp(parsed.key_type,"ecdsa-sha2-nistp256"));
uint8_t maximum_blob[129]={0}; unsigned char encoded[177]; size_t encoded_length=0;
for (size_t n=128;n<=129;++n) {
assert(mbedtls_base64_encode(encoded,sizeof(encoded),&encoded_length,maximum_blob,n)==0);
snprintf(text,sizeof(text),"ssh-ed25519 %s",encoded);
memset(&parsed,0,sizeof(parsed));
assert(parse_public_key(text,strlen(text),&parsed)==(n==128));
}
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s comment\"}",ed);
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
const esp_err_t errors[]={USER_DATABASE_ERR_DUPLICATE_SSH_KEY,ESP_ERR_NO_MEM,ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_ARG};
const unsigned states[]={DUPLICATE,FULL,STALE,FAILED};
for (unsigned i=0;i<4;++i) {
unsigned revokes=web_revokes; mutation_error=errors[i];
submit_account(&admin,body); execute_account(); assert(s_operation.state==states[i] && web_revokes==revokes);
}
mutation_error=ESP_OK;
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\",\"public_key\":\"%s\"}",ed,ed);
account_begin(&admin,body); account_expect("400 Bad Request");
const char *invalid[]={"ssh-rsa AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "ssh-ed25519 AAA", "ssh-ed25519 AA=A", "ssh-ed25519 AB==", "ssh-ed25519 AAAA\\ncomment", "ssh-ed25519 AAAA\\u0000", "ssh-ed25519 AAAA\\u0080", "ssh-ed25519 !!!!", "ssh-ed25519 ====", "ssh-ed25519 AAAA AAAA\\rAAAA"};
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\"}",invalid[i]);
account_begin(&admin,body); account_expect("400 Bad Request");
}
for (unsigned n=384;n<=385;++n) {
strcpy(text,ed); size_t size=strlen(text); memset(text+size,' ',n-size); text[n]=0;
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\"}",text);
account_begin(&admin,body); account_expect(n==384 ? "202 Accepted" : "400 Bad Request");
if (n==384) { execute_account(); assert(s_operation.state==OK); }
}
for (unsigned i=0;i<4;++i) {
snprintf(body,sizeof(body),"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":%u}",i);
account_begin(&admin,body); account_expect(i<3 ? "202 Accepted" : "400 Bad Request");
if (i<3) { execute_account(); assert(s_operation.state==OK); }
}
const char *bad[]={
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":00}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":-1}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":0,\"key_index\":1}",
"{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":0}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
const char *clear="{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
submit_account(&admin,clear); unsigned calls=mutations; s_operation.deadline=0; execute_account();
assert(s_operation.state==CANCELLED && mutations==calls);
submit_account(&admin,clear); web_session_store_invalidate(admin.view.id); execute_account();
assert(s_operation.state==CANCELLED && mutations==calls);
admin=mint(&alice); submit_account(&admin,clear); execute_account(); assert(s_operation.state==OK);
self_target=true;
submit_account(&admin,"{\"action\":\"key-clear\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}");
execute_account(); assert(s_operation.state==OK);
account_begin(&admin,NULL); account_expect("401 Unauthorized"); self_target=false;
puts("PASS Accounts keys: strict envelopes/schemas/limits, bounded fingerprint-only POST read, HTTP policy, dispatcher results, cancellation and self revocation uncertainty");
}
static void account_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob), other=mint(&alice); receive_fragment=64;
account_begin(NULL,deletion); account_expect("401 Unauthorized");
@@ -372,6 +507,6 @@ static void account_settings_tests(void) {
submit_account(&admin,deletion); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before);
puts("PASS Accounts execution failure/stale/protected results, dequeue cancellation, missed account revocation/DB failure and admitted-work completion after expiry");
credential_tests(); password_parser_tests(); generated_tests(); self_tests();
credential_tests(); password_parser_tests(); generated_tests(); self_tests(); key_tests();
assert(wiped_passwords && wiped_bodies && wiped_generated && wiped_responses);
}
+21
View File
@@ -47,6 +47,27 @@ settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
if accounts:
HEADERS["mbedtls/base64.h"] = """
#pragma once
#include <stddef.h>
#include <openssl/evp.h>
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 <stdbool.h>
typedef void *esp_timer_handle_t;
+137 -1
View File
@@ -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 = '<img>'} = {}) {
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:'<img>'}]}),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'));
+4
View File
@@ -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))