Files
ESP32_Serial_Swiss_Army_Knife/tests/admin_console_boundary/accounts.c
T
Commander1024 fe1e2d98b4 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.
2026-09-07 10:03:45 +02:00

203 lines
11 KiB
C

/* 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;
}