Enable bounded browser account administration for Phase 8D.7
Allow other-account add/password and forced delete/role commands through shared dispatcher and handler policy. Keep self-target, generated-secret, key, bootstrap, and recovery workflows blocked. Revalidate currentness after password prompts and before database API admission. Document that admitted mutations may finish after disconnect, while subsequent stale operations must reject. Add policy, transaction-failure, cleanup, and targeted-revocation regressions. Record completed review, passing host tests and firmware build, with target validation and M2 acceptance still pending.
This commit is contained in:
@@ -175,6 +175,8 @@ The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the sam
|
||||
|
||||
The second 8D.7 slice additionally permits exact parsed browser `web certificate rotate --force`. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner `dispatcher_actions` mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof).
|
||||
|
||||
The third 8D.7 slice permits browser other-account interactive add/password and forced delete/role mutations, with shared parsed policy at dispatcher admission and canonical-handler defense. Self-target, generated-secret, key, bootstrap and recovery workflows remain blocked. Account/owner/session/token currentness is checked after password prompts and before database API operation admission. This is operation-admission currentness, not an atomic liveness/NVS-commit guarantee: an admitted derivation/mutation may finish and target-revoke after disconnect or expiry; subsequent stale operations reject. Reconnecting administrators must inspect uncertain account outcomes rather than assume cancellation. Existing transaction cleanup, account invariants and best-effort targeted notifications remain unchanged.
|
||||
|
||||
## Wi-Fi and persistence
|
||||
|
||||
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
||||
|
||||
@@ -77,6 +77,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
### Browser admin backend (8D.5)
|
||||
|
||||
- 8D.7 third account slice: `admin_ssh_console` shares parsed browser other-account policy with `user_console`; interactive add/password and forced delete/role now allowed, self/generated/key/bootstrap/recovery still blocked. Post-prompt/pre-DB-API currentness is operation admission, not cancellation of admitted derivation/commit. Existing target-only notifications follow success. Review has no actionable findings; `python3 tests/admin_console_boundary/accounts.py` adds deterministic handler/database failure and stale-next-operation regressions. Target/M2 pending; see `docs/phase8d7_implementation.md`.
|
||||
|
||||
- 8D.7 second slice: exact parsed browser `web certificate rotate --force`; `admin_ssh_console.{c,h}` supplies the typed request union/owner `dispatcher_actions` mask, bounded drain/200 ms handoff to the existing 12 KiB dispatcher, persistent pending gate and revalidated executing-slot reservation. `web_console.c` schedules; `web_admin_transport.c` revalidates then calls transactional `web_security_rotate_certificate()` → `web_server_stop()` → `web_server_start()`, short-circuiting errors and retaining ownership on failed stop. SSH/UART0 unchanged. No tasks/depth/routes/assets/stacks added; target stack margins unknown. Boundary `run.py` includes `certificate.c`; lifecycle/policy and transport 25/tickets 12 host groups pass as reported. Credential/account then other owner slices remain; user authorized stacking, not target/M2 sign-off. See `docs/phase8d7_implementation.md`.
|
||||
- 8D.7 first-slice history: browser `reboot`/`web stop` defer via `admin_ssh_console` control task; WEB owner revalidates cookie/principal/token before lifecycle calls. Pending console input is discarded (incoming-frame disposition latched before receive). `web_console.c` defers stop only for browser origin; other restrictions remain. Tests additionally include `python3 tests/admin_console_boundary/lifecycle.py`; handoff: `docs/phase8d7_implementation.md`. No 8D.7/M2 acceptance yet.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **8D.7 third account slice review completed (2026-09-07), target pending:** Inherited uncommitted code/tests enable browser other-account interactive add/password and forced delete/role; self/generated/key/bootstrap/recovery remain blocked. Independent production/test reviews found no actionable findings; prior precommit-liveness finding withdrawn. Contract is post-prompt, pre-database-API operation-admission currentness: admitted work may commit and target-revoke after disconnect/expiry, subsequent stale work rejects. Documentation now records uncertain-result recovery and deterministic-test limits. Boundary/accounts/policy (review agent), lifecycle, transport 25/tickets 12 and store/serial (parent) pass. Parent incremental `pio run` PASS 3.35 s, **95,580 B RAM / 1,648,577 B flash**, 0/+516 B versus certificate slice. No production edits in continuation, device operation, sanitizer pass, asset regeneration or commit. See `docs/phase8d7_implementation.md`. Supersedes older all-account-blocked/next-account notes; remaining owner/credential parity, target checks, numeric reserves and 8D.7/M2 acceptance remain pending. No next slice started.
|
||||
|
||||
- **8D.7 second certificate slice implemented / host-tested / build-verified, target pending:** Exact parsed browser `web certificate rotate --force` is enabled. Typed deferred-request union preserves queue capacity; immutable owner `dispatcher_actions` transfers certificate work after bounded drain/200 ms delay to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending-input gate survives handoff; token/principal/session revalidation and executing-slot reservation fence stale work/self-detach reuse. WEB uses transactional certificate commit → stop → start, returning on early errors and retaining ownership on failed stop. SSH/UART0 unchanged; other account/network/credential/SSH mutations remain blocked. No new tasks/depth/routes/assets/stacks. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Parent final rerun passes boundary (`run.py` including `certificate.c`), lifecycle, policy, transport **25**/tickets **12**, server **11**, cookie/admin, store/serial, UI **17** plus renderer/CSP and diff checks; independent reviewer has no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. Owner mask/local scratch and target stack margins remain unmeasured; host sizeof is not target proof. Drain/acknowledgement is bounded, not execution latency or peer receipt. **User explicitly authorized continuing to stack the next bounded slice, not target sign-off. Next: credential/account, then other owner slices; 8D.7/M2 acceptance and numeric reserves pending.** See `docs/phase8d7_implementation.md` for separate slice histories and certificate force/trust/relogin/USB/SSH/failure checklist. Supersedes the first-slice next-step/continuation-pending instruction below. Production/tests/docs updated and parent build/regressions executed; no device operation, asset regeneration or commit.
|
||||
|
||||
- **8D.7 first increment implemented / host-tested / build-verified (2026-09-06), target pending:** User requests starting 8D.7. Scope split enables only browser `reboot` and `web stop` through existing bounded deferred control; WEB revalidates session/principal/token before lifecycle APIs. Adds browser-origin query and pending snapshot flag; HTTPD discards deferred input, latching disposition before receive. Review fixed second-frame-before-poll and cancellation-during-receive races; final reviewer confirms correction. Canonical handler/console/policy, transport **23**/tickets **12**, lifecycle **11**, cookie/admin, store/serial and UI **17** suites pass. Final `pio run` **12.44 s, 95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** vs 8D.6. No tasks/routes/capacity/assets/UI/credential changes or device operations. `docs/phase8d7_implementation.md` holds behavior, restrictions, validation limits/resources and disruptive target checklist. **8D.7/M2 incomplete; next slice remains safe HTTPS identity/certificate handling**, then remaining owner/account restrictions. Obtain target regression or explicit user decision before stacking runtime changes. `web stop` intentionally closes both browser routes; restart via UART0/admin SSH. Prior 8D.6 sign-off stands; internal/DMA minima **6,516 / 1,580 B** and numeric reserves remain follow-ups.
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
# Phase 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
||||
|
||||
## Current status — second bounded certificate slice
|
||||
## Current status: third bounded account slice
|
||||
|
||||
**Account slice reviewed / host-tested / build-verified (2026-09-07); no actionable findings remain. Target validation and 8D.7/M2 acceptance remain pending.** This supersedes the account restrictions and next-slice statements in the historical sections below, not their recorded validation limits.
|
||||
|
||||
Browser admins may now operate on other accounts with exact parsed `user add <name> user|admin`, `user password <name>`, `user delete <name> --force`, and `user role <name> user|admin --force`. Add/password use hidden password and confirmation prompts. Dispatcher policy and the canonical handler both enforce this bounded allowlist. Self-target mutations (even no-op role changes), generated-password output, key workflows, bootstrap and recovery remain blocked for browsers. Existing inspection is unchanged. Database final-admin and migrated-account protections remain authoritative.
|
||||
|
||||
### Operation-admission currentness
|
||||
|
||||
The guarantee is **operation-admission currentness**, not currentness at commit. Account, originating owner/session and console token are checked after password prompts and before admitting the database API operation; delete/role also revalidate before API admission. Queued or subsequent stale operations must reject. Disconnect, logout, expiry or revocation during password derivation does not cancel or roll back an already admitted operation. Such an operation may persist and request target-only web/SSH revocation after the initiating browser is gone. Notification failure does not undo a committed mutation, and completion output is not a receipt guarantee.
|
||||
|
||||
After a disconnect with an uncertain result, reconnect using a current administrator and inspect `user list` / `user show <name>` before retrying. Password values cannot be inspected; verify the intended login outcome or deliberately reset the target password rather than assuming cancellation. Do not expose credentials in status or logs.
|
||||
|
||||
### Review and verification
|
||||
|
||||
Independent production and regression reviews found no actionable defects. The earlier proposed precommit-currentness finding was withdrawn as inconsistent with the existing executing-handler contract. No production changes were needed during this review continuation; inherited changes were preserved.
|
||||
|
||||
Passed in this continuation: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/admin_ssh_policy/run.py` (review agent); `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/web_admin_transport/run.py --tickets` (25 transport / 12 ticket groups), and `python3 tests/web_session_store/run.py --serial` (parent). Account regressions cover admitted derivation invalidation, successful commits and target-only notification arguments, NVS open/write/commit failures, candidate cleanup, and rejection of the next stale operation.
|
||||
|
||||
These are deterministic handler/database and separate console/transport boundary tests, not real HTTPD disconnect/expiry, scheduler or end-to-end socket tests. Account tests double currentness, prompts, crypto, NVS and notification APIs; they do not independently prove live connection isolation. No device or sanitizer validation was performed.
|
||||
|
||||
`pio run` passed (incremental, 3.35 s): **95,580 B linked RAM / 1,648,577 B flash**, **0 / +516 B** versus the recorded certificate slice, **0 / +2,088 B** versus 8D.6, **+1,048 / +48,604 B** versus 8D.0. No new task, queue depth, route, socket capacity, assets or stack size; runtime reserves and stack margins remain unmeasured.
|
||||
|
||||
### Pending account target checks
|
||||
|
||||
1. Exercise each enabled other-account command, hidden confirmation/mismatch/cancel, and self/generated/key/bootstrap/recovery rejection. Verify final-admin protections and unchanged UART0/admin SSH behavior.
|
||||
2. Confirm successful target mutations revoke only that account's web/SSH sessions, retaining unrelated browser serial/admin, USB and SSH traffic. Check stale queued commands and prompts after logout/expiry/revocation and slot reuse.
|
||||
3. Where safely injectable, disconnect during admitted derivation and inspect the resulting account state after reconnect; do not expect cancellation. Check persistence failure and retry recovery without logging secrets. Unavailable timing/failure injection remains unexecuted.
|
||||
4. Run the lifecycle/full-mix/soak/resource checklist below as well. Remaining self/generated/key/legacy-credential and other owner-specific parity work stays blocked until separately implemented; this review does not close M2 or start another slice.
|
||||
|
||||
## Second bounded certificate slice history
|
||||
|
||||
**Second slice implemented / host-tested / build-verified; independent reviewer reports no actionable findings. No hardware validation or 8D.7/M2 acceptance.** The user explicitly authorized continuing to stack the next bounded slice; this is a continuation decision, not target sign-off. Next comes credential/account handling, then other owner-specific slices. Prior 8D.6 sign-off stands; numeric reserves remain open.
|
||||
|
||||
|
||||
+36
-4
@@ -183,6 +183,12 @@ static bool session_is_current(const admin_ssh_console_token_t *token,
|
||||
return matched && current;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_dispatch_is_current(void)
|
||||
{
|
||||
return xTaskGetCurrentTaskHandle() == s_task &&
|
||||
(!s_dispatch_remote || session_is_current(&s_dispatch_token, &s_dispatch_principal));
|
||||
}
|
||||
|
||||
static bool append_output_locked(admin_session_t *session,
|
||||
const uint8_t *data, size_t length)
|
||||
{
|
||||
@@ -488,6 +494,34 @@ static int ssh_output_write(void *cookie, const char *buffer, int length)
|
||||
return length;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_web_user_command_allowed(
|
||||
size_t argc, char **argv, const user_principal_t *principal)
|
||||
{
|
||||
if (argc == 0U || strcmp(argv[0], "user") != 0) return false;
|
||||
if (argc == 1U ||
|
||||
(argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||
strcmp(argv[1], "list") == 0)) ||
|
||||
(argc == 3U && strcmp(argv[1], "show") == 0)) return true;
|
||||
|
||||
bool role_valid = argc >= 4U &&
|
||||
(strcmp(argv[3], "user") == 0 || strcmp(argv[3], "admin") == 0);
|
||||
bool mutation =
|
||||
(argc == 4U && strcmp(argv[1], "add") == 0 && role_valid) ||
|
||||
(argc == 3U && strcmp(argv[1], "password") == 0) ||
|
||||
(argc == 4U && strcmp(argv[1], "delete") == 0 &&
|
||||
strcmp(argv[3], "--force") == 0) ||
|
||||
(argc == 5U && strcmp(argv[1], "role") == 0 && role_valid &&
|
||||
strcmp(argv[4], "--force") == 0);
|
||||
/* Parsed names use the database's exact, case-sensitive identity. Reject
|
||||
* self even for no-op role changes; their handler still requests revocation.
|
||||
* Generation/output and key workflows remain outside this bounded slice. */
|
||||
return mutation && principal != NULL && principal->role == USER_ROLE_ADMIN &&
|
||||
principal->username_length > 0U &&
|
||||
principal->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
!(strlen(argv[2]) == principal->username_length &&
|
||||
memcmp(argv[2], principal->username, principal->username_length) == 0);
|
||||
}
|
||||
|
||||
static bool remote_command_allowed(const admin_request_t *request)
|
||||
{
|
||||
char copy[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
@@ -514,10 +548,8 @@ static bool remote_command_allowed(const admin_request_t *request)
|
||||
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
|
||||
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
||||
} else if (strcmp(argv[0], "user") == 0) {
|
||||
allowed = argc == 1U ||
|
||||
(argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||
strcmp(argv[1], "list") == 0)) ||
|
||||
(argc == 3U && strcmp(argv[1], "show") == 0);
|
||||
allowed = admin_ssh_console_web_user_command_allowed(
|
||||
argc, argv, &request->principal);
|
||||
} else if (strcmp(argv[0], "reboot") == 0) {
|
||||
allowed = argc == 1U;
|
||||
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
|
||||
|
||||
@@ -121,6 +121,12 @@ esp_err_t admin_ssh_console_start_uart_frontend(void);
|
||||
bool admin_ssh_console_dispatch_is_remote(void);
|
||||
bool admin_ssh_console_dispatch_is_web(void);
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
||||
/* Revalidate account, originating owner/session and token before side effects.
|
||||
* False outside the dispatcher; UART0 dispatch remains physically trusted. */
|
||||
bool admin_ssh_console_dispatch_is_current(void);
|
||||
/* Shared parsed browser account policy: dispatcher admission + handler defense. */
|
||||
bool admin_ssh_console_web_user_command_allowed(
|
||||
size_t argc, char **argv, const user_principal_t *principal);
|
||||
esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
const char *prompt, uint8_t *output, size_t capacity,
|
||||
bool hidden, size_t *output_length);
|
||||
|
||||
+27
-3
@@ -219,6 +219,15 @@ static int bootstrap(bool generated)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t mutation_currentness(void)
|
||||
{
|
||||
if (admin_ssh_console_dispatch_is_remote() &&
|
||||
!admin_ssh_console_dispatch_is_current()) {
|
||||
return ESP_ERR_NOT_ALLOWED;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int add_user(const char *username, const char *role_text, bool generated)
|
||||
{
|
||||
user_role_t role;
|
||||
@@ -239,6 +248,7 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_create((const uint8_t *)username, strlen(username),
|
||||
role, password, password_length);
|
||||
@@ -268,6 +278,7 @@ static int change_password(const char *username, bool generated)
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_password((const uint8_t *)username,
|
||||
strlen(username),
|
||||
@@ -388,6 +399,14 @@ static int command_user_inner(int argc, char **argv)
|
||||
{
|
||||
bool remote = admin_ssh_console_dispatch_is_remote();
|
||||
const user_principal_t *principal = admin_ssh_console_dispatch_principal();
|
||||
/* Repeat admission on canonical parsed arguments: direct handler calls must
|
||||
* not bypass self-target, generated-secret or UART0-only restrictions. */
|
||||
if (admin_ssh_console_dispatch_is_web() &&
|
||||
(!admin_ssh_console_web_user_command_allowed((size_t)argc, argv, principal) ||
|
||||
!admin_ssh_console_dispatch_is_current())) {
|
||||
printf("Browser account command restricted or session no longer current.\n");
|
||||
return 1;
|
||||
}
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) ||
|
||||
(argc == 2 && strcmp(argv[1], "list") == 0)) {
|
||||
return show_users(NULL);
|
||||
@@ -425,7 +444,10 @@ static int command_user_inner(int argc, char **argv)
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "delete") == 0 &&
|
||||
strcmp(argv[3], "--force") == 0) {
|
||||
esp_err_t error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
|
||||
esp_err_t error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete user (the migrated or final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
@@ -443,8 +465,10 @@ static int command_user_inner(int argc, char **argv)
|
||||
printf("Role must be user or admin.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = user_database_set_role((const uint8_t *)argv[2],
|
||||
strlen(argv[2]), role);
|
||||
esp_err_t error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_role((const uint8_t *)argv[2], strlen(argv[2]), role);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change role (the final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/* Included by accounts.py after extracted production functions. */
|
||||
static void reset(void)
|
||||
{
|
||||
memset(&s_database, 0, sizeof(s_database));
|
||||
memset(&candidate_storage, 0, sizeof(candidate_storage));
|
||||
s_candidate=&candidate_storage; s_mutex=(void *)1; s_initialized=true;
|
||||
s_database.version=USER_DATABASE_SCHEMA_VERSION;
|
||||
s_database.size=sizeof(s_database); s_database.generation=1;
|
||||
s_database.admin_bootstrapped=1;
|
||||
fail_stage=0; invalidate_during_derivation=false; derivation_invalidations=0;
|
||||
assert(initialize_user(&s_database.users[0], (const uint8_t *)"admin", 5,
|
||||
USER_ROLE_ADMIN, (const uint8_t *)"test-password", 13)==ESP_OK);
|
||||
assert(initialize_user(&s_database.users[1], (const uint8_t *)"other", 5,
|
||||
USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK);
|
||||
assert(initialize_user(&s_database.users[2], (const uint8_t *)"observer", 8,
|
||||
USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK);
|
||||
recount(&s_database);
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
actor=(user_principal_t){.role=USER_ROLE_ADMIN, .username_length=5,
|
||||
.username="admin", .user_id=s_database.users[0].user_id,
|
||||
.auth_generation=s_database.users[0].auth_generation};
|
||||
writes=commits=prompts=checks=web_revokes=ssh_revokes=0;
|
||||
revoke_prompt=revoke_check=0; owner_current=true; remote=web=true;
|
||||
mismatch=cancel_prompt=stale_prompt=false; notify_error=ESP_OK;
|
||||
memset(revoked_name,0,sizeof(revoked_name));
|
||||
}
|
||||
static int run(const char *line)
|
||||
{
|
||||
char copy[257]; char *argv[10]={0};
|
||||
assert(strlen(line)<sizeof(copy)); strcpy(copy,line);
|
||||
size_t argc=esp_console_split_argv(copy,argv,10);
|
||||
/* Direct canonical handler, deliberately bypassing dispatcher policy. */
|
||||
return command_user((int)argc,argv);
|
||||
}
|
||||
static void unchanged(const stored_database_t *before)
|
||||
{
|
||||
assert(!memcmp(before,&s_database,sizeof(*before)));
|
||||
assert(!web_revokes && !ssh_revokes);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
assert(!locks);
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
const char *supported[]={
|
||||
"user add fresh user", "user add fresh admin", "user password other",
|
||||
"user delete other --force", "user role other admin --force",
|
||||
"\"user\" \"password\" \"other\"", "user role other user --force",
|
||||
};
|
||||
for (size_t i=0;i<sizeof(supported)/sizeof(*supported);++i) {
|
||||
reset(); stored_database_t before=s_database;
|
||||
assert(run(supported[i])==0);
|
||||
assert(web_revokes==1 && ssh_revokes==1);
|
||||
assert(!strcmp(revoked_name,i<2 ? "fresh" : "other"));
|
||||
assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t)));
|
||||
assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t)));
|
||||
assert(admin_ssh_console_dispatch_is_current());
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
if (i<2) {
|
||||
assert(s_database.user_count==before.user_count+1);
|
||||
int fresh=find_user(&s_database,(const uint8_t *)"fresh",5);
|
||||
assert(fresh>=0 && s_database.users[fresh].role==(i==0 ? USER_ROLE_USER : USER_ROLE_ADMIN));
|
||||
} else if (i==3) {
|
||||
assert(find_user(&s_database,(const uint8_t *)"other",5)<0);
|
||||
} else if (i!=6) {
|
||||
assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1);
|
||||
} else {
|
||||
assert(!writes && !commits); /* Existing no-op role still revokes target. */
|
||||
}
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
assert(!locks);
|
||||
}
|
||||
const char *denied[]={
|
||||
"user password admin", "\"user\" \"password\" \"admin\"",
|
||||
"user delete admin --force", "user role admin user --force",
|
||||
"user role admin admin --force", "user add admin admin",
|
||||
"user password admin --generate", "user password other --generate",
|
||||
"user add fresh user --generate", "user key add other",
|
||||
"user key add other ssh-ed25519 AAAA", "user key delete other 0 --force",
|
||||
"user key clear other --force", "user bootstrap", "user bootstrap --generate",
|
||||
"user recover --force", "user password other extra",
|
||||
"user role other admin --force extra", "user delete other --force extra",
|
||||
"user add fresh invalid", "user add fresh user extra",
|
||||
"user delete other", "user role other user",
|
||||
"user role \"admin\" user --force", "user add \"admin\" user",
|
||||
};
|
||||
for (size_t i=0;i<sizeof(denied)/sizeof(*denied);++i) {
|
||||
reset(); stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
assert(run(denied[i])!=0); unchanged(&before);
|
||||
assert(!prompts && !writes && !commits && random_calls==rng);
|
||||
}
|
||||
reset();
|
||||
actor.username_length=0;
|
||||
assert(run("user password other")!=0 && !prompts && !writes);
|
||||
reset();
|
||||
actor.username_length=USER_DATABASE_USERNAME_CAPACITY+1;
|
||||
assert(run("user password other")!=0 && !prompts && !writes);
|
||||
reset();
|
||||
/* Self defense is identity-based, not a hard-coded 'admin' name. */
|
||||
memcpy(s_database.users[0].username,"chief",5);
|
||||
memcpy(actor.username,"chief",5);
|
||||
assert(run("user password \"chief\"")!=0 && !prompts && !writes);
|
||||
assert(run("user role \"chief\" admin --force")!=0 && !writes);
|
||||
const char *prompted[]={"user add fresh admin", "user password other"};
|
||||
for (size_t i=0;i<2;++i) {
|
||||
for (unsigned mode=0;mode<7;++mode) {
|
||||
reset(); stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
if (mode<2) revoke_prompt=mode+1;
|
||||
if (mode==2) revoke_check=2; /* After both successful prompts. */
|
||||
if (mode==3) mismatch=true;
|
||||
if (mode==4) cancel_prompt=true;
|
||||
if (mode==5) { ++actor.auth_generation; }
|
||||
if (mode==6) stale_prompt=true;
|
||||
assert(run(prompted[i])!=0); unchanged(&before);
|
||||
assert(!writes && !commits && random_calls==rng);
|
||||
}
|
||||
}
|
||||
/* Operation admission is the final post-prompt check before the database
|
||||
* API, not the later NVS commit. Once admitted, expiry/closure during
|
||||
* derivation does not cancel the transaction. No browser receipt is proved. */
|
||||
for (size_t i=0;i<2;++i) {
|
||||
for (unsigned stage=0;stage<=3;++stage) {
|
||||
reset(); stored_database_t before=s_database;
|
||||
invalidate_during_derivation=true; fail_stage=stage;
|
||||
int result=run(prompted[i]);
|
||||
assert(derivation_invalidations==1 && !owner_current && checks==2);
|
||||
assert(prompts==2 && !locks);
|
||||
if (stage==0) {
|
||||
assert(result==0 && writes==1 && commits==1);
|
||||
assert(s_database.generation==before.generation+1);
|
||||
if (i==0) {
|
||||
int fresh=find_user(&s_database,(const uint8_t *)"fresh",5);
|
||||
assert(fresh>=0 && s_database.users[fresh].role==USER_ROLE_ADMIN);
|
||||
assert(s_database.user_count==before.user_count+1);
|
||||
assert(!memcmp(&before.users[1],&s_database.users[1],sizeof(stored_user_t)));
|
||||
} else {
|
||||
assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1);
|
||||
assert(memcmp(before.users[1].password_salt,s_database.users[1].password_salt,
|
||||
sizeof(before.users[1].password_salt))!=0);
|
||||
}
|
||||
assert(web_revokes==1 && ssh_revokes==1);
|
||||
assert(!strcmp(revoked_name,i==0 ? "fresh" : "other"));
|
||||
assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t)));
|
||||
assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t)));
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
} else {
|
||||
assert(result!=0); unchanged(&before);
|
||||
assert(commits==(stage==3 ? 1U : 0U));
|
||||
}
|
||||
/* Loss of liveness cannot authorize a subsequent operation. */
|
||||
stored_database_t after=s_database;
|
||||
unsigned prior_writes=writes, prior_commits=commits;
|
||||
unsigned prior_web=web_revokes, prior_ssh=ssh_revokes;
|
||||
assert(run("user password observer")!=0);
|
||||
assert(!memcmp(&after,&s_database,sizeof(after)));
|
||||
assert(prompts==2 && writes==prior_writes && commits==prior_commits);
|
||||
assert(web_revokes==prior_web && ssh_revokes==prior_ssh);
|
||||
}
|
||||
}
|
||||
/* Every enabled mutation fails closed when the originating session or copied
|
||||
* account is stale BEFORE operation admission, including forced mutations. */
|
||||
for (size_t i=0;i<sizeof(supported)/sizeof(*supported);++i) {
|
||||
reset(); stored_database_t before=s_database; owner_current=false;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!prompts && !writes);
|
||||
reset(); before=s_database; revoke_check=2;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!writes && !commits);
|
||||
reset(); before=s_database; actor.role=USER_ROLE_USER;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!prompts && !writes);
|
||||
}
|
||||
for (size_t i=0;i<5;++i) {
|
||||
for (unsigned stage=1;stage<=3;++stage) {
|
||||
reset(); stored_database_t before=s_database; fail_stage=stage;
|
||||
assert(run(supported[i])!=0); unchanged(&before);
|
||||
assert(commits==(stage==3 ? 1U : 0U));
|
||||
}
|
||||
}
|
||||
for (size_t i=0;i<3;++i) {
|
||||
for (unsigned stage=4;stage<=5;++stage) {
|
||||
reset(); stored_database_t before=s_database; fail_stage=stage;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!writes && !commits);
|
||||
}
|
||||
}
|
||||
reset(); notify_error=ESP_FAIL;
|
||||
assert(run("user password other")==0);
|
||||
assert(commits==1 && web_revokes==1 && ssh_revokes==1);
|
||||
assert(s_database.users[1].auth_generation==2);
|
||||
/* Real database invariants, independent of browser self-target policy. */
|
||||
reset(); stored_database_t before=s_database;
|
||||
assert(user_database_delete((const uint8_t *)"admin",5)!=ESP_OK); unchanged(&before);
|
||||
assert(user_database_set_role((const uint8_t *)"admin",5,USER_ROLE_USER)!=ESP_OK);
|
||||
unchanged(&before); assert(!writes && !commits);
|
||||
/* Trusted UART0 bypasses browser admission, not database invariants. */
|
||||
reset(); web=false; remote=false;
|
||||
assert(run("user delete admin --force")!=0); assert(!writes && !web_revokes);
|
||||
reset(); web=false; remote=false;
|
||||
assert(run("user role admin user --force")!=0); assert(!writes && !web_revokes);
|
||||
/* Normal UART0 and SSH prompted nonself commands still use the same handler. */
|
||||
reset(); web=false; assert(run("user password other")==0);
|
||||
reset(); web=false; remote=false; owner_current=false;
|
||||
assert(run("user password other")==0);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical account handlers + production DB transactions; deterministic IO/NVS/crypto.
|
||||
|
||||
Not a concurrent RTOS, cryptographic, real-NVS or target test. Run directly.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
|
||||
def function(source, name):
|
||||
start = source.index(name + "(")
|
||||
start = source.rfind("\n", 0, start) + 1
|
||||
return source[start:source.index("\n}", start) + 2] + "\n"
|
||||
|
||||
def strip_includes(text):
|
||||
return "\n".join(line for line in text.splitlines()
|
||||
if not line.startswith(("#include", "#pragma once")))
|
||||
|
||||
db = (ROOT / "src/user_database.c").read_text()
|
||||
console = (ROOT / "src/user_console.c").read_text()
|
||||
admin = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
prelude = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
|
||||
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION };
|
||||
typedef void *SemaphoreHandle_t;
|
||||
#define portMAX_DELAY 0
|
||||
#define NVS_READWRITE 1
|
||||
typedef int nvs_handle_t;
|
||||
static unsigned locks, writes, commits, random_calls, prompts, checks, web_revokes, ssh_revokes;
|
||||
static unsigned fail_stage, revoke_prompt, revoke_check, derivation_invalidations;
|
||||
static bool invalidate_during_derivation;
|
||||
static bool owner_current = true, remote = true, web = true, mismatch, cancel_prompt, stale_prompt;
|
||||
static int notify_error = ESP_OK;
|
||||
static char revoked_name[17];
|
||||
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
static void xSemaphoreTake(void *m, int t) { (void)m; (void)t; assert(!locks++); }
|
||||
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
|
||||
static int nvs_open(const char *ns, int mode, int *h) {
|
||||
(void)ns; (void)mode; assert(locks);
|
||||
if (invalidate_during_derivation) {
|
||||
assert(derivation_invalidations==1 && !owner_current);
|
||||
}
|
||||
*h=1; return fail_stage==1 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static int nvs_set_blob(int h, const char *key, const void *data, size_t n) {
|
||||
(void)h; (void)key; (void)data; (void)n; ++writes; return fail_stage==2 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static int nvs_commit(int h) { (void)h; ++commits; return fail_stage==3 ? ESP_FAIL : ESP_OK; }
|
||||
static void nvs_close(int h) { (void)h; }
|
||||
static int secure_random_fill(void *p, size_t n) {
|
||||
memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
|
||||
uint32_t iterations, uint8_t *hash) {
|
||||
(void)p; (void)n; (void)s; (void)iterations; memset(hash, 7, 32);
|
||||
if (invalidate_during_derivation) {
|
||||
/* Model originating browser expiry/closure after operation admission.
|
||||
* This is a deterministic derivation double, not real PBKDF2/HTTPD. */
|
||||
assert(locks==1 && prompts==2 && checks==2 && owner_current);
|
||||
assert(!writes && !commits);
|
||||
owner_current=false;
|
||||
++derivation_invalidations;
|
||||
}
|
||||
return fail_stage==5 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
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;
|
||||
}
|
||||
'''
|
||||
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; }
|
||||
static bool admin_ssh_console_dispatch_is_web(void) { return web; }
|
||||
static const user_principal_t *admin_ssh_console_dispatch_principal(void) { return remote ? &actor : NULL; }
|
||||
static bool admin_ssh_console_dispatch_is_current(void) {
|
||||
bool current=false; ++checks;
|
||||
if (checks==revoke_check) owner_current=false;
|
||||
return owner_current && user_database_principal_is_current(&actor, ¤t)==ESP_OK && current;
|
||||
}
|
||||
static int admin_command_gate_take(void) { return ESP_OK; }
|
||||
static void admin_command_gate_give(void) {}
|
||||
static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap,
|
||||
size_t min, size_t max, size_t *n) {
|
||||
(void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts;
|
||||
memcpy(out, "test-password", 13); *n=13;
|
||||
if (mismatch && prompts==2) out[0]='X';
|
||||
/* Simulate invalidation just after the prompt boundary returned success. */
|
||||
if (prompts==revoke_prompt) owner_current=false;
|
||||
if (stale_prompt && prompts==2) ++actor.auth_generation;
|
||||
return cancel_prompt ? ESP_ERR_INVALID_STATE : ESP_OK;
|
||||
}
|
||||
static int web_serial_transport_revoke_user(const uint8_t *u, size_t n) {
|
||||
++web_revokes; assert(n<sizeof(revoked_name)); memcpy(revoked_name,u,n); revoked_name[n]=0;
|
||||
assert(strcmp(revoked_name,"admin")); return notify_error;
|
||||
}
|
||||
static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
|
||||
++ssh_revokes; assert(strlen(revoked_name)==n && !memcmp(u,revoked_name,n)); return notify_error;
|
||||
}
|
||||
/* Forbidden paths are traps rather than alternative implementations. */
|
||||
static int show_users(const char *n) { (void)n; return 0; }
|
||||
static int recover_database(void) { assert(!"recovery"); return 1; }
|
||||
static int bootstrap(bool g) { (void)g; assert(!"bootstrap"); return 1; }
|
||||
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; }
|
||||
static int add_key_parts(const char *n,const uint8_t *t,size_t tl,const uint8_t *b,size_t bl) {
|
||||
(void)n; (void)t; (void)tl; (void)b; (void)bl; assert(!"key mutation"); return 1;
|
||||
}
|
||||
esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r,user_database_generated_password_t *p) {
|
||||
(void)u; (void)n; (void)r; (void)p; assert(!"generated credential"); return ESP_FAIL;
|
||||
}
|
||||
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_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",
|
||||
"user_database_principal_is_current", "create_locked", "user_database_create",
|
||||
"mutate_user_begin", "user_database_delete", "user_database_set_role",
|
||||
"user_database_set_password"]
|
||||
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"]
|
||||
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()
|
||||
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)
|
||||
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: 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")
|
||||
@@ -132,6 +132,36 @@ static void test_currentness(void)
|
||||
admin_ssh_console_close(&a);
|
||||
puts("PASS: owner stale/account current isolation, callback close/reuse, revoked submitted prompts, periodic unanswered invalidation/stale wake, UART recovery, consumed output wiping");
|
||||
}
|
||||
static void test_dispatch_currentness(void)
|
||||
{
|
||||
++a.slot_generation; live[0]=true; principal_current=true;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch();
|
||||
assert(admin_ssh_console_dispatch_is_current());
|
||||
current_task=(void *)2;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
current_task=s_task;
|
||||
live[0]=false;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
assert(!s_sessions[0].active);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
++a.slot_generation; live[0]=true;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); principal_current=false;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
assert(!s_sessions[0].active);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); principal_current=true;
|
||||
++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); s_dispatch_token.slot_generation--;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
assert(s_sessions[0].active); /* Stale dispatch cannot close replacement. */
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
s_dispatch_remote=false;
|
||||
assert(admin_ssh_console_dispatch_is_current()); /* Trusted UART0. */
|
||||
puts("PASS: handler currentness API rejects wrong task, stale owner/account/token; preserves replacement and UART0");
|
||||
}
|
||||
|
||||
static void test_shared_admission(void)
|
||||
{
|
||||
admin_ssh_console_token_t web={.slot_index=255, .session_id=7,
|
||||
@@ -220,7 +250,12 @@ int main(void)
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply;
|
||||
uint8_t secret[32]; size_t n;
|
||||
uint8_t history_before[sizeof(s_sessions[0].history)];
|
||||
memcpy(history_before,s_sessions[0].history,sizeof(history_before));
|
||||
assert(admin_ssh_console_dispatch_is_current());
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK);
|
||||
assert(!memcmp(history_before,s_sessions[0].history,sizeof(history_before)));
|
||||
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
|
||||
assert(n==6 && !memcmp(secret,"secret",6));
|
||||
assert(s_sessions[0].output_length==strlen("Password: \r\n"));
|
||||
clear_output(&a);
|
||||
@@ -277,5 +312,6 @@ int main(void)
|
||||
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
|
||||
assert(!lock_depth);
|
||||
test_currentness();
|
||||
test_dispatch_currentness();
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import tempfile
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
source = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
start = source.index("bool admin_ssh_console_web_user_command_allowed(")
|
||||
policy = source[start:source.index("\n}", start) + 2]
|
||||
start = source.index("static bool remote_command_allowed(")
|
||||
helper = source[start:source.index("\n}", start) + 2]
|
||||
prelude = r'''
|
||||
@@ -24,7 +26,11 @@ prelude = r'''
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
|
||||
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
|
||||
#define USER_DATABASE_USERNAME_CAPACITY 16U
|
||||
#define USER_ROLE_ADMIN 2
|
||||
typedef struct { int role; size_t username_length; char username[17]; } user_principal_t;
|
||||
typedef struct {
|
||||
user_principal_t principal;
|
||||
struct { uint8_t transport; } token;
|
||||
char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
} admin_request_t;
|
||||
@@ -57,6 +63,9 @@ int main(void) {
|
||||
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
||||
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
|
||||
"wifi status", "mdns status", "\"web\" \"status\"",
|
||||
"user add other user", "user add other admin", "user password other",
|
||||
"user delete other --force", "user role other user --force",
|
||||
"user role other admin --force", "\"user\" \"password\" \"other\"",
|
||||
"web certificate rotate --force",
|
||||
" \"web\" \"certificate\" \"rotate\" \"--force\" ",
|
||||
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
|
||||
@@ -73,9 +82,15 @@ int main(void) {
|
||||
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
||||
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
||||
"reboot --force", "user bootstrap", "user recover --force",
|
||||
"user add other admin --generate", "user delete other --force",
|
||||
"user role other user --force", "user password admin --generate",
|
||||
"user password other", "user key add admin", "user key clear admin --force",
|
||||
"user add other admin --generate", "user delete other",
|
||||
"user role other user", "user password admin --generate",
|
||||
"user password admin", "user password other --generate",
|
||||
"user delete admin --force", "user role admin admin --force",
|
||||
"user role admin user --force", "user add admin admin",
|
||||
"\"user\" \"password\" \"admin\"", "user password other extra",
|
||||
"user add other invalid", "user add other user extra",
|
||||
"user delete other --force extra", "user role other admin --force extra",
|
||||
"user key add admin", "user key clear admin --force",
|
||||
"user key delete admin 0 --force", "user list extra", "user show admin extra",
|
||||
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
||||
" \"user\" \"password\" \"admin\" \"--generate\"",
|
||||
@@ -85,12 +100,16 @@ int main(void) {
|
||||
};
|
||||
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
|
||||
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
request.principal = (user_principal_t){.role=USER_ROLE_ADMIN,
|
||||
.username_length=5, .username="admin"};
|
||||
strcpy(request.line,web_allowed[i]);
|
||||
assert(remote_command_allowed(&request));
|
||||
assert(!strcmp(request.line,web_allowed[i]));
|
||||
}
|
||||
for (size_t i=0; i<sizeof(web_denied)/sizeof(web_denied[0]); ++i) {
|
||||
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
request.principal = (user_principal_t){.role=USER_ROLE_ADMIN,
|
||||
.username_length=5, .username="admin"};
|
||||
strcpy(request.line,web_denied[i]);
|
||||
if (remote_command_allowed(&request)) fprintf(stderr,"Unexpected allow: %s\n",request.line);
|
||||
assert(!remote_command_allowed(&request));
|
||||
@@ -100,12 +119,12 @@ int main(void) {
|
||||
assert(remote_command_allowed(&request) ==
|
||||
(strstr(request.line,"bootstrap")==NULL && strstr(request.line,"recover")==NULL));
|
||||
}
|
||||
puts("PASS: SSH policy unchanged; web read-only exceptions, mutations/lifecycle and quoted forms checked with actual IDF parser");
|
||||
puts("PASS: SSH policy unchanged; web bounded account forms, restrictions/lifecycle and quoted forms checked with actual IDF parser");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "test.c").write_text(prelude + helper + cases)
|
||||
(path / "test.c").write_text(prelude + policy + helper + cases)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
|
||||
Reference in New Issue
Block a user