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:
2026-09-07 10:03:45 +02:00
parent 326119812f
commit fe1e2d98b4
11 changed files with 533 additions and 13 deletions
+202
View File
@@ -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;
}
+166
View File
@@ -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, &current)==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")
+36
View File
@@ -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");
}
+24 -5
View File
@@ -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)