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:
@@ -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,¤t)==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);
|
||||
|
||||
@@ -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[]={
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user