Implement SSH authorized key management

This commit is contained in:
2026-09-08 16:37:47 +02:00
parent 22a7c7b0a5
commit 42f6423d4e
19 changed files with 930 additions and 83 deletions
+126
View File
@@ -0,0 +1,126 @@
/* Included in accounts.py's canonical DB/console transaction harness.
* Production SSH parsing, with OpenSSL-backed curve/SHA adapters, not mbedTLS. */
static user_database_account_t key_target(void)
{
user_database_accounts_t accounts;
assert(user_database_get_accounts(&accounts)==ESP_OK);
return accounts.users[1];
}
static size_t ssh_string(uint8_t *out, const void *value, size_t length)
{
out[0]=out[1]=out[2]=0; out[3]=(uint8_t)length;
memcpy(out+4,value,length); return length+4;
}
static void stale_keys(const user_database_account_t *target, const uint8_t *blob, size_t length)
{
stored_database_t before=s_database;
unsigned saved=commits;
uint8_t index=0;
user_database_user_snapshot_t snapshot;
assert(user_database_add_ssh_key_current(target,s_ed25519_type,11,blob,length,&index)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(target,0)==ESP_ERR_NOT_FOUND);
assert(user_database_clear_ssh_keys_current(target)==ESP_ERR_NOT_FOUND);
memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(target,&snapshot)==ESP_ERR_NOT_FOUND);
assert(all_zero(&snapshot,sizeof(snapshot)) && commits==saved); unchanged(&before);
}
static void typed_key_tests(void)
{
reset();
uint8_t ed[128]={0}, p256[128]={0}, point[65], public[32]={1};
size_t en=ssh_string(ed,s_ed25519_type,11); en+=ssh_string(ed+en,public,32);
EC_GROUP *group=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
assert(group && EC_POINT_point2oct(group,EC_GROUP_get0_generator(group),POINT_CONVERSION_UNCOMPRESSED,point,sizeof(point),NULL)==65);
EC_GROUP_free(group);
size_t pn=ssh_string(p256,s_ecdsa_type,19);
pn+=ssh_string(p256+pn,s_ecdsa_curve,8); pn+=ssh_string(p256+pn,point,65);
assert(user_database_key_valid(s_ed25519_type,11,ed,en));
assert(user_database_key_valid(s_ecdsa_type,19,p256,pn));
for (size_t n=0;n<en;++n) assert(!user_database_key_valid(s_ed25519_type,11,ed,n));
for (size_t n=0;n<pn;++n) assert(!user_database_key_valid(s_ecdsa_type,19,p256,n));
assert(!user_database_key_valid(s_ed25519_type,11,ed,en+1));
assert(!user_database_key_valid(s_ecdsa_type,19,p256,pn+1));
assert(!user_database_key_valid(s_ed25519_type,11,p256,pn));
assert(!user_database_key_valid((const uint8_t *)"ssh-rsa",7,ed,en));
uint8_t bad[129]; memcpy(bad,p256,pn); bad[pn-65]=2;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad+pn-64,0,64); bad[pn-65]=4;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memcpy(bad,p256,pn); bad[27]='x';
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad,0xff,sizeof(bad));
assert(!user_database_key_valid(s_ed25519_type,11,bad,sizeof(bad)));
user_database_account_t target=key_target();
user_database_user_snapshot_t snapshot;
snapshot_busy=true; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_TIMEOUT && last_wait==0);
assert(all_zero(&snapshot,sizeof(snapshot))); snapshot_busy=false;
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && last_wait==0 && !snapshot.public_key_count);
uint8_t index=255;
stored_database_t invalid_before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,bad,sizeof(bad),&index)==ESP_ERR_INVALID_ARG);
unchanged(&invalid_before);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==0);
stale_keys(&target,ed,en); target=key_target();
user_principal_t authenticated; bool authorized=false;
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&authenticated,&authorized)==ESP_OK && authorized);
assert(authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY && authenticated.auth_generation==target.auth_generation);
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==1);
uint8_t digest[32]; assert(SHA256(ed,en,digest));
assert(snapshot.public_keys[0].active && !strcmp(snapshot.public_keys[0].key_type,"ssh-ed25519"));
assert(!memcmp(snapshot.public_keys[0].sha256_fingerprint,digest,32));
unsigned saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY && commits==saved);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target();
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && authorized);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"observer",8,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
ed[en-1]=2;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==2);
target=key_target(); saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY);
ed[en-1]=3;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_NO_MEM && commits==saved);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_FAIL); unchanged(&before);
assert(user_database_clear_ssh_keys_current(&target)==ESP_FAIL); unchanged(&before);
}
fail_stage=0;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_OK);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
stale_keys(&target,ed,en); target=key_target();
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==2);
assert(!snapshot.public_keys[1].active && snapshot.public_keys[2].index==2);
assert(user_database_remove_ssh_key_current(&target,1)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(&target,3)==ESP_ERR_INVALID_ARG);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target(); assert(user_database_clear_ssh_keys_current(&target)==ESP_OK);
stale_keys(&target,ed,en); target=key_target(); saved=commits;
assert(user_database_clear_ssh_keys_current(&target)==ESP_OK && commits==saved);
assert(user_database_delete_current(&target)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_role_current(&target,USER_ROLE_ADMIN)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_password_current(&target,(const uint8_t *)"test-password",13)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); target.user_id=0; stale_keys(&target,ed,en);
target=key_target(); target.auth_generation=0; stale_keys(&target,ed,en);
target=key_target(); memset(target.username,'x',sizeof(target.username));
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_INVALID_ARG);
assert(user_database_remove_ssh_key_current(&target,0)==ESP_ERR_INVALID_ARG);
assert(user_database_clear_ssh_keys_current(NULL)==ESP_ERR_INVALID_ARG);
assert(user_database_get_account_keys(NULL,&snapshot)==ESP_ERR_INVALID_ARG && all_zero(&snapshot,sizeof(snapshot)));
/* Legacy CLI APIs retain the exact transaction path and generation changes. */
target=key_target(); assert(user_database_add_ssh_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&index)==ESP_OK);
stale_keys(&target,ed,en);
assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK);
assert(user_database_clear_ssh_keys((const uint8_t *)"other",5)==ESP_OK);
s_initialized=false; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_INVALID_STATE && all_zero(&snapshot,sizeof(snapshot)));
}
+36 -14
View File
@@ -80,16 +80,33 @@ static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
}
return fail_stage==5 ? ESP_FAIL : ESP_OK;
}
#include <openssl/sha.h>
#include <openssl/ec.h>
#include <openssl/obj_mac.h>
typedef EC_GROUP *mbedtls_ecp_group;
typedef struct { EC_POINT *point; } mbedtls_ecp_point;
#define MBEDTLS_ECP_DP_SECP256R1 1
static void mbedtls_ecp_group_init(mbedtls_ecp_group *g) { *g=NULL; }
static void mbedtls_ecp_point_init(mbedtls_ecp_point *p) { p->point=NULL; }
static int mbedtls_ecp_group_load(mbedtls_ecp_group *g, int id) {
assert(id==1); *g=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); return *g ? 0 : -1;
}
static int mbedtls_ecp_point_read_binary(mbedtls_ecp_group *g, mbedtls_ecp_point *p, const uint8_t *b, size_t n) {
p->point=EC_POINT_new(*g); return p->point && EC_POINT_oct2point(*g,p->point,b,n,NULL)==1 ? 0 : -1;
}
static int mbedtls_ecp_check_pubkey(mbedtls_ecp_group *g, mbedtls_ecp_point *p) {
return EC_POINT_is_at_infinity(*g,p->point)==0 && EC_POINT_is_on_curve(*g,p->point,NULL)==1 ? 0 : -1;
}
static void mbedtls_ecp_point_free(mbedtls_ecp_point *p) { EC_POINT_free(p->point); }
static void mbedtls_ecp_group_free(mbedtls_ecp_group *g) { EC_GROUP_free(*g); }
static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) {
(void)p; (void)n; (void)h; (void)mode; assert(!"keys outside slice"); return -1;
assert(mode==0); return SHA256(p,n,h) ? 0 : -1;
}
'''
header = strip_includes((ROOT / "src/user_database.h").read_text())
state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")]
fakes = r'''
bool user_database_key_valid(const uint8_t *t, size_t tn, const uint8_t *b, size_t bn) {
(void)t; (void)tn; (void)b; (void)bn; assert(!"keys outside slice"); return false;
}
static stored_database_t candidate_storage;
static user_principal_t actor;
static bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
@@ -133,16 +150,11 @@ esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r
esp_err_t user_database_generate_password(const uint8_t *u,size_t n,user_database_generated_password_t *p) {
(void)u; (void)n; (void)p; assert(!"generated credential"); return ESP_FAIL;
}
esp_err_t user_database_remove_ssh_key(const uint8_t *u,size_t n,uint8_t i) {
(void)u; (void)n; (void)i; assert(!"key mutation"); return ESP_FAIL;
}
esp_err_t user_database_clear_ssh_keys(const uint8_t *u,size_t n) {
(void)u; (void)n; assert(!"key mutation"); return ESP_FAIL;
}
size_t esp_console_split_argv(char *, char **, size_t);
'''
db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_password_valid",
"user_database_password_valid", "read_ssh_string", "user_database_key_valid",
"user_role_to_string", "user_role_parse", "set_record_password", "find_user",
"find_free_user", "stored_keys_equal", "validate_database", "recount",
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
@@ -151,7 +163,11 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
"user_database_delete_current", "user_database_set_role_current",
"set_password", "user_database_set_password", "user_database_set_password_current",
"user_database_generate_password_value"]
"user_database_generate_password_value",
"user_database_get_account_keys", "add_ssh_key", "remove_ssh_key", "clear_ssh_keys",
"user_database_add_ssh_key", "user_database_remove_ssh_key", "user_database_clear_ssh_keys",
"key_target_valid", "user_database_add_ssh_key_current", "user_database_remove_ssh_key_current",
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key"]
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "command_user_inner", "command_user"]
@@ -159,15 +175,21 @@ unit = prelude + header + "\n" + state + fakes
unit += "\n".join(function(db, n) for n in db_names)
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
unit += "\n".join(function(console, n) for n in console_names)
unit += (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();')
assert ' typed_key_tests();' in account_tests
unit += account_tests
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
path = Path(directory)
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-o", str(path / "test")], check=True, timeout=30)
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
assert "test-password" not in result.stdout
assert "Generated password for" not in result.stdout
print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)")
print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)")
print("PASS: canonical parsed accounts + production DB transactions: nonself isolation, prompt revocation/cancel/mismatch, currentness, persistence/RNG/derive failures, final-admin invariants, self/generated/key/recovery traps; no password output")
+52 -18
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 20:
raise RuntimeError('Review URI extraction: expected 18 descriptors and two tables')
if len(uri_tables) != 21:
raise RuntimeError('Review URI extraction: expected 19 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -109,7 +109,9 @@ HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handle
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
HANDLER(web_account_generate_password_handler)
HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler)
static unsigned keys_calls;
static bool keys_fail;
static unsigned account_calls, account_fail_at;
static unsigned generation_calls;
static bool generation_fail;
@@ -124,7 +126,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 23 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->servercert_len == 1 && config->servercert[0] == 1);
@@ -165,6 +167,14 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
if (uri->handler == web_account_keys_handler) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/accounts/keys") && uri->method == HTTP_POST);
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
++keys_calls;
if (keys_fail) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri; return ESP_OK;
}
if (uri->handler == web_account_generate_password_handler) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/accounts/generate-password") && uri->method == HTTP_POST);
@@ -247,7 +257,8 @@ static void reset(void) {
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
account_calls = account_fail_at = generation_calls = 0; generation_fail = false;
account_calls = account_fail_at = generation_calls = keys_calls = 0;
generation_fail = keys_fail = false;
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
@@ -281,9 +292,10 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 23 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 24 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
@@ -334,7 +346,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 21 && unregister_calls == failure - 17);
assert(registered_count == 22 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
@@ -343,13 +355,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 23 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 24 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 22);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 23);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -361,7 +373,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 23 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 24 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -383,7 +395,7 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 22);
assert(settings_calls == 1 && registered_count == 23);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
@@ -391,7 +403,7 @@ int main(void) {
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 21 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 22 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
@@ -399,24 +411,26 @@ int main(void) {
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
for (unsigned failure = 1; failure <= 3; ++failure) {
reset(); account_calls = 0; account_fail_at = failure; start();
assert(account_calls == failure && registered_count == (failure == 1 ? 20 : 21));
assert(account_calls == failure && registered_count == (failure == 1 ? 21 : 22));
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
assert(web_server_stop() == ESP_OK);
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
assert(registered_count == 23 && account_calls == 3);
assert(registered_count == 24 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 22 && auth_live && serial_live && admin_owned);
assert(registered_count == 23 && auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
reset(); generation_fail = true; start();
assert(generation_calls == 1 && registered_count == 22 && account_calls == 3);
assert(generation_calls == 1 && registered_count == 23 && account_calls == 3);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
unsigned account_mutations = 0;
@@ -427,11 +441,31 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
generation_fail = false; fresh_registration(); start();
assert(generation_calls == 2 && registered_count == 23);
assert(generation_calls == 2 && registered_count == 24);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
puts("15 lifecycle groups passed (16 required fatal positions, 9 optional routes, plus failed unregister)");
reset(); keys_fail = true; start();
assert(keys_calls == 1 && registered_count == 23 && account_calls == 3 && generation_calls == 1);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(route("/ws/serial")->handler == websocket_handler);
assert(route("/ws/admin")->handler == web_admin_transport_upgrade_handler);
account_mutations = 0;
for (unsigned i = 0; i < registered_count; ++i) {
assert(strcmp(registered[i]->uri, "/api/settings/accounts/keys"));
if (!strcmp(registered[i]->uri, "/api/settings/account-operation") && registered[i]->method == HTTP_POST)
++account_mutations;
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
keys_fail = false; fresh_registration(); start();
assert(keys_calls == 2 && registered_count == 24);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
puts("16 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)");
return 0;
}
'''
+136 -1
View File
@@ -41,6 +41,9 @@ static void check_slot_wiped(void) {
zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length);
zero(&s_operation.principal,sizeof(s_operation.principal));
zero(&s_operation.target,sizeof(s_operation.target));
zero(s_operation.key_blob,sizeof(s_operation.key_blob));
zero(s_operation.key_type,sizeof(s_operation.key_type));
assert(!s_operation.key_blob_length && !s_operation.key_index);
}
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) {
assert(!host_lock_depth && !dispatcher && id);
@@ -88,6 +91,34 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) {
esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL;
}
static esp_err_t keys_error;
static unsigned key_lists;
esp_err_t user_database_get_account_keys(const user_database_account_t *target, user_database_user_snapshot_t *out) {
assert(!dispatcher && !host_lock_depth && target->user_id==7 && target->auth_generation==2);
assert(!strcmp(target->username,"carol")); ++key_lists;
memset(out,0,sizeof(*out));
if (keys_error) return keys_error;
strcpy(out->username,target->username); out->user_id=target->user_id; out->auth_generation=target->auth_generation;
out->public_key_count=3;
for (unsigned i=0;i<3;++i) {
out->public_keys[i].active=true; out->public_keys[i].index=i;
strcpy(out->public_keys[i].key_type,i==0 ? "ssh-ed25519" : "ecdsa-sha2-nistp256");
memset(out->public_keys[i].sha256_fingerprint,i,32);
}
return ESP_OK;
}
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *target,
const uint8_t *type,size_t type_length,const uint8_t *blob,size_t blob_length,uint8_t *index) {
assert(type_length==11 && !memcmp(type,"ssh-ed25519",11));
assert(blob_length==51 && blob[3]==11 && blob[18]==32); *index=0;
return user_database_delete_current(target);
}
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *target,uint8_t index) {
assert(index<3); return user_database_delete_current(target);
}
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *target) {
return user_database_delete_current(target);
}
static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}";
static void account_begin(const issued_t *identity,const char *body) {
@@ -311,6 +342,110 @@ static void self_tests(void) {
self_target=false;
puts("PASS Accounts self role/delete/password: protected failures keep login, success immediately target-revokes all logins, stale result reads denied, unrelated user survives");
}
static void key_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob); receive_fragment=768;
const char *selection="{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
for (unsigned mode=0;mode<13;++mode) {
account_begin(mode==0 ? NULL : mode==1 ? &user : &admin,selection);
req.uri="/api/settings/accounts/keys";
if (mode==2) req.method=HTTP_GET;
if (mode==3) req.uri="/api/settings/accounts/keys?x=1";
if (mode==4) add("Origin","https://evil.example");
if (mode==5) add("X-CSRF-Token","duplicate");
if (mode==6) add("Transfer-Encoding","chunked");
if (mode==7) add("Sec-Fetch-Site","cross-site");
if (mode==8) add("Content-Type","text/plain");
if (mode==9) req.content_len=aux.remaining_len=769;
if (mode==10) stale_user=alice.user_id;
if (mode==11) db_fail=true;
if (mode==12) receive_fragment=1;
unsigned before=key_lists; (void)web_account_keys_handler(&req);
assert(response_status[0]=='4' && key_lists==before);
zero(scratch,sizeof(scratch)); stale_user=0; db_fail=false; receive_fragment=768;
}
admin=mint(&alice);
for (unsigned mode=0;mode<3;++mode) {
account_begin(&admin,selection); req.uri="/api/settings/accounts/keys";
keys_error=mode==1 ? ESP_ERR_NOT_FOUND : mode==2 ? ESP_ERR_TIMEOUT : ESP_OK;
unsigned calls=mutations, ids=s_next_id;
assert(web_account_keys_handler(&req)==ESP_OK);
assert(!strcmp(response_status,mode==1 ? "409 Conflict" : mode==2 ? "503 Service Unavailable" : "200 OK"));
assert(calls==mutations && ids==s_next_id && strlen(output)<512);
if (!mode) {
assert(strstr(output,"\"index\":2") && strstr(output,"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\""));
assert(strstr(output,"ecdsa-sha2-nistp256") && !strstr(output,"password") && !strstr(output,"blob"));
bool no_store=false;
for (unsigned i=0;i<aux.resp_hdrs_count;++i)
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
assert(no_store);
}
}
keys_error=ESP_OK;
const char *bad_selection[]={"{}", "{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"username\":\"carol\"}",
"{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}",
"{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":0}"};
for (unsigned i=0;i<sizeof(bad_selection)/sizeof(*bad_selection);++i) {
account_begin(&admin,bad_selection[i]); req.uri="/api/settings/accounts/keys";
assert(web_account_keys_handler(&req)==ESP_OK && !strcmp(response_status,"400 Bad Request"));
}
const char *ed="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
char body[800], text[500];
account_operation_t parsed={0};
assert(parse_public_key("ecdsa-sha2-nistp256 AAAA",24,&parsed));
assert(!strcmp(parsed.key_type,"ecdsa-sha2-nistp256"));
uint8_t maximum_blob[129]={0}; unsigned char encoded[177]; size_t encoded_length=0;
for (size_t n=128;n<=129;++n) {
assert(mbedtls_base64_encode(encoded,sizeof(encoded),&encoded_length,maximum_blob,n)==0);
snprintf(text,sizeof(text),"ssh-ed25519 %s",encoded);
memset(&parsed,0,sizeof(parsed));
assert(parse_public_key(text,strlen(text),&parsed)==(n==128));
}
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s comment\"}",ed);
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
const esp_err_t errors[]={USER_DATABASE_ERR_DUPLICATE_SSH_KEY,ESP_ERR_NO_MEM,ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_ARG};
const unsigned states[]={DUPLICATE,FULL,STALE,FAILED};
for (unsigned i=0;i<4;++i) {
unsigned revokes=web_revokes; mutation_error=errors[i];
submit_account(&admin,body); execute_account(); assert(s_operation.state==states[i] && web_revokes==revokes);
}
mutation_error=ESP_OK;
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\",\"public_key\":\"%s\"}",ed,ed);
account_begin(&admin,body); account_expect("400 Bad Request");
const char *invalid[]={"ssh-rsa AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "ssh-ed25519 AAA", "ssh-ed25519 AA=A", "ssh-ed25519 AB==", "ssh-ed25519 AAAA\\ncomment", "ssh-ed25519 AAAA\\u0000", "ssh-ed25519 AAAA\\u0080", "ssh-ed25519 !!!!", "ssh-ed25519 ====", "ssh-ed25519 AAAA AAAA\\rAAAA"};
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\"}",invalid[i]);
account_begin(&admin,body); account_expect("400 Bad Request");
}
for (unsigned n=384;n<=385;++n) {
strcpy(text,ed); size_t size=strlen(text); memset(text+size,' ',n-size); text[n]=0;
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\"}",text);
account_begin(&admin,body); account_expect(n==384 ? "202 Accepted" : "400 Bad Request");
if (n==384) { execute_account(); assert(s_operation.state==OK); }
}
for (unsigned i=0;i<4;++i) {
snprintf(body,sizeof(body),"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":%u}",i);
account_begin(&admin,body); account_expect(i<3 ? "202 Accepted" : "400 Bad Request");
if (i<3) { execute_account(); assert(s_operation.state==OK); }
}
const char *bad[]={
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":00}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":-1}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":0,\"key_index\":1}",
"{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":0}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
const char *clear="{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
submit_account(&admin,clear); unsigned calls=mutations; s_operation.deadline=0; execute_account();
assert(s_operation.state==CANCELLED && mutations==calls);
submit_account(&admin,clear); web_session_store_invalidate(admin.view.id); execute_account();
assert(s_operation.state==CANCELLED && mutations==calls);
admin=mint(&alice); submit_account(&admin,clear); execute_account(); assert(s_operation.state==OK);
self_target=true;
submit_account(&admin,"{\"action\":\"key-clear\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}");
execute_account(); assert(s_operation.state==OK);
account_begin(&admin,NULL); account_expect("401 Unauthorized"); self_target=false;
puts("PASS Accounts keys: strict envelopes/schemas/limits, bounded fingerprint-only POST read, HTTP policy, dispatcher results, cancellation and self revocation uncertainty");
}
static void account_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob), other=mint(&alice); receive_fragment=64;
account_begin(NULL,deletion); account_expect("401 Unauthorized");
@@ -372,6 +507,6 @@ static void account_settings_tests(void) {
submit_account(&admin,deletion); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before);
puts("PASS Accounts execution failure/stale/protected results, dequeue cancellation, missed account revocation/DB failure and admitted-work completion after expiry");
credential_tests(); password_parser_tests(); generated_tests(); self_tests();
credential_tests(); password_parser_tests(); generated_tests(); self_tests(); key_tests();
assert(wiped_passwords && wiped_bodies && wiped_generated && wiped_responses);
}
+21
View File
@@ -47,6 +47,27 @@ settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
if accounts:
HEADERS["mbedtls/base64.h"] = """
#pragma once
#include <stddef.h>
#include <openssl/evp.h>
static inline int mbedtls_base64_decode(unsigned char *out, size_t capacity, size_t *length,
const unsigned char *in, size_t n) {
unsigned char decoded[132];
if (!n || n>172 || n%4) return -1;
int result=EVP_DecodeBlock(decoded,in,(int)n);
if (result<0) return -1;
if (in[n-1]=='=') --result;
if (in[n-2]=='=') --result;
if ((size_t)result>capacity) return -1;
memcpy(out,decoded,(size_t)result); *length=(size_t)result; return 0;
}
static inline int mbedtls_base64_encode(unsigned char *out, size_t capacity, size_t *length,
const unsigned char *in, size_t n) {
if (capacity < 4*((n+2)/3)+1) return -1;
*length=(size_t)EVP_EncodeBlock(out,in,(int)n); return 0;
}
"""
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;
+137 -1
View File
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -789,8 +789,144 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
return b;
}
const keysPath = '/api/settings/accounts/keys';
const fingerprint = 'SHA256:' + 'a'.repeat(43);
const keysReply = (extra = {}) => json({username:'carol',user_id:7,auth_generation:2,keys:[{index:0,type:'ssh-ed25519',fingerprint}],...extra});
async function keyBrowser() {
const b = await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change();
b.queues[keysPath].push(keysReply()); b.click('account-keys-refresh'); await tick(); return b;
}
const accountPath = '/api/settings/account-operation';
const accountReply = (id, state, action = 'role') => json({id, state, action});
await test('Key list exact protected identity POST, safe fingerprints and socket/lease isolation', async () => {
const b=await keyBrowser(), p=b.calls.find(c=>c.url===keysPath);
assert.deepEqual(JSON.parse(p.body),{username:'carol',user_id:7,auth_generation:2});
assert.equal(p.headers['X-CSRF-Token'],token); assert.equal(p.headers['Content-Type'],'application/json');
assert.match(b.nodes['account-keys-list'].textContent,/0: ssh-ed25519 SHA256:/);
assert.ok(!b.nodes['account-key-delete'].disabled); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
const u=await connected(); u.click('account-keys-refresh'); u.click('account-key-add'); await tick(); assert.ok(!u.calls.some(c=>c.url===keysPath || c.url===accountPath));
});
await test('ECDSA P-256 lists and imports; key read timeout and 401 use existing session isolation', async () => {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:[{index:0,type:'ecdsa-sha2-nistp256',fingerprint}]})); b.click('account-keys-refresh'); await tick(); assert.match(b.nodes['account-keys-list'].textContent,/ecdsa-sha2-nistp256/);
b.nodes['account-public-key'].value='ecdsa-sha2-nistp256 AAAA comment'; b.queues[accountPath].push(accountReply(34,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key,'ecdsa-sha2-nistp256 AAAA comment');
const t=await keyBrowser(); t.queues[keysPath].push(o=>new Promise((_,reject)=>o.signal.addEventListener('abort',()=>reject(new Error('timeout'))))); t.click('account-keys-refresh'); await tick(); t.fire(15000); await tick(); assert.ok(!t.nodes['account-keys-refresh'].disabled); assert.ok(t.nodes['account-key-delete'].disabled); assert.ok(t.sockets.every(s=>!s.closed));
t.queues[keysPath].push(failure(401)); t.click('account-keys-refresh'); await tick(); assert.deepEqual(t.redirects,['/login']); assert.ok(t.sockets.every(s=>s.closed)); assert.equal(t.nodes['account-keys-list'].textContent,'');
});
await test('Key import/delete/clear confirm exact body, single POST and refresh new generation keys', async () => {
for(const action of ['key-add','key-delete','key-clear']) {
const b=await keyBrowser(); const publicKey='ssh-ed25519 AAAA comment'; b.nodes['account-public-key'].value=publicKey;
b.window.confirm=()=>false; b.click('account-'+action); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath));
b.nodes['account-public-key'].value=publicKey; let confirmation; b.window.confirm=m=>{confirmation=m; return true;};
b.queues[accountPath].push(accountReply(30,'pending',action)); b.click('account-'+action); await tick();
assert.equal(b.nodes['account-public-key'].value,''); assert.match(confirmation,/carol/); if(action==='key-delete') assert.ok(confirmation.includes(fingerprint));
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='key-add'?{public_key:publicKey}:action==='key-delete'?{key_index:0}:{})});
b.queues[accountPath].push(accountReply(30,'ok',action));
b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]})); b.queues[keysPath].push(keysReply({auth_generation:3}));
b.fire(1000); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
assert.equal(JSON.parse(b.calls.filter(c=>c.url===keysPath).at(-1).body).auth_generation,3); assert.ok(!b.nodes['account-key-delete'].disabled);
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)});
function assertKeySlots(b, indices) {
assert.equal(b.nodes['account-keys-list'].textContent, indices.map(index => `${index}: ssh-ed25519 ${slotKey(index).fingerprint}`).join('\n'));
assert.equal(b.nodes['account-key-index'].value, String(indices[0]));
for(let index=0;index<3;++index) {
const option=b.nodes['key-option-'+index], present=indices.includes(index);
assert.equal(option.value,String(index)); assert.equal(option.hidden,!present); assert.equal(option.disabled,!present);
assert.equal(option.textContent,present?`${index}: ${slotKey(index).fingerprint}`:'');
}
assert.ok(!b.nodes['account-key-delete'].disabled && !b.nodes['account-key-clear'].disabled);
}
for(const indices of [[1],[0,2]]) await test(`Sparse key slots [${indices}] render and delete by index, not array position`, async () => {
for(const selected of indices) {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(slotKey)}));
b.click('account-keys-refresh'); await tick(); assertKeySlots(b,indices);
let confirmation; b.window.confirm=m=>{confirmation=m;return true;};
b.nodes['account-key-index'].value=String(selected); b.nodes['account-key-index'].change();
b.queues[accountPath].push(accountReply(35,'pending','key-delete')); b.click('account-key-delete'); await tick();
assert.ok(confirmation.includes(slotKey(selected).fingerprint));
for(const other of indices.filter(index=>index!==selected)) assert.ok(!confirmation.includes(slotKey(other).fingerprint));
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:2,key_index:selected});
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Key deletion automatically refreshes sparse survivors and uses their new identity for the next deletion', async () => {
for(const [before,removed,after] of [[[0,1],0,[1]],[[0,1,2],1,[0,2]]]) {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:before.map(slotKey)}));
b.click('account-keys-refresh'); await tick(); b.nodes['account-key-index'].value=String(removed);
b.queues[accountPath].push(accountReply(36,'pending','key-delete')); b.click('account-key-delete'); await tick();
const reads=b.calls.filter(c=>c.url===keysPath).length;
b.queues[accountPath].push(accountReply(36,'ok','key-delete'));
b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]}));
b.queues[keysPath].push(keysReply({auth_generation:3,keys:after.map(slotKey)}));
b.fire(1000); await tick(); assertKeySlots(b,after);
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
const keyReads=b.calls.filter(c=>c.url===keysPath); assert.equal(keyReads.length,reads+1);
assert.deepEqual(JSON.parse(keyReads.at(-1).body),{username:'carol',user_id:7,auth_generation:3});
b.nodes['account-key-index'].value=String(removed); b.click('account-key-delete'); await tick();
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
const selected=after.at(-1); let confirmation; b.window.confirm=m=>{confirmation=m;return true;};
b.nodes['account-key-index'].value=String(selected); b.queues[accountPath].push(accountReply(37,'pending','key-delete'));
b.click('account-key-delete'); await tick(); assert.ok(confirmation.includes(slotKey(selected).fingerprint));
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,2);
assert.deepEqual(JSON.parse(posts[1].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:3,key_index:selected});
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Duplicate and out-of-range key slots reject the whole list and cannot authorize deletion', async () => {
for(const indices of [[1,1],[0,2,2],[-1],[3],[0,3],[1.5],['1']]) {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(index=>({...slotKey(0),index}))}));
b.click('account-keys-refresh'); await tick();
assert.equal(b.nodes['account-keys-list'].textContent,''); assert.match(b.nodes['account-keys-detail'].textContent,/unavailable or invalid/);
assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled && b.nodes['account-key-index'].disabled);
let confirmations=0; b.window.confirm=()=>{++confirmations;return true;};
b.nodes['account-key-index'].value='0'; b.click('account-key-delete'); b.click('account-key-clear'); await tick();
assert.equal(confirmations,0); assert.ok(!b.calls.some(c=>c.url===accountPath));
assert.equal(b.calls.filter(c=>c.url===keysPath).length,2);
assert.ok(![...b.timers.values()].some(t=>t.ms===1000));
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Key list rejects stale identities, invalid schema and optional endpoint failures without retry', async () => {
for(const response of [failure(409),failure(404),failure(503),keysReply({user_id:8}),keysReply({auth_generation:3}),keysReply({keys:[{index:3,type:'ssh-ed25519',fingerprint}]}),keysReply({keys:[{index:0,type:'ssh-ed25519',fingerprint:'<img>'}]}),keysReply({keys:Array(4).fill({})}),new Response(' '.repeat(769))]) {
const b=await keyBrowser(); b.queues[keysPath].push(response); b.click('account-keys-refresh'); await tick();
assert.equal(b.nodes['account-keys-list'].textContent,''); assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled);
assert.match(b.nodes['account-keys-detail'].textContent,/stale|unavailable/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2);
b.click('account-key-delete'); await tick(); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Pasted keys clear on contexts and late list headers/body cannot change new target', async () => {
for(const streamed of [false,true]) for(const mode of ['target','view','domain','refresh','pagehide','logout']) {
const b=await keyBrowser(), d=deferred(); let stream;
b.queues[keysPath].push(streamed?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
b.click('account-keys-refresh'); await tick(); const p=b.calls.filter(c=>c.url===keysPath).at(-1); b.nodes['account-public-key'].value='PASTED';
if(mode==='target') { b.nodes['account-target'].value='0'; b.nodes['account-target'].change(); }
if(mode==='view') b.click('select-serial'); if(mode==='domain') b.click('settings-serial'); if(mode==='refresh') b.click('refresh-accounts'); if(mode==='pagehide') b.emit('pagehide'); if(mode==='logout') b.click('sign-out');
await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(p.signal.aborted);
if(streamed) { try {stream.enqueue(new TextEncoder().encode(await keysReply().text())); stream.close();} catch {} } else d.resolve(failure(401));
await tick(); assert.equal(b.nodes['account-keys-list'].textContent,''); if(mode!=='logout') assert.deepEqual(b.redirects,[]);
}
});
await test('Key UTF-8 and JSON bounds reject private/multiline/oversize; cancellation clears paste', async () => {
for(const value of ['-----BEGIN OPENSSH PRIVATE KEY-----','ssh-ed25519 AAAA\nssh-ed25519 BBBB','ssh-ed25519 AAAA '+ 'é'.repeat(185),'ssh-ed25519 AAAA '+ 'x'.repeat(369),'ssh-ed25519 AAAA '+ '\\'.repeat(367)]) {
const b=await keyBrowser(); b.nodes['account-public-key'].value=value; b.click('account-key-add'); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
const b=await keyBrowser(); b.nodes['account-public-key'].value='ssh-ed25519 AAAA '+ 'x'.repeat(367); b.queues[accountPath].push(accountReply(31,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(Buffer.byteLength(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key),384);
});
await test('Key outcomes duplicate/full/stale/failed refresh, bounded polls and self 401 uncertainty', async () => {
for(const state of ['duplicate','full','stale','failed']) {
const b=await keyBrowser(); b.queues[accountPath].push(accountReply(32,state,'key-add')); b.queues[keysPath].push(keysReply()); b.click('account-result'); await tick();
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/Username already|Account capacity/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2);
}
const b=await keyBrowser(); b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.click('account-key-clear'); await tick();
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.elapse(1000); b.fire(1000); await tick(); }
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); assert.match(b.nodes['account-operation-detail'].textContent,/stopped/);
const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick();
assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/);
});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
+4
View File
@@ -84,6 +84,10 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
assert 'id="account-password-saved" type="checkbox"' in rendered['html']
assert 'not applied yet' in rendered['html'] and 'no retrieval' in rendered['html']
assert 'JavaScript cannot securely zero strings' in rendered['html']
assert 'id="account-public-key" maxlength="384" autocomplete="off" spellcheck="false"' in rendered['html']
for action in ('add', 'delete', 'clear'):
assert f'id="account-key-{action}"' in rendered['html']
assert 'no private-key upload, export or SSH host management' in rendered['html']
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
(tmp / 'rendered.json').write_text(json.dumps(rendered))