Add HTTPS identity rotation support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user