Add HTTPS identity rotation support

This commit is contained in:
2026-09-13 18:21:37 +02:00
parent 36e80811e8
commit aa4bbc2c8c
24 changed files with 826 additions and 170 deletions
+23 -6
View File
@@ -31,7 +31,7 @@ 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
The suite reports 17 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,
@@ -44,7 +44,7 @@ intentionally retained.
## Integration/API contract
Five public functions remain:
The five existing public functions remain:
- `web_security_init(web_security_load_result_t *)`
- `web_security_copy_tls_material(...)` (unchanged pair-copy API)
@@ -52,23 +52,40 @@ Five public functions remain:
- `web_security_rotate_certificate(void)`
- `web_security_reset_all(void)` (**TLS only**, changed signature)
8D.21 adds `web_security_get_identity_snapshot()` (zero-wait public fingerprint/
generation only) and the owner-only reservation contract
`web_security_reserve_identity()` / `web_security_replace_reserved()` /
`web_security_release_identity()`. The API-symbol check includes all nine functions.
New tests cover zero-wait contention, stale generations, one-use/nonreused tokens,
reservation exhaustion and competing canonical rotate/reset/init during real crypto.
Crypto/NVS runs outside the normal mutex during replacement; the identity reservation
survives until its owner releases it after service stop/start.
`python3 tests/web_admin_transport/server_lifecycle.py` additionally links real
production security and mbedTLS to the production HTTPS owner, with NVS/HTTPD doubles,
for unchanged identity/storage before commit and no rollback after stop/start failure.
See [8D.21 contracts and evidence limits](../../docs/phase8d21_implementation.md).
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.
Legacy startup callers and console policy/completion integrations were removed in
the accepted legacy-credential cleanup; this test does not reintroduce them.
Load results retain `STORED=0`, `GENERATED_MISSING=1`, and add `MIGRATED_V1=2`.
Repeated successful init returns the remembered result without reloading.
Repeated successful init returns the remembered result without reloading; an active
identity reservation rejects init until its owner finishes.
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,
CLI and browser-shell identity mutations now share `web_server_replace_identity()`
service/security reservation composition. `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.
+6 -3
View File
@@ -17,6 +17,7 @@ HEADERS = {
#define ESP_ERR_INVALID_VERSION 4
#define ESP_ERR_INVALID_RESPONSE 5
#define ESP_ERR_NO_MEM 6
#define ESP_ERR_TIMEOUT 7
""",
"esp_mac.h": """#pragma once
#include <stdint.h>
@@ -26,6 +27,7 @@ HEADERS = {
""",
"freertos/FreeRTOS.h": """#pragma once
#define portMAX_DELAY 0xffffffffU
#define pdTRUE 1
""",
"freertos/semphr.h": """#pragma once
typedef void *SemaphoreHandle_t;
@@ -69,7 +71,8 @@ with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
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",
"web_security_reset_all", "web_security_get_identity_snapshot",
"web_security_reserve_identity", "web_security_replace_reserved", "web_security_release_identity",
}
header = (ROOT / "src/web_security.h").read_text()
assert "web_security_credentials_t" not in header
@@ -78,8 +81,8 @@ with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
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 "web_server_replace_identity(0, 0, reset, &committed)" 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")
print("PASS exact public/owner API and legacy credential/console DB-mutation absence")
+48 -3
View File
@@ -11,13 +11,14 @@
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 bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication, mutex_busy;
static void (*crypto_hook)(void);
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; }
{ assert(m && !locked); if (mutex_busy) { assert(delay == 0); return 0; } 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)
@@ -32,6 +33,7 @@ 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 (crypto_hook) { void (*hook)(void) = crypto_hook; crypto_hook = NULL; hook(); }
if (fail_rng) return ESP_FAIL;
return getrandom(out, length, 0) == (ssize_t)length ? ESP_OK : ESP_FAIL;
}
@@ -68,7 +70,7 @@ esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *data, size_t
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);
assert(size == 1340 && (s_identity_token ? !locked : locked));
++writes;
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
if (fault == SET) return ESP_FAIL;
@@ -90,6 +92,8 @@ static void boot(void)
{
memset(&s_material, 0, sizeof(s_material));
s_material_ready = false; s_security_mutex = NULL;
s_identity_token = s_next_identity_token = 0; s_identity_used = false;
crypto_hook = NULL; mutex_busy = false;
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;
@@ -124,6 +128,19 @@ static void rejected(void)
assert(writes == 0 || fault == SET || fault == COMMIT);
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
}
static void competing_identity(void)
{
assert(!locked && s_identity_token);
web_security_identity_snapshot_t snapshot;
assert(web_security_get_identity_snapshot(&snapshot) == ESP_OK && snapshot.busy);
assert(snapshot.generation == expected_live.generation);
assert(!memcmp(snapshot.fingerprint, expected_live.certificate_fingerprint, 32));
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
assert(web_security_init(NULL) == ESP_ERR_INVALID_STATE);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
}
int main(void)
{
boot(); stored_size = 0;
@@ -262,6 +279,34 @@ int main(void)
}
group("rotation/reset transactional failures, identity change and generation increment");
uint32_t token = 0, generation = s_material.generation;
int prior_writes = writes, prior_rng = rng_calls;
assert(web_security_reserve_identity(generation - 1, false, &token) == ESP_ERR_INVALID_STATE && !token);
assert(writes == prior_writes && rng_calls == prior_rng);
web_security_identity_snapshot_t projection;
mutex_busy = true;
assert(web_security_get_identity_snapshot(&projection) == ESP_ERR_TIMEOUT);
assert(web_security_reserve_identity(generation, false, &token) == ESP_ERR_TIMEOUT);
mutex_busy = false;
assert(web_security_reserve_identity(generation, false, &token) == ESP_OK && token);
crypto_hook = competing_identity;
assert(web_security_replace_reserved(token) == ESP_OK && !crypto_hook);
assert(s_identity_token == token && s_material.generation == generation + 1);
assert(web_security_replace_reserved(token) == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
web_security_release_identity(token - 1); assert(s_identity_token == token);
web_security_release_identity(token); assert(!s_identity_token);
assert(web_security_replace_reserved(token) == ESP_ERR_INVALID_STATE);
assert(web_security_get_identity_snapshot(&projection) == ESP_OK && !projection.busy);
assert(!memcmp(projection.fingerprint, s_material.certificate_fingerprint, 32));
expected_live = s_material;
group("zero-wait public projection and stale/token fencing; real crypto outside locks excludes canonical writers through release");
s_next_identity_token = UINT32_MAX;
assert(web_security_reserve_identity(0, true, &token) == ESP_ERR_INVALID_STATE);
assert(web_security_get_identity_snapshot(&projection) == ESP_OK && projection.busy);
assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
group("reservation IDs saturate without ABA or recovery mutation bypass");
for (int kind = 0; kind < 3; ++kind) {
boot(); legacy(&identity);
if (kind == 0) stored_size = 0;