249 lines
14 KiB
Python
249 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Canonical account handlers + production DB transactions; deterministic IO/NVS/crypto.
|
|
|
|
Not a concurrent RTOS, cryptographic, real-NVS or target test. Run directly.
|
|
"""
|
|
from pathlib import Path
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
|
|
|
def function(source, name):
|
|
start = source.index(name + "(")
|
|
start = source.rfind("\n", 0, start) + 1
|
|
return source[start:source.index("\n}", start) + 2] + "\n"
|
|
|
|
def strip_includes(text):
|
|
return "\n".join(line for line in text.splitlines()
|
|
if not line.startswith(("#include", "#pragma once")))
|
|
|
|
db = (ROOT / "src/user_database.c").read_text()
|
|
console = (ROOT / "src/user_console.c").read_text()
|
|
admin = (ROOT / "src/admin_ssh_console.c").read_text()
|
|
prelude = r'''
|
|
#define _POSIX_C_SOURCE 200809L
|
|
#include <assert.h>
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <stddef.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
typedef int esp_err_t;
|
|
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
|
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
|
|
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION, ESP_ERR_TIMEOUT };
|
|
#define pdTRUE 1
|
|
static bool snapshot_busy;
|
|
static int last_wait;
|
|
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;
|
|
static bool invalidate_during_derivation;
|
|
static bool owner_current = true, remote = true, web = true, mismatch, cancel_prompt, stale_prompt;
|
|
static int notify_error = ESP_OK;
|
|
static char revoked_name[17];
|
|
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
|
static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_busy) return 0; assert(!locks++); return pdTRUE; }
|
|
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 || 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; ++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 void nvs_close(int h) { (void)h; }
|
|
static int secure_random_fill(void *p, size_t n) {
|
|
memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK;
|
|
}
|
|
static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
|
|
uint32_t iterations, uint8_t *hash) {
|
|
(void)p; (void)n; (void)s; (void)iterations; memset(hash, 7, 32);
|
|
if (invalidate_during_derivation) {
|
|
/* Model originating browser expiry/closure after operation admission.
|
|
* This is a deterministic derivation double, not real PBKDF2/HTTPD. */
|
|
assert(locks==1 && prompts==2 && checks==2 && owner_current);
|
|
assert(!writes && !commits);
|
|
owner_current=false;
|
|
++derivation_invalidations;
|
|
}
|
|
return fail_stage==5 ? ESP_FAIL : ESP_OK;
|
|
}
|
|
#include <openssl/sha.h>
|
|
#include <openssl/ec.h>
|
|
#include <openssl/obj_mac.h>
|
|
typedef EC_GROUP *mbedtls_ecp_group;
|
|
typedef struct { EC_POINT *point; } mbedtls_ecp_point;
|
|
#define MBEDTLS_ECP_DP_SECP256R1 1
|
|
static void mbedtls_ecp_group_init(mbedtls_ecp_group *g) { *g=NULL; }
|
|
static void mbedtls_ecp_point_init(mbedtls_ecp_point *p) { p->point=NULL; }
|
|
static int mbedtls_ecp_group_load(mbedtls_ecp_group *g, int id) {
|
|
assert(id==1); *g=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); return *g ? 0 : -1;
|
|
}
|
|
static int mbedtls_ecp_point_read_binary(mbedtls_ecp_group *g, mbedtls_ecp_point *p, const uint8_t *b, size_t n) {
|
|
p->point=EC_POINT_new(*g); return p->point && EC_POINT_oct2point(*g,p->point,b,n,NULL)==1 ? 0 : -1;
|
|
}
|
|
static int mbedtls_ecp_check_pubkey(mbedtls_ecp_group *g, mbedtls_ecp_point *p) {
|
|
return EC_POINT_is_at_infinity(*g,p->point)==0 && EC_POINT_is_on_curve(*g,p->point,NULL)==1 ? 0 : -1;
|
|
}
|
|
static void mbedtls_ecp_point_free(mbedtls_ecp_point *p) { EC_POINT_free(p->point); }
|
|
static void mbedtls_ecp_group_free(mbedtls_ecp_group *g) { EC_GROUP_free(*g); }
|
|
static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) {
|
|
assert(mode==0); return SHA256(p,n,h) ? 0 : -1;
|
|
}
|
|
'''
|
|
header = strip_includes((ROOT / "src/user_database.h").read_text())
|
|
state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")]
|
|
fakes = r'''
|
|
|
|
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; }
|
|
static const user_principal_t *admin_ssh_console_dispatch_principal(void) { return remote ? &actor : NULL; }
|
|
static bool admin_ssh_console_dispatch_is_current(void) {
|
|
bool current=false; ++checks;
|
|
if (checks==revoke_check) owner_current=false;
|
|
return owner_current && user_database_principal_is_current(&actor, ¤t)==ESP_OK && current;
|
|
}
|
|
static bool gate_held;
|
|
static int admin_command_gate_take(void) { assert(!gate_held); gate_held=true; return ESP_OK; }
|
|
static void admin_command_gate_give(void) { assert(gate_held); gate_held=false; }
|
|
static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap,
|
|
size_t min, size_t max, size_t *n) {
|
|
(void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts;
|
|
memcpy(out, "test-password", 13); *n=13;
|
|
if (mismatch && prompts==2) out[0]='X';
|
|
/* Simulate invalidation just after the prompt boundary returned success. */
|
|
if (prompts==revoke_prompt) owner_current=false;
|
|
if (stale_prompt && prompts==2) ++actor.auth_generation;
|
|
return cancel_prompt ? ESP_ERR_INVALID_STATE : ESP_OK;
|
|
}
|
|
static int web_serial_transport_revoke_user(const uint8_t *u, size_t n) {
|
|
++web_revokes; assert(n<sizeof(revoked_name)); memcpy(revoked_name,u,n); revoked_name[n]=0;
|
|
assert(strcmp(revoked_name,"admin")); return notify_error;
|
|
}
|
|
static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
|
|
++ssh_revokes; assert(strlen(revoked_name)==n && !memcmp(u,revoked_name,n)); return notify_error;
|
|
}
|
|
/* Forbidden paths are traps rather than alternative implementations. */
|
|
#define MALLOC_CAP_SPIRAM 1U
|
|
#define MALLOC_CAP_8BIT 2U
|
|
static bool fail_snapshot_alloc;
|
|
static unsigned snapshot_allocations;
|
|
static struct { uint64_t before; user_database_snapshot_t value; uint64_t after; } snapshot_memory;
|
|
static void *heap_caps_malloc(size_t n, unsigned caps) {
|
|
assert(gate_held && n==sizeof(snapshot_memory.value));
|
|
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
|
++snapshot_allocations;
|
|
if (fail_snapshot_alloc) return NULL;
|
|
snapshot_memory.before=snapshot_memory.after=UINT64_C(0xaabbccdd11223344);
|
|
memset(&snapshot_memory.value,0xa5,n);
|
|
return &snapshot_memory.value;
|
|
}
|
|
static unsigned user_registrations;
|
|
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
|
|
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
|
|
assert(!strcmp(c->command,"user") && c->func); ++user_registrations; return ESP_OK;
|
|
}
|
|
static int mbedtls_base64_encode(uint8_t *out,size_t cap,size_t *n,const uint8_t *in,size_t len) {
|
|
(void)in; assert(cap>=4 && len==32); memcpy(out,"AAAA",4); *n=4; return 0;
|
|
}
|
|
|
|
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; }
|
|
static int add_key_parts(const char *n,const uint8_t *t,size_t tl,const uint8_t *b,size_t bl) {
|
|
(void)n; (void)t; (void)tl; (void)b; (void)bl; assert(!"key mutation"); return 1;
|
|
}
|
|
esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r,user_database_generated_password_t *p) {
|
|
(void)u; (void)n; (void)r; (void)p; assert(!"generated credential"); return ESP_FAIL;
|
|
}
|
|
esp_err_t user_database_generate_password(const uint8_t *u,size_t n,user_database_generated_password_t *p) {
|
|
(void)u; (void)n; (void)p; assert(!"generated credential"); return ESP_FAIL;
|
|
}
|
|
|
|
size_t esp_console_split_argv(char *, char **, size_t);
|
|
'''
|
|
db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
|
|
"user_database_password_valid", "read_ssh_string", "user_database_key_valid",
|
|
"user_role_to_string", "user_role_parse", "set_record_password", "find_user",
|
|
"find_free_user", "stored_keys_equal", "validate_database", "recount",
|
|
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
|
|
"user_database_principal_is_current", "create_locked", "user_database_create",
|
|
"mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
|
|
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
|
|
"user_database_delete_current", "user_database_set_role_current",
|
|
"set_password", "user_database_set_password", "user_database_set_password_current",
|
|
"user_database_generate_password_value",
|
|
"user_database_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",
|
|
"initialize_dummy_verifier", "user_database_init", "user_database_recover_empty",
|
|
"user_database_get_snapshot"]
|
|
console_names = ["print_fingerprint", "print_user", "show_users", "print_usage", "revoke_user_network_sessions", "read_password",
|
|
"show_generated_password", "mutation_currentness", "add_user", "change_password",
|
|
"parse_key_index", "recover_database", "command_user_inner", "command_user",
|
|
"user_console_register_commands"]
|
|
unit = prelude + header + "\n" + state + fakes
|
|
unit += "\n".join(function(db, n) for n in db_names)
|
|
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
|
|
unit += console[console.index("static user_database_snapshot_t"):console.index("static void print_usage")]
|
|
unit += "\n".join(function(console, n) for n in console_names)
|
|
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
|
|
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
|
|
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
|
|
snapshot_tests = (ROOT / "tests/admin_console_boundary/psram_snapshot.c").read_text()
|
|
account_tests = account_tests.replace('int main(void)', snapshot_tests + '\nint main(void)')
|
|
account_tests = account_tests.replace(' typed_account_tests();', ' psram_snapshot_tests();\n typed_key_tests();\n typed_account_tests();')
|
|
assert ' typed_key_tests();' in account_tests
|
|
unit += account_tests
|
|
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
|
|
path = Path(directory)
|
|
(path / "test.c").write_text(unit)
|
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
|
|
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
|
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
|
|
result = subprocess.run([str(path / "test")], timeout=10, capture_output=True, text=True)
|
|
if result.returncode:
|
|
print(result.stderr)
|
|
result.check_returncode()
|
|
assert "test-password" not in result.stdout
|
|
assert "Generated password for" not in result.stdout
|
|
print("PASS: PSRAM snapshot allocation failure/retry/retention, status/list/show/unknown/unavailable, full wipe and bounds, gate ownership, mutation/recovery isolation")
|
|
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")
|