Implement SSH authorized key management
This commit is contained in:
@@ -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):**
|
||||
|
||||
@@ -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,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **8D.11 partial target evidence (2026-09-08):** User supplied 60-second fresh-boot/full-mix telemetry; no functional/sign-off claim. Full mix eventually established (two SSH public-key sessions, SSH writer + USB/two web observers, both admins), but last browser admission again required retries. Three TLS errors `-0x0050` verified against installed mbedTLS as NET_CONN_RESET, not allocation failure; cause unknown. Loaded internal/DMA free 32,596/24,840 B, lifetime minima 5,468/532 B, largest 22,528 B; per-region minima are conservative/non-simultaneous. Settled free is similar to prior 8D.10, minima much lower; no causal regression attribution. SSH stack minimum-free 18,468 boot/16,276 loaded B. Web send failure/close 1, expired tickets 3; SSH handshake/auth failures zero; no broker drop counters. Exact memory/counters/evidence limits in `docs/phase8d11_implementation.md`. Previously accepted admission issue recurred, not fixed; investigate timing/socket occupancy/allocation correlation before capacity changes. Key algorithms/new UI provenance, mutation checklist, HTTPD/dispatcher margins, cleanup/soak and target sign-off remain pending. Documentation-only update; no source/build/device action.
|
||||
|
||||
- **8D.11 implemented (2026-09-08), host-tested/build-verified; target sign-off pending:** Accounts now supports fingerprint listing and bounded Ed25519/P256 public-key import/delete/clear. Conditional database wrappers check target identity under the canonical lock; existing dispatcher/account slot and target-only revocation remain. Optional POST `/api/settings/accounts/keys` brings handler budget to 24, six sockets/tasks/stacks/queue depth unchanged. Sparse stable key slots are supported; review finding fixed and regressed. Parent canonical accounts, cookie accounts, UI, lifecycle (16), build and diff checks passed; final agent UI extension passes 68 groups plus CSP. Final production build 14.85 s, 96,076 B RAM / 1,703,685 B flash (+168/+9,448 vs 8D.10). Exact API, evidence attribution and pending target checklist: `docs/phase8d11_implementation.md`. No device/assets/commit action, reserve approval, full M3 claim or 8D.12 work. Supersedes the older wait-for-8D.11 instruction below, not accepted 8D.8–8D.10/M2.
|
||||
|
||||
- **8D.8–8D.10 implemented scope signed off by user (2026-09-08):** User provides settled boot/full-mix telemetry and reports thorough Serial parameter display/settings and user/account testing: “Implemented work can be signed off.” Accepts 8D.8, 8D.9 including UX refinement, and both implemented 8D.10 slices, superseding pending-signoff/target-blocker statements below without inventing individual checklist passes. Current source/handoff confirms credential/self workflows and generated route complete. Exact flashed revision/browser/durations/reboot/fault-injection details not separately supplied. Full mix at **230400 8N1 RTS/CTS, DTR active**: SSH writer8, USB observer9, web observers26/11, SSH admin and web admin, two cookie sessions; no broker drop counters supplied. Boot internal/DMA/PSRAM free **69,928/62,172/8,246,368 B**, loaded final **32,556/24,800/8,087,656 B**, loaded minima **19,228/11,472/8,065,444 B**, largest **23,552/23,552/7,995,392 B**. SSH minimum-free stack **18,476 boot / 16,284 loaded B**. One SSH handshake failure/broker revocation, one WebSocket send failure/close and one invalid login retained without diagnosis. All six loaded samples, counters, scope and evidence limits: `docs/phase8d10_implementation.md`. Numeric reserves, HTTPD/dispatcher stack margins, cleanup/soak evidence and earlier accepted admission issue remain followups, not reopening sign-off. Browser-shell restrictions/UART0 recovery and M2 stand; no full M3 claim. **Wait for separately requested 8D.11.** This update documentation-only, no new build/test/device/source/config/asset/commit action; preserve existing user `platformio.ini` edit.
|
||||
|
||||
The following implementation entries predate target sign-off and retain historical build/test attribution; their target-pending status is superseded above.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Phase 8D.11 — SSH authorized keys
|
||||
|
||||
## Status (2026-09-08)
|
||||
|
||||
Implemented on user request; host-tested and firmware-build verified. Target validation/sign-off remains pending. Accepted 8D.8–8D.10 and M2 remain accepted; this is not M3 completion or authorization for 8D.12.
|
||||
|
||||
## User-supplied target telemetry (2026-09-08)
|
||||
|
||||
User supplies a fresh-boot sample at 60 seconds and a later full-client-mix sample. The last browser serial/admin connection again required several attempts. This records partial target evidence, not functional key-management acceptance or phase sign-off; exact flashed revision, browser, key algorithms, traffic duration and cleanup/soak results were not supplied.
|
||||
|
||||
| Memory (bytes) | Boot free / minimum / largest | Loaded free / minimum / largest |
|
||||
|---|---|---|
|
||||
| Internal 8-bit | 69,584 / 58,076 / 31,744 | 32,596 / 5,468 / 22,528 |
|
||||
| Internal DMA | 61,828 / 50,320 / 31,744 | 24,840 / 532 / 22,528 |
|
||||
| PSRAM | 8,246,356 / 8,242,108 / 8,126,464 | 8,087,636 / 8,056,956 / 7,995,392 |
|
||||
|
||||
Minima are conservative sums of per-region lifetime minima, not a simultaneous sample; internal/DMA capabilities overlap and must not be added. Low minima are a transient-pressure warning, not proof of allocation failure. Settled loaded free internal/DMA is comparable to the prior accepted 8D.10 sample (32,556 / 24,800), but lifetime minima are substantially lower (previously 19,228 / 11,472). Different connection attempts/workloads prevent attributing that difference to 8D.11.
|
||||
|
||||
Boot: SSH/HTTPS started once without reported startup failures; all supplied network request/session counters zero; no broker clients, UART stopped, USB attached but host closed. mDNS announced with ESP_OK. Five accounts/two admins, no cookie sessions. SSH owner stack 20,480 bytes, minimum-free 18,468.
|
||||
|
||||
Loaded: two successful SSH public-key sessions (admin console and user serial writer); two browser serial observers, USB observer and active browser admin. Broker writer 16, web observers 17/19, USB observer 10, all pending/events zero at the sample. UART running at 230400 8N1 RTS/CTS, DTR active, RX/TX queues empty; USB host open. Two cookie sessions; mDNS/SSH/HTTPS report operational, no transition/error. SSH minimum-free stack 16,276 bytes; HTTPD/dispatcher stack margins unreported. Public-key authentication works for these two sessions, but algorithms and whether keys were imported through the new UI are unspecified.
|
||||
|
||||
Admission evidence: HTTPS handshake attempts logged from 197688 through 379338 ms, with three errors at 377948/378298/378658 ms: `mbedtls_ssl_handshake returned -0x0050`, TLS session creation and HTTPD accept failure. Installed mbedTLS `net_sockets.h` defines this as `MBEDTLS_ERR_NET_CONN_RESET`, not allocation failure. It does not establish why the connection reset or whether transient heap pressure caused delays. The previously accepted intermittent last-browser admission issue has recurred and remains unresolved.
|
||||
|
||||
Loaded counters: SSH TCP connections/handshake success/auth attempts 2/2/2; handshake/auth/timeouts/capacity/IO failures zero. SSH broker connect 1, writer request/grant 1/1, revocations 1; admin admission 1/no failures, stream RX/accepted/rejected 7/7/0, TX 5,977, session revocations zero. Browser admin tickets issued/consumed 2/2, connected/disconnected 2/1, TX 272; rejection/capacity/protocol/backpressure/send/queue counters zero. Web requests 89 authenticated/zero auth failures, root 2/status 76/tickets 8/assets 2, response errors zero; tickets issued/consumed/expired 8/5/3. Serial WS connect/disconnect 5/3 with zero admission/service/broker failures; RX zero; TX 218 binary frames/21,200 bytes and 16 control frames/1,322 bytes; writer requests/grants/denials 5/1/4. One send failure/close, zero queue/protocol failures. Cookie login attempts 2, invalid/throttled/capacity/CSRF/logout zero. No broker drop-counter output supplied; zero pending queues is not proof of lossless traffic. Application counters do not account for every pre-HTTP TLS failure.
|
||||
|
||||
Documentation-only evidence update: no new build, source/config edits, upload, erase, device commands or sign-off. Next diagnostic evidence should correlate connection attempts with heap/allocation failures and browser network timing/socket occupancy rather than assume heap exhaustion or increase socket capacity blindly. Key-workflow checklist and reserve approval remain open.
|
||||
|
||||
## Scope and contracts
|
||||
|
||||
- Accounts settings lists selected-account key types, stable slot indices and SHA256 fingerprints; imports OpenSSH Ed25519/P-256 public-key text and explicitly deletes one slot or clears all keys.
|
||||
- `user_database.{c,h}` provides a zero-wait fingerprint-only snapshot and conditional key mutation wrappers. Username/account ID/auth generation are checked under the canonical database mutation lock. Existing CLI mutation, persistence, duplicate/capacity, curve validation and SSH authentication semantics are retained.
|
||||
- `web_account_settings.{c,h}` admits strict JSON and uses the existing account-operation slot and administration dispatcher. Successful mutations best-effort revoke only the target's web/SSH sessions; currentness checks remain authoritative. Self changes can invalidate the response/result login: 401/disconnect proves neither success nor cancellation. Inspect after relogin before retrying.
|
||||
- Optional **POST `/api/settings/accounts/keys`** accepts `{username,user_id,auth_generation}` and returns `{username,user_id,auth_generation,keys:[{index,type,fingerprint}]}`. This read uses POST to retain the existing bounded JSON/Origin/CSRF policy rather than introduce query parsing. Stale/absent selection returns 409; unavailable snapshot returns 503. No public-key blobs, verifier material or private keys are returned.
|
||||
- Existing POST `/api/settings/account-operation` adds `key-add` with `public_key`, `key-delete` with `key_index` (0–2), and `key-clear`. All carry target identity. Request bodies remain **768 bytes/four receives**, public-key text at most **384 decoded bytes**, decoded SSH blob at most **128 bytes**, three keys per account. Envelope/base64/schema rejection occurs at admission; canonical blob/curve validation runs on the dispatcher. Results remain bounded, secret-free, replaceable and session-bound; no mutation auto-retry.
|
||||
- UI confirms mutations, clears pasted text on submission/context changes, uses text-only rendering and fences stale navigation/session/target work. Completion uses existing bounded polling followed by account/key refresh. Stable key slots may be sparse: option values and deletion confirmation resolve by actual index, not array position.
|
||||
- `web_server.c` registers the listing route independently through the allocation-safe optional POST helper. **24 handlers**, six sockets/no LRU; no new tasks, stack-size increases, queue-depth changes or broker lease changes. Larger operation/local structures still have runtime stack costs requiring target measurement.
|
||||
- No private-key upload/export, SSH host-identity management, browser-shell restriction changes, UART0 recovery changes or generated asset regeneration.
|
||||
|
||||
## Validation
|
||||
|
||||
Parent integrated runs passed:
|
||||
|
||||
- `python3 tests/admin_console_boundary/accounts.py`: canonical key validation/authorization, Ed25519/P256, malformed/off-curve/truncated keys, zero-wait snapshots, stale ID/generation/recreation, duplicate/capacity, sparse slots, failed persistence and CLI parity. Crypto host adapters use OpenSSL; this is not target SSH validation.
|
||||
- `python3 tests/web_cookie_auth/run.py --accounts`: shared authentication policy and account/key route admission/execution tests.
|
||||
- `python3 tests/web_ui_session/run.py`: initial integrated 64 groups and subsequent sparse-slot production fix, plus renderer/HTML/CSP checks. Final test-only extension independently passed **68 browser groups** (agent-run), including sparse `[1]`/`[0,2]`, deletion/automatic refresh, and invalid/duplicate indices.
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: **16 groups**, including isolated optional-route failure and restart recovery (dependencies faked).
|
||||
- `pio run`: final production build **PASS, 14.85 s**, **96,076 B RAM / 1,703,685 B flash**; **+168 B RAM / +9,448 B flash** versus recorded final 8D.10. The later four UI test groups change no firmware source.
|
||||
- `git diff --check` passed for the integrated production change; the final test extension also passed its scoped check.
|
||||
|
||||
Backend agent additionally reports passing console boundary `run.py` and cookie `--serial-settings`. Independent source review identified the sparse-slot UI assumption; corrected production selection/validation and added dedicated regressions. No other concrete review findings were reported. Host tests are not real network/concurrency/stack-margin proof.
|
||||
|
||||
## Pending target checklist
|
||||
|
||||
1. Import real Ed25519 and P-256 public keys, compare fingerprints with CLI/ssh-keygen, authenticate with corresponding private keys, and verify existing password authentication remains available.
|
||||
2. Exercise maximum input, malformed/private-key text rejection, duplicates and full three-key capacity. Delete slot 0 while later slots survive; list/delete/clear sparse slots and re-add successfully.
|
||||
3. Mutate another account through browser and CLI; verify target revocation, unrelated sessions retained, persisted keys after reboot, stale selection rejection and failed-operation recovery.
|
||||
4. Exercise own-account key changes: immediate revocation and uncertain lost acknowledgement, relogin/inspect without automatic replay. Confirm last-key removal does not imply password removal.
|
||||
5. Run full supported serial/admin mix, preserving one writer and isolated observers; record heap/internal-DMA largest blocks, HTTPD/dispatcher stack margins and cleanup/repeated-operation behavior.
|
||||
6. Obtain user target sign-off separately. No upload, erase, hardware test, sanitizer run or commit was performed by this task.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Phase 8D — Incremental web administration plan
|
||||
|
||||
**Latest implementation (2026-09-08):** User-requested **8D.11 implemented, host-tested/build-verified; target sign-off pending**. Accounts fingerprint listing and Ed25519/P256 import/delete/clear use canonical target-checked APIs and the existing dispatcher. Sparse-slot selection regression fixed; 24 handlers/six sockets, no new task/stack-size/queue expansion. Final build 96,076 B RAM / 1,703,685 B flash. [8D.11 record](phase8d11_implementation.md) contains API/bounds, test attribution and pending hardware checklist. Supersedes historical wait-for-8D.11 instructions below; 8D.8–8D.10/M2 remain accepted. No M3 completion or 8D.12 work.
|
||||
|
||||
**Latest target sign-off (2026-09-08):** User reports thorough Serial parameter display/settings and user/account testing, supplies settled boot/full-client-mix evidence, and explicitly says implemented work can be signed off. **Implemented 8D.8–8D.10 are accepted**, including 8D.9 UX and both 8D.10 slices. Supersedes target/signoff-pending statements below, not build/test evidence or restrictions. [8D.10 target acceptance record](phase8d10_implementation.md) contains all six loaded samples, client mix, counters and evidence limits. Full mix at 230400 baud confirms SSH sole writer + USB/two web observers with both admin routes. Final internal/DMA free 32,556/24,800 B, lifetime minima 19,228/11,472 B, largest 23,552 B; isolated failures retained without diagnosis. Exact revision/browser/durations/individual cases are unspecified; no leak-free-soak or reserve approval inferred. M2 stands; full M3/browser-shell parity is not claimed. **Next is 8D.11 only when separately requested; this sign-off authorizes no implementation.**
|
||||
|
||||
**Current slice 2 completion (2026-09-08):** **8D.10 implementation is complete, host-tested/build-verified, not target accepted; target validation/full signoff remain pending.** Create/password/self workflows use bounded 768-byte admission and periodic credential cleanup (30-second deadline plus one-second timer/scheduling latency); admitted executing work is not cancelled. Protected generation is separate before commit, with no retained retrieval. Self revocation may prevent results: 401/disconnect is uncertain, never grounds for automatic retry. Browser-shell restrictions remain unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage, **23 handlers/six sockets**. Parent PASS canonical accounts/boundary, parser **294**, cookie accounts **9 plus shared**, serial-settings **10**, transport **25**/tickets **12**, store/serial and diff check. UI agent **57 plus CSP** and route agent lifecycle **15** pass; these are not claims of the parent's additional UI/lifecycle reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. Timer runtime costs and heap/stack margins remain unmeasured. [Current 8D.10 record](phase8d10_implementation.md) contains contracts/evidence/target checklist. No sanitizer validation, assets/device/commit/8D.11 work, M2 reopening, prior-phase signoff or reserve approval inferred.
|
||||
|
||||
+96
-10
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 account’s web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n"
|
||||
" if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
|
||||
" if (!window.confirm((action === 'key-add' ? 'Import public key for ' : action === 'key-delete' ? 'Delete key ' + accountKeys.find(k => k.index === Number(element('account-key-index').value)).fingerprint + ' for ' : action === 'key-clear' ? 'Clear ALL authorized keys for ' : action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
|
||||
" const current = () => accountsAbort === controller && accountsLive();\n"
|
||||
" controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n"
|
||||
" const detail = element('account-operation-detail'); let deadline, refresh = false, until = Infinity;\n"
|
||||
" const detail = element('account-operation-detail'); let deadline, refresh = false, refreshKeys = false, until = Infinity;\n"
|
||||
" clearAccountKeys();\n"
|
||||
" detail.textContent = accountWarning + (action ? 'Submitting once...' : 'Reading latest result...');\n"
|
||||
" element('accounts-detail').textContent = 'List may be stale until operation completes and refresh succeeds.';\n"
|
||||
" const read = async (method, requestBody) => {\n"
|
||||
" const response = api('/api/settings/account-operation', generation, {method, body: requestBody, signal: controller.signal, limit: 96, current: () => current() && performance.now() < until}); requestBody = undefined;\n"
|
||||
" const {payload: result} = await response;\n"
|
||||
" if (performance.now() >= until) throw new Error('Check deadline');\n"
|
||||
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password'].includes(result.action) ||\n"
|
||||
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password','key-add','key-delete','key-clear'].includes(result.action) ||\n"
|
||||
" !['idle','pending','ok','failed','cancelled','stale','protected','duplicate','full'].includes(result.state) || ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
|
||||
" (method === 'POST' && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation result');\n"
|
||||
" if (method === 'POST') accountWarning = '';\n"
|
||||
@@ -535,6 +580,8 @@ static const char s_app_js[] =
|
||||
" else if (accountId && result.id !== accountId) accountWarning = 'Previous result replaced or unavailable; outcome unknown. ';\n"
|
||||
" accountId = result.id; accountPending = result.state === 'pending'; accountAwaitingAck = false;\n"
|
||||
" const messages = {duplicate: 'Username already exists. Refresh before retrying.', full: 'Account capacity full. Inspect accounts before retrying.', idle: 'No retained result. Inspect accounts before retrying.', pending: 'Queued or executing...', ok: 'Account change completed and saved.', failed: 'Operation failed. Inspect accounts before retrying.', cancelled: 'Cancelled before execution: login or queue deadline stale.', stale: 'Account changed or was replaced. Refresh and select it again.', protected: 'Account is protected (including the final administrator), or database unavailable.'};\n"
|
||||
" refreshKeys = result.action.startsWith('key-');\n"
|
||||
" if (refreshKeys) { messages.duplicate = 'Public key already authorized. Inspect refreshed keys before retrying.'; messages.full = 'Authorized key capacity full (three). Inspect refreshed keys before retrying.'; }\n"
|
||||
" detail.textContent = accountWarning + result.action + ': ' + messages[result.state];\n"
|
||||
" return result.state;\n"
|
||||
" };\n"
|
||||
@@ -560,7 +607,7 @@ static const char s_app_js[] =
|
||||
" if (state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n"
|
||||
" refresh = state !== 'pending' && state !== 'idle';\n"
|
||||
" } catch (error) { if (live(generation) && current()) detail.textContent = accountWarning + (error.status ? error.message : 'Outcome unknown.') + ' Use Check Result and Refresh before an explicit retry. No automatic retry.'; }\n"
|
||||
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh) await refreshAccounts(); } }\n"
|
||||
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh && await refreshAccounts() && refreshKeys && live(generation)) await refreshAccountKeys(); } }\n"
|
||||
"}\n"
|
||||
"element('settings-serial').addEventListener('click', () => selectSettingsDomain('serial'));\n"
|
||||
"element('settings-accounts').addEventListener('click', () => selectSettingsDomain('accounts'));\n"
|
||||
@@ -573,7 +620,10 @@ static const char s_app_js[] =
|
||||
"element('account-password-saved').addEventListener('change', () => { savedContext = element('account-password-saved').checked && generatedPassword && element('account-password').value === generatedPassword && generatedContext === secretContext() && performance.now() < secretExpires ? generatedContext : ''; });\n"
|
||||
"for (const id of ['account-password','account-password-confirm']) element(id).addEventListener('input', () => { invalidateSecretRequest(); accountButtons(); });\n"
|
||||
"for (const id of ['account-purpose','account-username','account-create-role','account-role']) element(id).addEventListener(id === 'account-username' ? 'input' : 'change', () => { clearAccountSecret(); accountButtons(); });\n"
|
||||
"element('account-target').addEventListener('change', () => { clearAccountSecret(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
|
||||
"element('account-keys-refresh').addEventListener('click', refreshAccountKeys);\n"
|
||||
"for (const action of ['key-add','key-delete','key-clear']) element('account-' + action).addEventListener('click', () => accountOperation(action));\n"
|
||||
"element('account-key-index').addEventListener('change', clearAccountSecret);\n"
|
||||
"element('account-target').addEventListener('change', () => { clearAccountSecret(); clearAccountKeys(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
|
||||
"let accountRole = 'user', selected = 'serial';\n"
|
||||
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
|
||||
"let adminGeneration = 0, adminTimer = null;\n"
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user