Remove Legacy Credential Bootstrap Paths

Decouple user provisioning from HTTPS identity storage while retaining
compatible v1 user records and migrating TLS material to the
credential-free
v2 format. Add focused security regression coverage and update operator
documentation.
This commit is contained in:
2026-09-08 19:09:26 +02:00
parent 82f21d6116
commit ac80863d80
26 changed files with 1013 additions and 583 deletions
+39 -1
View File
@@ -84,6 +84,13 @@ static void typed_key_tests(void)
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;
/* Reload the persisted v1 record with all three keys and verifiers intact. */
stored_database_t stored=s_database;
storage_test=true; unload_database();
user_database_load_result_t loaded;
assert(user_database_init(&loaded)==ESP_OK && loaded==USER_DATABASE_LOAD_STORED);
assert(!memcmp(&stored,&s_database,sizeof(stored)) && commits==saved);
storage_test=false;
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);
@@ -98,6 +105,37 @@ static void typed_key_tests(void)
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);
/* Sparse v1 reload must preserve the entire record, including IDs,
* generations, password verifiers, key blobs/types and fingerprints. */
stored=s_database;
user_database_user_snapshot_t sparse_snapshot=snapshot;
unsigned sparse_writes=writes, sparse_commits=commits;
assert(persisted_size==sizeof(stored) && !memcmp(persisted,&stored,sizeof(stored)));
storage_test=true; unload_database();
assert(user_database_init(&loaded)==ESP_OK && loaded==USER_DATABASE_LOAD_STORED);
storage_test=false;
assert(!memcmp(&stored,&s_database,sizeof(stored)));
assert(persisted_size==sizeof(stored) && !memcmp(persisted,&stored,sizeof(stored)));
assert(writes==sparse_writes && commits==sparse_commits);
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK);
assert(!memcmp(&sparse_snapshot,&snapshot,sizeof(snapshot)));
for (size_t slot=0;slot<3;slot+=2) {
const stored_key_t *key=&stored.users[1].keys[slot];
assert(key->active && snapshot.public_keys[slot].active);
assert(snapshot.public_keys[slot].index==slot);
assert(user_database_key_valid(key->type,key->type_length,key->blob,key->blob_length));
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,
key->type,key->type_length,key->blob,key->blob_length,
&authenticated,&authorized)==ESP_OK && authorized);
assert(authenticated.user_id==target.user_id && authenticated.auth_generation==target.auth_generation);
assert(authenticated.role==target.role && authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY);
bool current=false;
assert(user_database_principal_is_current(&authenticated,&current)==ESP_OK && current);
}
assert(!snapshot.public_keys[1].active);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,
s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
assert(writes==sparse_writes && commits==sparse_commits);
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);
@@ -116,7 +154,7 @@ static void typed_key_tests(void)
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. */
/* Ordinary 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);
+72 -1
View File
@@ -6,7 +6,6 @@ static void reset(void)
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);
@@ -138,8 +137,80 @@ static void typed_password_tests(void)
}
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
}
static void unload_database(void)
{
s_initialized=false; s_mutex=NULL; release_candidate();
secure_wipe(&s_database,sizeof(s_database));
}
static void storage_tests(void)
{
reset(); storage_test=true;
/* Both historical v1 states load without any account/verifier/ID changes. */
for (unsigned admins=0;admins<2;++admins) {
reset();
s_database.users[0].role=admins ? USER_ROLE_ADMIN : USER_ROLE_USER;
recount(&s_database);
stored_database_t before=s_database;
memcpy(persisted,&before,sizeof(before)); persisted_size=sizeof(before);
unload_database();
user_database_load_result_t result;
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_STORED);
assert(!memcmp(&before,&s_database,sizeof(before)) && !writes && !commits);
assert(user_database_recover_empty()==ESP_ERR_INVALID_STATE);
if (!admins) {
assert(user_database_delete((const uint8_t *)"admin",5)==ESP_OK);
assert(s_database.admin_count==0 && s_database.user_count==2);
}
}
for (unsigned kind=0;kind<4;++kind) {
reset(); stored_database_t bad=s_database;
if (kind==0) ++bad.version;
if (kind==1) bad.v1_admin_marker=0;
if (kind==2) bad.users[0].user_id=0;
memcpy(persisted,&bad,sizeof(bad)); persisted_size=sizeof(bad)-(kind==3);
size_t size=persisted_size;
unload_database(); user_database_load_result_t result;
assert(user_database_init(&result)!=ESP_OK && !s_initialized && !s_mutex);
assert(!writes && !commits && persisted_size==size && !memcmp(persisted,&bad,size));
web=false; remote=true;
assert(run("user recover --force")!=0 && !writes);
remote=false;
assert(run("user recover")!=0 && !writes);
assert(run("user recover --force")==0 && s_initialized);
assert(!s_database.user_count && !s_database.admin_count);
assert(validate_database(&s_database)==ESP_OK);
}
reset(); unload_database(); persisted_size=0;
user_database_load_result_t result;
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_EMPTY);
assert(s_initialized && !s_database.user_count && writes==1 && commits==1);
stored_database_t empty=s_database;
assert(persisted_size==sizeof(empty) && !memcmp(persisted,&empty,sizeof(empty)));
unload_database();
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_STORED);
assert(!memcmp(&empty,&s_database,sizeof(empty)) && writes==1 && commits==1);
user_database_snapshot_t snapshot;
assert(user_database_get_snapshot(&snapshot)==ESP_OK && snapshot.initialized);
assert(!snapshot.user_count && !snapshot.admin_count);
web=remote=false;
assert(run("user bootstrap")!=0 && run("user bootstrap --generate")!=0);
assert(run("user recover --force")!=0 && !memcmp(&empty,&s_database,sizeof(empty)));
assert(run("user add chief admin")==0 && s_database.admin_count==1);
assert(user_database_delete((const uint8_t *)"chief",5)==ESP_ERR_INVALID_STATE);
assert(user_database_set_role((const uint8_t *)"chief",5,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
for (unsigned stage=1;stage<=3;++stage) {
reset(); unload_database(); persisted_size=0; fail_stage=stage;
assert(user_database_init(&result)==ESP_FAIL && !s_initialized && !s_mutex);
assert(!persisted_size);
assert(user_database_recover_empty()==ESP_FAIL && !s_initialized && !s_mutex);
}
storage_test=false;
}
int main(void)
{
storage_tests();
typed_account_tests();
typed_password_tests();
const char *supported[]={
+30 -7
View File
@@ -41,6 +41,20 @@ enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
typedef void *SemaphoreHandle_t;
#define portMAX_DELAY 0
#define NVS_READWRITE 1
#define NVS_READONLY 0
#define ESP_ERR_NVS_NOT_FOUND 100
static uint8_t persisted[65536], staged[65536];
static size_t persisted_size, staged_size;
static bool storage_test;
static void *xSemaphoreCreateMutex(void) { return (void *)1; }
static void vSemaphoreDelete(void *m) { (void)m; }
static int nvs_flash_init(void) { return ESP_OK; }
static int nvs_get_blob(int h, const char *key, void *out, size_t *n) {
(void)h; (void)key;
if (!persisted_size) return ESP_ERR_NVS_NOT_FOUND;
if (out) { assert(*n>=persisted_size); memcpy(out,persisted,persisted_size); }
*n=persisted_size; return ESP_OK;
}
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;
@@ -53,16 +67,21 @@ static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_b
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);
(void)ns; (void)mode; assert(locks || storage_test);
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;
(void)h; (void)key; ++writes;
if (fail_stage==2) return ESP_FAIL;
assert(n<=sizeof(staged)); memcpy(staged,data,n); staged_size=n; return ESP_OK;
}
static int nvs_commit(int h) {
(void)h; ++commits; if (fail_stage==3) return ESP_FAIL;
memcpy(persisted,staged,staged_size); persisted_size=staged_size; return 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;
@@ -108,6 +127,8 @@ state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp
fakes = r'''
static stored_database_t candidate_storage;
static int allocate_candidate(void) { s_candidate=&candidate_storage; return ESP_OK; }
static void release_candidate(void) { secure_wipe(&candidate_storage,sizeof(candidate_storage)); s_candidate=NULL; }
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; }
@@ -138,8 +159,7 @@ static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
}
/* 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;
@@ -167,10 +187,12 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"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"]
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key",
"initialize_dummy_verifier", "user_database_init", "user_database_recover_empty",
"user_database_get_snapshot"]
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"]
"parse_key_index", "recover_database", "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")
@@ -190,6 +212,7 @@ with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
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: empty initialization/recovery, unchanged v1 records, corrupt/unsupported fail-closed loads, first UART0 administrator and removed bootstrap commands")
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")
+14 -4
View File
@@ -26,7 +26,7 @@ typedef struct { int unused; } user_principal_t;
'''
fakes = r'''
static bool remote, web;
static unsigned stops, reboots, scheduled, waits, rotations;
static unsigned stops, reboots, scheduled, waits, rotations, usages;
static esp_err_t schedule_result, stop_result;
static admin_ssh_deferred_action_type_t last_action;
bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
@@ -42,13 +42,11 @@ static esp_err_t web_serial_transport_clear_counters(void) { assert(false); retu
static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
static void print_usage(void) { assert(false); }
static void print_usage(void) { ++usages; }
static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; }
static int show_status(void) { assert(false); return 1; }
static int show_counters(void) { assert(false); return 1; }
static int show_credentials(void) { assert(false); return 1; }
static int show_certificate(void) { assert(false); return 1; }
static int rotate_credentials(void) { assert(false); return 1; }
static int rotate_certificate(void) { ++rotations; return 0; }
static int reset_material(void) { assert(false); return 1; }
static bool force_is_present(int argc, char **argv, int expected) {
@@ -57,6 +55,18 @@ static bool force_is_present(int argc, char **argv, int expected) {
'''
tests = r'''
int main(void) {
char *removed[]={"web", "credentials", "show", "--force"};
for (unsigned origin=0; origin<3; ++origin) {
remote=origin!=0; web=origin==2;
removed[2]="show";
assert(command_web(2,removed)==1);
assert(command_web(3,removed)==1);
removed[2]="rotate";
assert(command_web(3,removed)==1);
assert(command_web(4,removed)==1);
}
assert(usages==12 && !stops && !scheduled && !rotations);
remote=web=false;
char *diagnostics[]={"web", "diagnostics", "show"};
assert(command_web(3, diagnostics)==0 && !stops && !scheduled);
char *stop[]={"web", "stop"};
+103 -5
View File
@@ -5,6 +5,7 @@ Requires Python 3, cc and IDF_PATH (defaults to PlatformIO's installed SDK).
Does not run FreeRTOS dispatch, SSH I/O or target hardware.
"""
import os
import re
from pathlib import Path
import subprocess
import tempfile
@@ -46,10 +47,11 @@ int main(void) {
{"", true}, {" ", true}, {" ", true},
{"memory", true}, {"user", true}, {"user list", true},
{"user show bootstrap", true}, {"exit", true},
{"user bootstrap", false}, {"user bootstrap extra", false},
/* Removed verbs reach the canonical handler, not a bootstrap policy. */
{"user bootstrap", true}, {"user bootstrap extra", true},
{"user recover", false}, {"user recover --force", false},
{" user recover --force ", false},
{"\"user\" \"bootstrap\"", false},
{"\"user\" \"bootstrap\"", true},
{"\"user\" \"recover\" --force", false},
};
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) {
@@ -116,11 +118,11 @@ int main(void) {
assert(!remote_command_allowed(&request));
assert(!strcmp(request.line,web_denied[i]));
request.token.transport=0;
/* SSH retains only the global bootstrap/recover dispatcher restriction. */
/* SSH retains only the global recovery dispatcher restriction. */
assert(remote_command_allowed(&request) ==
(strstr(request.line,"bootstrap")==NULL && strstr(request.line,"recover")==NULL));
(strstr(request.line,"recover")==NULL));
}
puts("PASS: SSH policy unchanged; web bounded account forms, restrictions/lifecycle and quoted forms checked with actual IDF parser");
puts("PASS: UART0-only recovery, removed bootstrap policy, web bounded account forms, restrictions/lifecycle and quoted forms checked with actual IDF parser");
}
'''
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
@@ -130,3 +132,99 @@ with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
# Compile the actual composition-root security initialization and start gates.
# Other subsystem setup is excluded; deterministic errors model its results.
main = (ROOT / "src/main.c").read_text()
initialization = main[main.index(" web_security_load_result_t web_security_source"):
main.index(" esp_err_t web_runtime_error")]
gates = main[main.index(" if (wifi_error == ESP_OK && web_security_error"):
main.index(" if (local_ui_error == ESP_OK)")]
web_header = "\n".join(line for line in (ROOT / "src/web_security.h").read_text().splitlines()
if not line.startswith(("#include", "#pragma once")))
user_header = (ROOT / "src/user_database.h").read_text()
user_state = re.search(r"typedef enum \{[^{}]*\} user_database_load_result_t;", user_header).group()
startup = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define SSH_TRANSPORT_PORT 22
#define TAG "test"
#define ESP_LOGI(tag, ...) snprintf(last_log, sizeof(last_log), __VA_ARGS__)
#define ESP_LOGE(tag, ...) snprintf(last_error, sizeof(last_error), __VA_ARGS__)
static char last_log[256], last_error[256], tls_log[256];
static esp_err_t tls_error, db_error;
static unsigned tls_calls, db_calls, web_starts, ssh_starts;
static const char *esp_err_to_name(esp_err_t e) { (void)e; return "error"; }
static esp_err_t web_server_start(void) { ++web_starts; return ESP_OK; }
static esp_err_t ssh_transport_start(void) { ++ssh_starts; return ESP_OK; }
'''
startup += web_header + "\n" + user_state + r'''
static web_security_load_result_t tls_source;
static user_database_load_result_t db_source;
esp_err_t web_security_init(web_security_load_result_t *out) {
++tls_calls; *out=tls_source; return tls_error;
}
esp_err_t user_database_init(user_database_load_result_t *out) {
++db_calls; strcpy(tls_log,last_log); *out=db_source; return db_error;
}
static void boot(esp_err_t random_error, esp_err_t wifi_error,
esp_err_t web_runtime_error, esp_err_t ssh_security_error,
esp_err_t ssh_runtime_error) {
'''
startup += initialization + gates + r'''
}
int main(void) {
const web_security_load_result_t sources[]={WEB_SECURITY_LOAD_STORED,
WEB_SECURITY_LOAD_GENERATED_MISSING, WEB_SECURITY_LOAD_MIGRATED_V1};
const char *labels[]={"Using stored HTTPS identity", "Using newly generated HTTPS identity",
"Using migrated v1 HTTPS identity"};
for (unsigned i=0;i<3;++i) {
tls_source=sources[i];
for (unsigned empty=0;empty<2;++empty) {
db_source=empty ? USER_DATABASE_LOAD_EMPTY : USER_DATABASE_LOAD_STORED;
boot(ESP_OK,ESP_FAIL,ESP_OK,ESP_OK,ESP_OK);
assert(!strcmp(tls_log,labels[i]));
assert(!strcmp(last_log,empty ? "Using new empty user database" : "Using stored user database"));
}
}
for (unsigned failures=0;failures<64;++failures) {
tls_error=(failures&1) ? ESP_FAIL : ESP_OK;
db_error=(failures&2) ? ESP_FAIL : ESP_OK;
esp_err_t wifi=(failures&4) ? ESP_FAIL : ESP_OK;
esp_err_t web_runtime=(failures&8) ? ESP_FAIL : ESP_OK;
esp_err_t ssh_security=(failures&16) ? ESP_FAIL : ESP_OK;
esp_err_t ssh_runtime=(failures&32) ? ESP_FAIL : ESP_OK;
tls_calls=db_calls=web_starts=ssh_starts=0;
boot(ESP_OK,wifi,web_runtime,ssh_security,ssh_runtime);
assert(tls_calls==1 && db_calls==1);
assert(web_starts==(!wifi && !tls_error && !web_runtime));
assert(ssh_starts==(!wifi && !ssh_security && !ssh_runtime));
if (db_error) assert(strstr(last_error,"user recover --force"));
}
tls_calls=db_calls=web_starts=ssh_starts=0;
boot(ESP_FAIL,ESP_OK,ESP_OK,ESP_FAIL,ESP_OK);
assert(!tls_calls && db_calls==1 && !web_starts && !ssh_starts);
puts("PASS: startup init signatures/states, exact TLS source logs, 64 independent service-gate cases and RNG failure");
}
'''
(path / "startup.c").write_text(startup)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
str(path / "startup.c"), "-o", str(path / "startup")], check=True, timeout=30)
subprocess.run([str(path / "startup")], check=True, timeout=10)
completion = (ROOT / "src/console_completion.c").read_text()
candidates = re.findall(r'^\s*"([^"\n]+)",?$', completion, re.MULTILINE)
assert not any(c.startswith(("user bootstrap", "web credentials")) for c in candidates)
for retained in ("user recover --force", "user add", "user password", "user key add",
"web certificate info", "web certificate rotate --force", "web reset --force"):
assert retained in candidates
assert "Bootstrap/recovery" not in source
assert "legacy" not in initialization
print("PASS: removed completion entries, retained account/TLS/recovery commands and no startup credential copy")
+109
View File
@@ -0,0 +1,109 @@
# Production web-security host regression
Run from the repository root:
```sh
python3 tests/web_security/run.py
```
Requires a C11 compiler, `nm`, Python 3, and installed mbedTLS 3.6 headers plus
`libmbedx509` / `libmbedcrypto`. Verified with host mbedTLS 3.6.7. No downloads,
firmware build, device access, asset generation or persistent build outputs.
The runner creates adapters and binaries in a temporary directory.
`security.c` includes the **entire unchanged production `src/web_security.c`**.
No crypto function, generator, validator, decoder or transaction is extracted or
replaced. Actual mbedTLS generates P-256 keys and signed certificates and parses,
hashes, checks the key pair and verifies the self-signature. The legacy fixture
is independently assembled at fixed little-endian byte offsets from a freshly
generated real identity, not a production legacy encoder. No real device secret
or fixed private key is checked in.
Host adapters provide a fixed device MAC, Linux `getrandom`, a lock-ownership
assertion, and fault-injectable NVS with staged writes/commit. At every write and
commit the adapter checks that production live state is still its predecessor.
Legacy wipe calls are counted and checked. Production restart is simulated by
clearing only module RAM while retaining the adapter's stored record. These
adapters do **not** prove ESP entropy initialization, allocation failure inside
mbedTLS, FreeRTOS concurrency, ESP NVS flash/power-loss semantics, TLS handshakes,
HTTPD restarts, target stack margins, or secure physical flash erasure. In
particular, modeled failed commits retain predecessor storage; real flash fault
and power-loss behavior needs target validation. No claim that NVS logical
replacement securely erases historical flash pages.
The suite reports 15 production security groups plus one API/console static
absence check. Coverage includes fresh and stored-v2 paths, exact v1 migration,
metadata/pair-copy bounds, NVS failures and retries, 21 legacy corruptions,
14 v2 corruptions, unknown sizes, real bad signatures with recomputed hashes,
mismatched private keys, wrong-device certificates, RNG/MAC/mutex failures,
rotation/reset commit-before-publication, unavailable explicit recovery,
generation exhaustion and invalid arguments. Console checks establish removal
of legacy command/secret/synchronization references, not runtime console
lifecycle execution. Read-only database status and login-failure counters are
intentionally retained.
## Integration/API contract
Five public functions remain:
- `web_security_init(web_security_load_result_t *)`
- `web_security_copy_tls_material(...)` (unchanged pair-copy API)
- `web_security_get_certificate_metadata(...)` (unchanged metadata)
- `web_security_rotate_certificate(void)`
- `web_security_reset_all(void)` (**TLS only**, changed signature)
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
one credential struct type, three username/password capacity/length constants,
and two console operations (`web credentials show`, `web credentials rotate`).
There is no credential generation/display/synchronization path. Authentication
continues to belong to the user database; read-only status does not mutate it.
The integration owner must remove legacy startup callers in `main.c` and adapt
other console policy/completion/UI/test callers outside this ownership scope.
Load results retain `STORED=0`, `GENERATED_MISSING=1`, and add `MIGRATED_V1=2`.
Repeated successful init returns the remembered result without reloading.
Migration must validate and commit before publication; no fallback generation
or overwrite follows migration failure. Reset explicitly overwrites missing,
valid, or incompatible material, increments a live generation or uses one when
no live identity exists, and fails on live generation exhaustion. Rotation
requires live material and also fails at `UINT32_MAX`.
`web reset --force` retains the old lifecycle: commit first; when running,
stop then start, with no start after failed stop; otherwise attempt start.
Lifecycle failure does not roll back committed identity. Database accounts are
never synchronized, reset or otherwise mutated by these operations.
## Storage contract
The namespace/key remain **`web_sec/material`**, one blob, no new NVS keys.
V1 is exactly **1392 bytes**, read only through a private fixed-offset decoder.
Its credential layout must still match the shipped `admin`/24-character URL-safe
format, zero padding/reserved fields, and strict TLS validation.
V2 is exactly **1340 bytes** (52 bytes smaller), native little-endian ESP32
layout with compile-time offset/size assertions:
| Offset | Size | Field |
|---:|---:|---|
| 0 | 4 | schema version = 2 |
| 4 | 2 | blob size = 1340 |
| 6 | 2 | reserved, zero |
| 8 | 4 | nonzero generation |
| 12 | 2 | private key DER length |
| 14 | 2 | certificate DER length |
| 16 | 256 | private key DER, unused bytes zero |
| 272 | 1024 | certificate DER, unused bytes zero |
| 1296 | 32 | certificate SHA-256 fingerprint |
| 1328 | 12 | reserved, zero |
Migration preserves exact DER bytes, fingerprint and generation, including
`UINT32_MAX`; it never regenerates TLS identity. TLS validation includes exact
outer DER lengths, P-256 pair consistency, self-signature, fingerprint, device
CN/SAN, validity and existing certificate extension policy. Unknown sizes or
schema versions fail with `ESP_ERR_INVALID_VERSION`; malformed known records
fail with `ESP_ERR_INVALID_RESPONSE` (underlying operational failures propagate).
Every legacy input buffer is wiped on all post-read exits. The live blob has no
credential fields. No new task, queue, mutex, heap allocation or storage key is
introduced; the existing mutex remains. Migration adds a bounded 1392-byte
transient decoder buffer alongside the 1340-byte candidate; target call-stack
high-water usage is unmeasured.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Compile unchanged production security implementation with real host mbedTLS."""
from pathlib import Path
import subprocess
import re
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HEADERS = {
"esp_err.h": """#pragma once
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_INVALID_ARG 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_INVALID_SIZE 3
#define ESP_ERR_INVALID_VERSION 4
#define ESP_ERR_INVALID_RESPONSE 5
#define ESP_ERR_NO_MEM 6
""",
"esp_mac.h": """#pragma once
#include <stdint.h>
#include "esp_err.h"
#define ESP_MAC_WIFI_SOFTAP 1
esp_err_t esp_read_mac(uint8_t *, int);
""",
"freertos/FreeRTOS.h": """#pragma once
#define portMAX_DELAY 0xffffffffU
""",
"freertos/semphr.h": """#pragma once
typedef void *SemaphoreHandle_t;
SemaphoreHandle_t xSemaphoreCreateMutex(void);
int xSemaphoreTake(SemaphoreHandle_t, unsigned);
int xSemaphoreGive(SemaphoreHandle_t);
""",
"nvs.h": """#pragma once
#include <stddef.h>
#include "esp_err.h"
typedef int nvs_handle_t;
#define NVS_READONLY 0
#define NVS_READWRITE 1
#define ESP_ERR_NVS_NOT_FOUND 10
#define ESP_ERR_NVS_TYPE_MISMATCH 11
#define ESP_ERR_NVS_INVALID_LENGTH 12
esp_err_t nvs_open(const char *, int, nvs_handle_t *);
esp_err_t nvs_get_blob(nvs_handle_t, const char *, void *, size_t *);
esp_err_t nvs_set_blob(nvs_handle_t, const char *, const void *, size_t);
esp_err_t nvs_commit(nvs_handle_t);
void nvs_close(nvs_handle_t);
""",
}
with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
out = Path(directory)
for name, text in HEADERS.items():
path = out / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
executable = out / "security"
subprocess.run([
"cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-O1", "-g",
"-I", str(out), "-I", str(ROOT / "src"),
str(ROOT / "tests/web_security/security.c"),
"-lmbedx509", "-lmbedcrypto", "-o", str(executable),
], check=True)
subprocess.run([str(executable)], check=True)
symbols = subprocess.check_output(["nm", "-g", str(executable)], text=True)
assert "web_security_show_credentials" not in symbols
assert "web_security_rotate_credentials" not in symbols
assert set(re.findall(r" T (web_security_\w+)$", symbols, re.MULTILINE)) == {
"web_security_init", "web_security_copy_tls_material",
"web_security_get_certificate_metadata", "web_security_rotate_certificate",
"web_security_reset_all",
}
header = (ROOT / "src/web_security.h").read_text()
assert "web_security_credentials_t" not in header
assert "WEB_SECURITY_PASSWORD" not in header
assert "WEB_SECURITY_USERNAME" not in header
console = (ROOT / "src/web_console.c").read_text()
for forbidden in ('"credentials"', "web credentials", "user_database_sync_legacy", "synchronize_migrated", "Password:"):
assert forbidden not in console, forbidden
assert "web_security_reset_all()" in console
assert set(re.findall(r"\b(user_database_\w+)\s*\(", console)) == {
"user_database_get_snapshot",
}
print("PASS exact five-function API and legacy credential/console DB-mutation absence")
+311
View File
@@ -0,0 +1,311 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/random.h>
/* Include, do not extract or replace: all production crypto/storage paths run. */
#include "../../src/web_security.c"
static uint8_t stored[1600], pending[1600];
static size_t stored_size, pending_size;
static int fault, writes, commits, rng_calls, legacy_wipes, groups;
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication;
static web_security_blob_t expected_live;
enum { OPEN_RO = 20, OPEN_RW, QUERY, READ, SET, COMMIT, TYPE, SHORT_READ };
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return fail_mutex ? NULL : (void *)1; }
int xSemaphoreTake(SemaphoreHandle_t m, unsigned delay)
{ (void)delay; assert(m && !locked); locked = true; return 1; }
int xSemaphoreGive(SemaphoreHandle_t m)
{ assert(m && locked); locked = false; return 1; }
esp_err_t esp_read_mac(uint8_t *mac, int type)
{
assert(type == ESP_MAC_WIFI_SOFTAP);
const uint8_t fixed[] = {2, 0, 0, 0x12, 0x34, 0x56};
memcpy(mac, fixed, sizeof(fixed));
if (alternate_mac) mac[5] ^= 1;
return fail_mac ? ESP_FAIL : ESP_OK;
}
esp_err_t secure_random_init(void) { return fail_rng ? ESP_FAIL : ESP_OK; }
esp_err_t secure_random_fill(void *out, size_t length)
{
++rng_calls;
if (fail_rng) return ESP_FAIL;
return getrandom(out, length, 0) == (ssize_t)length ? ESP_OK : ESP_FAIL;
}
int secure_random_mbedtls(void *ctx, unsigned char *out, size_t length)
{ (void)ctx; return secure_random_fill(out, length) == ESP_OK ? 0 : -1; }
void secure_wipe(void *data, size_t size)
{
volatile uint8_t *p = data;
for (size_t i = 0; i < size; ++i) p[i] = 0;
if (size == LEGACY_BLOB_SIZE) {
++legacy_wipes;
assert(bytes_are_zero(data, size));
}
}
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle)
{
assert(!strcmp(name, "web_sec"));
if (fault == (mode == NVS_READONLY ? OPEN_RO : OPEN_RW)) return ESP_FAIL;
*handle = mode;
return ESP_OK;
}
esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *data, size_t *size)
{
assert(handle == NVS_READONLY && !strcmp(key, "material"));
if (fault == TYPE) return ESP_ERR_NVS_TYPE_MISMATCH;
if (fault == (data ? READ : QUERY)) return ESP_FAIL;
if (!stored_size) return ESP_ERR_NVS_NOT_FOUND;
if (!data) { *size = stored_size; return ESP_OK; }
assert(*size >= stored_size);
memcpy(data, stored, stored_size);
*size = stored_size - (fault == SHORT_READ ? 1 : 0);
return ESP_OK;
}
esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, const void *data, size_t size)
{
assert(handle == NVS_READWRITE && !strcmp(key, "material"));
assert(size == 1340 && locked);
++writes;
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
if (fault == SET) return ESP_FAIL;
memcpy(pending, data, size); pending_size = size;
return ESP_OK;
}
esp_err_t nvs_commit(nvs_handle_t handle)
{
assert(handle == NVS_READWRITE && pending_size);
++commits;
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
if (fault == COMMIT) return ESP_FAIL;
memcpy(stored, pending, pending_size); stored_size = pending_size;
return ESP_OK;
}
void nvs_close(nvs_handle_t handle) { (void)handle; pending_size = 0; }
static void boot(void)
{
memset(&s_material, 0, sizeof(s_material));
s_material_ready = false; s_security_mutex = NULL;
s_load_result = WEB_SECURITY_LOAD_STORED;
fault = writes = commits = rng_calls = legacy_wipes = 0;
fail_mutex = fail_rng = fail_mac = alternate_mac = locked = false;
expected_live = s_material; watch_publication = true;
}
static void put16(uint8_t *p, unsigned v) { p[0] = v; p[1] = v >> 8; }
static void legacy(const web_security_blob_t *identity)
{
memset(stored, 0, sizeof(stored)); stored_size = 1392;
stored[0] = 1; put16(stored + 4, 1392);
put16(stored + 8, identity->generation);
put16(stored + 10, identity->generation >> 16);
stored[12] = 5; stored[13] = 24;
put16(stored + 14, identity->private_key_length);
put16(stored + 16, identity->certificate_length);
memcpy(stored + 20, "admin", 5);
memcpy(stored + 36, "Ab09-_Ab09-_Ab09-_Ab09-_", 24);
memcpy(stored + 68, identity->private_key_der, 256);
memcpy(stored + 324, identity->certificate_der, 1024);
memcpy(stored + 1348, identity->certificate_fingerprint, 32);
}
static void group(const char *name) { ++groups; printf("PASS %s\n", name); }
static void rejected(void)
{
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
size_t size = stored_size;
web_security_load_result_t result = (web_security_load_result_t)99;
assert(web_security_init(&result) != ESP_OK);
assert(result == 99 && !s_material_ready);
assert(bytes_are_zero((uint8_t *)&s_material, sizeof(s_material)));
assert(size == stored_size && !memcmp(before, stored, sizeof(before)));
assert(writes == 0 || fault == SET || fault == COMMIT);
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
}
int main(void)
{
boot(); stored_size = 0;
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
web_security_load_result_t result;
assert(web_security_init(&result) == ESP_OK);
assert(result == WEB_SECURITY_LOAD_GENERATED_MISSING);
assert(stored_size == 1340 && writes == 1 && commits == 1);
assert(s_material.generation == 1 && validate_blob(&s_material) == ESP_OK);
web_security_blob_t identity = s_material;
group("fresh TLS-only generation and commit-before-publication");
boot(); assert(web_security_init(&result) == ESP_OK);
assert(result == WEB_SECURITY_LOAD_STORED && !writes && !commits);
assert(!memcmp(&identity, &s_material, sizeof(identity)));
group("stored v2 exact reload without write");
identity.generation = 0x12345678;
boot(); legacy(&identity);
assert(web_security_init(&result) == ESP_OK);
assert(result == WEB_SECURITY_LOAD_MIGRATED_V1 && legacy_wipes == 1);
assert(writes == 1 && commits == 1 && stored_size == 1340);
assert(!memcmp(&identity, &s_material, sizeof(identity)));
assert(!memcmp(stored, &identity, sizeof(identity)));
assert(web_security_init(&result) == ESP_OK && writes == 1);
assert(result == WEB_SECURITY_LOAD_MIGRATED_V1);
group("v1 migration exact key/certificate/fingerprint/generation and wipe");
uint8_t certificate[1024], key[256]; size_t cn, kn;
assert(web_security_copy_tls_material(NULL, 0, &cn, NULL, 0, &kn) == ESP_OK);
memset(certificate, 0xa5, sizeof(certificate));
assert(web_security_copy_tls_material(certificate, sizeof(certificate), &cn,
key, 1, &kn) == ESP_ERR_INVALID_SIZE);
assert(certificate[0] == 0xa5);
assert(web_security_copy_tls_material(certificate, sizeof(certificate), &cn,
key, sizeof(key), &kn) == ESP_OK);
assert(cn == identity.certificate_length && kn == identity.private_key_length);
assert(!memcmp(certificate, identity.certificate_der, cn));
assert(!memcmp(key, identity.private_key_der, kn));
web_security_certificate_metadata_t metadata;
assert(web_security_get_certificate_metadata(&metadata) == ESP_OK);
assert(metadata.material_generation == identity.generation);
assert(!memcmp(metadata.sha256_fingerprint, identity.certificate_fingerprint, 32));
group("public metadata and atomic pair-copy capacity contract");
const int faults[] = {OPEN_RO, QUERY, READ, TYPE, SHORT_READ, OPEN_RW, SET, COMMIT};
for (size_t i = 0; i < sizeof(faults)/sizeof(faults[0]); ++i) {
boot(); legacy(&identity); fault = faults[i]; rejected();
if (fault == READ || fault == SHORT_READ || fault == OPEN_RW ||
fault == SET || fault == COMMIT) assert(legacy_wipes == 1);
fault = 0;
assert(web_security_init(&result) == ESP_OK);
assert(!memcmp(&identity, &s_material, sizeof(identity)));
}
group("migration NVS open/query/read/type/short/set/commit failures and retry");
const size_t corrupt[] = {0, 4, 6, 8, 12, 13, 14, 15, 16, 17, 18,
20, 25, 36, 60, 68, 323, 324, 1347, 1348, 1380};
for (size_t i = 0; i < sizeof(corrupt)/sizeof(corrupt[0]); ++i) {
boot(); legacy(&identity);
if (corrupt[i] == 8) memset(stored + 8, 0, 4);
else stored[corrupt[i]] ^= 0x80;
rejected(); assert(legacy_wipes == 1 && writes == 0);
}
group("21 legacy schema/reserved/credential/length/DER/fingerprint corruptions");
const size_t bad_sizes[] = {1, 1339, 1341, 1391, 1393, 1600};
for (size_t i = 0; i < sizeof(bad_sizes)/sizeof(bad_sizes[0]); ++i) {
boot(); legacy(&identity); stored_size = bad_sizes[i]; rejected();
}
const size_t v2_corrupt[] = {0, 4, 6, 8, 12, 13, 14, 15, 16, 271, 272, 1295, 1296, 1328};
for (size_t i = 0; i < sizeof(v2_corrupt)/sizeof(v2_corrupt[0]); ++i) {
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
if (v2_corrupt[i] == 8) memset(stored + 8, 0, 4);
else stored[v2_corrupt[i]] ^= 0x80;
rejected();
}
group("unknown sizes and 14 v2 structural/crypto corruptions fail untouched");
/* Recompute fingerprint so signature validation, not just hashing, rejects. */
web_security_blob_t invalid = identity;
invalid.certificate_der[invalid.certificate_length - 1] ^= 1;
assert(mbedtls_sha256(invalid.certificate_der, invalid.certificate_length,
invalid.certificate_fingerprint, 0) == 0);
boot(); legacy(&invalid); rejected();
web_security_blob_t other;
assert(generate_all(&other, 1) == ESP_OK);
invalid = identity;
memcpy(invalid.private_key_der, other.private_key_der, sizeof(invalid.private_key_der));
invalid.private_key_length = other.private_key_length;
boot(); legacy(&invalid); rejected();
boot(); legacy(&identity); fail_mac = true; rejected();
boot(); alternate_mac = true;
assert(generate_all(&invalid, 1) == ESP_OK);
alternate_mac = false; legacy(&invalid); rejected();
group("real signature, mismatched private key and device identity rejection");
for (size_t i = 0; i < 5; ++i) {
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
fault = faults[i]; rejected(); assert(writes == 0);
}
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
fault = SHORT_READ; rejected(); assert(writes == 0);
group("stored v2 read failures remain closed without writes");
for (int kind = 0; kind < 3; ++kind) {
boot(); stored_size = 0;
fail_rng = kind == 0; fail_mutex = kind == 1; fail_mac = kind == 2;
rejected(); assert(writes == 0);
}
for (size_t i = 5; i < sizeof(faults)/sizeof(faults[0]); ++i) {
boot(); stored_size = 0; fault = faults[i]; rejected();
}
group("fresh entropy/mutex/MAC/persistence failures do not publish");
boot(); legacy(&identity); assert(web_security_init(NULL) == ESP_OK);
expected_live = s_material;
for (int operation = 0; operation < 2; ++operation) {
for (int f = OPEN_RW; f <= COMMIT; ++f) {
if (f != OPEN_RW && f != SET && f != COMMIT) continue;
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
fault = f;
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) != ESP_OK);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
assert(!memcmp(before, stored, sizeof(before)));
}
fault = 0; fail_rng = true;
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) != ESP_OK);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
fail_rng = false;
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) == ESP_OK);
assert(s_material.generation == expected_live.generation + 1);
assert(memcmp(s_material.certificate_fingerprint, expected_live.certificate_fingerprint, 32));
assert(validate_blob(&s_material) == ESP_OK);
expected_live = s_material;
}
group("rotation/reset transactional failures, identity change and generation increment");
for (int kind = 0; kind < 3; ++kind) {
boot(); legacy(&identity);
if (kind == 0) stored_size = 0;
if (kind == 1) stored[0] = 99;
assert(web_security_reset_all() == ESP_OK);
assert(s_material_ready && s_material.generation == 1 && stored_size == 1340);
assert(validate_blob(&s_material) == ESP_OK);
}
group("explicit reset replaces missing/unknown/valid storage without prior init");
for (int kind = 0; kind < 3; ++kind) {
for (size_t i = 5; i < sizeof(faults)/sizeof(faults[0]); ++i) {
boot(); legacy(&identity);
if (kind == 0) stored_size = 0;
if (kind == 1) stored[0] = 99;
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
size_t size = stored_size;
fault = faults[i];
assert(web_security_reset_all() != ESP_OK);
assert(!s_material_ready && stored_size == size);
assert(!memcmp(stored, before, sizeof(before)));
assert(bytes_are_zero((uint8_t *)&s_material, sizeof(s_material)));
}
}
group("uninitialized reset persistence failures preserve missing/invalid/valid storage");
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
assert(web_security_init(NULL) == ESP_OK);
s_material.generation = UINT32_MAX; expected_live = s_material;
int old_writes = writes;
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)) && writes == old_writes);
boot(); identity.generation = UINT32_MAX; legacy(&identity);
assert(web_security_init(&result) == ESP_OK && s_material.generation == UINT32_MAX);
group("generation exhaustion rejects mutations but preserves migration identity");
boot(); legacy(&identity);
assert(web_security_copy_tls_material(NULL, 0, NULL, NULL, 0, &kn) == ESP_ERR_INVALID_ARG);
assert(web_security_copy_tls_material(NULL, 1, &cn, NULL, 0, &kn) == ESP_ERR_INVALID_ARG);
assert(web_security_copy_tls_material(NULL, 0, &cn, NULL, 0, &kn) == ESP_ERR_INVALID_STATE);
assert(web_security_get_certificate_metadata(NULL) == ESP_ERR_INVALID_ARG);
assert(web_security_get_certificate_metadata(&metadata) == ESP_ERR_INVALID_STATE);
group("public invalid argument/unavailable contracts");
printf("PASS %d production security groups\n", groups);
return 0;
}