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
+7
View File
@@ -17,6 +17,13 @@ static esp_err_t web_server_start(void) {
}
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
assert(!service && !identity && !reset); *committed = false;
esp_err_t error = web_security_rotate_certificate();
if (error != ESP_OK) return error;
*committed = true; error = web_server_stop();
return error == ESP_OK ? web_server_start() : error;
}
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
static size_t feed_limit, fed_length, output_length;
+57 -1
View File
@@ -32,11 +32,12 @@ static void pipeline_reset(void) {
memset(&s_operation, 0, sizeof(s_operation)); s_ack_id = 0; s_ack_server = NULL;
pipeline_now = 0; pipeline_current = true; pipeline_queue_fail = false;
pipeline_reboots = pipeline_submits = 0; pipeline_id = 0; validation_hook = NULL;
identity_generation = 11; identity_token = 0; identity_replacements = 0; identity_error = ESP_OK; identity_hook = NULL;
reset(); start();
}
static uint32_t pipeline_admit(unsigned action) {
assert(s_operation.state != PENDING && s_operation.state != EXECUTING && !s_ack_id);
s_operation = (lifecycle_operation_t){.id=++s_next_id, .generation=s_generation,
s_operation = (lifecycle_operation_t){.id=++s_next_id, .generation=s_generation, .identity_generation=identity_generation,
.session=1, .principal={USER_ROLE_ADMIN}, .ack_deadline=pipeline_now+2000000,
.deadline=pipeline_now+30000000, .action=action, .state=PENDING};
s_ack_id = s_operation.id; s_ack_server = SERVER;
@@ -50,7 +51,62 @@ static void pipeline_callback(uint32_t id) {
static void validation_aba(void) {
assert(web_server_stop() == ESP_OK); fresh_registration(); start();
}
static void identity_interleave(void) {
assert(!locked && s_transitioning && identity_token);
bool committed = true;
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_start() == ESP_ERR_INVALID_STATE && web_server_stop() == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
}
static void pipeline_tests(void) {
for (unsigned mode = 0; mode < 8; ++mode) {
pipeline_reset(); uint32_t id = pipeline_admit(3);
unsigned before_starts = ssl_starts;
if (mode == 0) ++s_generation;
if (mode == 1) ++identity_generation;
if (mode == 2) s_generation = UINT32_MAX;
if (mode == 3) identity_generation = UINT32_MAX;
if (mode == 4) s_last_error = ESP_FAIL;
if (mode == 5) identity_token = 99;
if (mode == 6) pipeline_current = false;
if (mode == 7) pipeline_now = 2000000;
pipeline_callback(id); web_lifecycle_settings_execute(id);
assert(!identity_replacements && !ssl_stops && ssl_starts == before_starts);
assert(s_operation.state == (mode >= 6 ? CANCELLED : FAILED));
}
puts("PASS HTTPS rotation original-login/ACK expiry and stale identity/service/saturation/CLI reservation reject before identity mutation");
for (unsigned failure = 0; failure < 7; ++failure) {
pipeline_reset(); uint32_t id = pipeline_admit(3), generation = s_generation;
identity_hook = identity_interleave;
if (failure == 1) identity_error = ESP_FAIL;
if (failure == 2) idle_detach_error = ESP_ERR_TIMEOUT;
if (failure == 3) admin_detach_error = ESP_ERR_TIMEOUT;
if (failure == 4) serial_detach_error = ESP_ERR_INVALID_STATE;
if (failure == 5) ssl_stop_error = ESP_FAIL;
if (failure == 6) ssl_start_error = ESP_FAIL;
web_lifecycle_settings_execute(id); assert(!identity_replacements);
pipeline_callback(id); assert(!identity_replacements);
fresh_registration(); web_lifecycle_settings_execute(id);
assert(identity_replacements == 1 && !identity_token && !s_transitioning);
assert(s_operation.state == (failure ? FAILED : OK));
assert(identity_generation == (failure == 1 ? 11U : 12U));
if (failure == 1) assert(!ssl_stops && ssl_starts == 1 && auth_live);
if (failure >= 2 && failure <= 5) assert(s_server == SERVER && ssl_starts == 1 && !auth_live);
if (failure == 6) assert(!s_server && !auth_live && ssl_starts == 2);
if (!failure) assert(auth_live && s_server == SERVER && s_generation == generation + 3);
web_lifecycle_settings_execute(id); assert(identity_replacements == 1);
}
puts("PASS ACK dispatch rotation holds common owner reservation through crypto/commit/stop/start; precommit failure keeps logins, postcommit failure never rolls back");
pipeline_reset(); assert(web_server_stop() == ESP_OK);
bool committed = false; unsigned starts = ssl_starts;
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_OK && committed);
assert(!s_server && ssl_starts == starts && identity_generation == 12);
fresh_registration();
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_OK && committed);
assert(s_server == SERVER && ssl_starts == starts + 1 && identity_generation == 13);
puts("PASS canonical stopped rotation remains stopped; CLI TLS reset starts stopped HTTPS without account/config reset");
for (unsigned action = 0; action < 3; ++action) {
pipeline_reset(); uint32_t id = pipeline_admit(action), generation = s_generation;
web_lifecycle_settings_execute(id); assert(!ssl_stops && !pipeline_reboots);
+140 -3
View File
@@ -19,7 +19,7 @@ source = SOURCE.read_text()
def function(name):
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\([^\n]*\)\n\{.*?^\}',
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\([^;{}]*\)\n\{.*?^\}',
source, re.M | re.S)
if not match:
raise RuntimeError('Production function shape changed: ' + name)
@@ -117,6 +117,22 @@ static void xSemaphoreGive(SemaphoreHandle_t m) {
assert(m && locked); locked = 0; if (unlock_hook) unlock_hook();
}
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
static uint32_t identity_generation = 11, identity_token;
static unsigned identity_replacements;
static esp_err_t identity_error;
static void (*identity_hook)(void);
static esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token) {
assert(!locked); (void)reset; *token = 0;
if (identity_token || identity_generation == UINT32_MAX || (expected && expected != identity_generation)) return ESP_ERR_INVALID_STATE;
*token = identity_token = 1; return ESP_OK;
}
static esp_err_t web_security_replace_reserved(uint32_t token) {
assert(!locked && token && token == identity_token); ++identity_replacements;
if (identity_hook) identity_hook();
if (identity_error == ESP_OK) ++identity_generation;
return identity_error;
}
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
@@ -845,7 +861,7 @@ int main(void) {
puts("PASS every other settings route failure leaves the complete Network domain available");
management_tests();
pipeline_tests();
puts("41 lifecycle groups passed (34 prior owner/route groups plus 7 lifecycle integration groups)");
puts("44 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups)");
return 0;
}
'''
@@ -1006,7 +1022,8 @@ unit += function('ensure_mutex')
unit += ''.join(function(name) for name in (
'web_server_init', 'start_server', 'web_server_start', 'stop_server',
'web_server_stop', 'web_server_stop_current', 'web_server_restart_current',
'web_server_reboot_current', 'web_server_get_management_snapshot', 'web_server_clear_counters'))
'web_server_reboot_current', 'web_server_get_management_snapshot', 'web_server_clear_counters',
'web_server_replace_identity'))
lifecycle_source = (ROOT / 'src/web_lifecycle_settings.c').read_text()
pipeline_state = lifecycle_source[lifecycle_source.index('typedef struct {'):lifecycle_source.index('static void cancel_locked')]
pipeline_state = 'enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };\n' + pipeline_state
@@ -1026,3 +1043,123 @@ with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as direct
'-g', str(c_file), '-o', str(executable)], check=True, timeout=30)
subprocess.run([str(executable)], check=True, timeout=15)
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
# Second executable links the same production server functions to the COMPLETE
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
import ast
security_runner = ast.parse((ROOT / 'tests/web_security/run.py').read_text())
security_headers = next(ast.literal_eval(node.value) for node in security_runner.body
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'HEADERS' for t in node.targets))
real_unit = unit.replace('int main(void)', 'int orchestration_regressions(void)')
real_unit = real_unit.replace('typedef int esp_err_t;\nenum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_INVALID_ARG };', '#include "esp_err.h"')
for name in ('web_security_reserve_identity', 'web_security_replace_reserved', 'web_security_release_identity', 'web_security_copy_tls_material'):
pattern = r'^static (?:esp_err_t|void) ' + name + r'\([^{}]*\) \{.*?^\}' if name != 'web_security_release_identity' else r'^static void web_security_release_identity[^\n]*'
match = re.search(pattern, real_unit, re.M | re.S)
assert match, name
signature = match.group().split('{', 1)[0].replace('static ', '', 1).strip() + ';'
real_unit = real_unit[:match.start()] + signature + real_unit[match.end():]
real_unit = real_unit.replace('config->servercert_len == 1 && config->servercert[0] == 1', 'config->servercert_len > 100 && config->servercert[0] == 0x30')
real_unit = real_unit.replace('config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2', 'config->prvtkey_len > 32 && config->prvtkey_pem[0] == 0x30')
real_unit = 'static int real_identity_active;\nstatic void real_runtime_check(void);\n' + real_unit
real_unit = real_unit.replace("event('A'); ++auth_stops", "real_runtime_check(); event('A'); ++auth_stops")
real_unit = real_unit.replace('assert(!locked && auth_live && !ssl_live); ++ssl_starts;', 'real_runtime_check(); assert(!locked && auth_live && !ssl_live); ++ssl_starts;')
real_unit += r'''
void identity_fixture_prepare(void);
void identity_fixture_fault(unsigned);
void identity_fixture_check(bool);
void identity_fixture_hook(void (*hook)(void));
esp_err_t web_security_rotate_certificate(void);
esp_err_t web_security_reset_all(void);
static unsigned real_crypto_hooks, real_runtime_checks;
static void real_runtime_check(void) {
if (!real_identity_active) return;
assert(!locked && s_transitioning);
++real_runtime_checks;
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
}
static void real_crypto_interleave(void) {
assert(!locked && s_transitioning);
++real_crypto_hooks;
bool committed = true;
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
assert(web_server_stop() == ESP_ERR_INVALID_STATE && web_server_start() == ESP_ERR_INVALID_STATE);
identity_fixture_check(false);
}
int main(void) {
for (unsigned failure = 0; failure < 7; ++failure) {
reset(); identity_fixture_prepare(); start();
bool committed = false;
if (failure >= 1 && failure <= 4) identity_fixture_fault(failure);
if (failure == 5) ssl_stop_error = ESP_FAIL;
if (failure == 6) ssl_start_error = ESP_FAIL;
identity_fixture_hook(real_crypto_interleave);
fresh_registration(); real_identity_active = 1;
esp_err_t error = web_server_replace_identity(s_generation, 1, false, &committed);
real_identity_active = 0;
assert(error == ESP_OK || failure);
assert((error == ESP_OK) == (failure == 0));
bool changed = !failure || failure >= 5;
assert(committed == changed && !s_transitioning);
identity_fixture_check(changed);
if (failure >= 1 && failure <= 4) assert(auth_live && !ssl_stops && ssl_starts == 1);
if (failure == 5) assert(s_server == SERVER && !auth_live && ssl_starts == 1);
if (failure == 6) assert(!s_server && !auth_live && ssl_starts == 2);
}
assert(real_crypto_hooks == 7 && real_runtime_checks >= 5);
puts("PASS integrated production HTTPS owner + real mbedTLS/NVS open/set/commit/RNG failures: exact identity/storage unchanged before commit; successful commit survives actual owner stop/start failure without rollback");
reset(); identity_fixture_prepare(); start();
bool committed = true;
assert(web_server_replace_identity(s_generation + 1, 1, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_replace_identity(s_generation, 2, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
identity_fixture_check(false);
assert(!ssl_stops && ssl_starts == 1 && auth_live);
puts("PASS integrated stale service and identity reject without real crypto/NVS effects; real crypto interleavings exclude canonical CLI/reset and direct security mutation");
return 0;
}
'''
security_fixture = (ROOT / 'tests/web_security/security.c').read_text().replace('"../../src/web_security.c"', '"' + str(ROOT / 'src/web_security.c') + '"')
security_fixture = security_fixture[:security_fixture.index('static void put16')].replace('legacy_wipes, groups;', 'legacy_wipes;')
security_fixture += r'''
static bool fail_during_crypto;
static void (*fixture_hook)(void);
static void fixture_crypto(void) {
assert(!locked && s_identity_token);
if (fixture_hook) fixture_hook();
if (fail_during_crypto) fail_rng = true;
}
void identity_fixture_prepare(void) {
boot(); stored_size = 0; fail_during_crypto = false; fixture_hook = NULL;
assert(web_security_init(NULL) == ESP_OK);
expected_live = s_material;
}
void identity_fixture_fault(unsigned value) {
fault = value == 1 ? OPEN_RW : value == 2 ? SET : value == 3 ? COMMIT : 0;
fail_during_crypto = value == 4;
}
void identity_fixture_hook(void (*hook)(void)) { fixture_hook = hook; crypto_hook = fixture_crypto; }
void identity_fixture_check(bool changed) {
assert(!locked && s_material_ready);
assert(s_material.generation == expected_live.generation + (changed ? 1U : 0U));
assert(stored_size == sizeof(s_material) && !memcmp(stored, &s_material, sizeof(s_material)));
if (!changed) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
else assert(memcmp(s_material.certificate_fingerprint, expected_live.certificate_fingerprint, 32));
}
'''
with tempfile.TemporaryDirectory(prefix='https-identity-integration-') as directory:
temporary = Path(directory)
for name, text in security_headers.items():
path = temporary / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
(temporary / 'server.c').write_text(real_unit)
(temporary / 'identity.c').write_text(security_fixture)
executable = temporary / 'integration'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror', '-g',
'-I', str(temporary), '-I', str(ROOT / 'src'),
str(temporary / 'server.c'), str(temporary / 'identity.c'),
'-lmbedx509', '-lmbedcrypto', '-o', str(executable)], check=True, timeout=30)
subprocess.run([str(executable)], check=True, timeout=30)
+3
View File
@@ -19,6 +19,9 @@ void esp_restart(void) { assert(false); }
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
esp_err_t web_security_rotate_certificate(void) { assert(false); return ESP_FAIL; }
esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
(void)service; (void)identity; (void)reset; (void)committed; assert(false); return ESP_FAIL;
}
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
}
+32 -4
View File
@@ -25,6 +25,13 @@ static esp_err_t owner_action(unsigned action, uint32_t generation) {
esp_err_t web_server_stop_current(uint32_t generation) { return owner_action(0, generation); }
esp_err_t web_server_restart_current(uint32_t generation) { return owner_action(1, generation); }
esp_err_t web_server_reboot_current(uint32_t generation) { return owner_action(2, generation); }
esp_err_t web_server_replace_identity(uint32_t generation, uint32_t identity, bool reset, bool *committed) {
assert(identity == 11 && !reset); *committed = owner_error == ESP_OK; return owner_action(3, generation);
}
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *out) {
assert(on_handler && !host_lock_depth); *out = (web_security_identity_snapshot_t){.generation=11};
memset(out->fingerprint, 0xab, 32); return ESP_OK;
}
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*callback)(void *), void *argument) {
assert(on_handler && !on_dispatcher && !on_callback && !host_lock_depth && handle == &server);
assert(!strcmp(response_status, "202 Accepted") && !send_fail && !aux.remaining_len && sends);
@@ -53,7 +60,7 @@ static void expect_lifecycle(const char *status, bool snapshot_read) {
on_handler = false;
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
assert(!strcmp(response_status, status) && mutations == before && submit_calls == submitted);
assert(strlen(output) < (snapshot_read ? 128 : 96)); zero(scratch, sizeof(scratch));
assert(strlen(output) < (snapshot_read ? 320 : 96)); zero(scratch, sizeof(scratch));
assert(!strstr(output, "principal") && !strstr(output, "csrf") && !strstr(output, "password"));
}
static const char *stop_body = "{\"action\":\"stop\",\"generation\":7}";
@@ -109,7 +116,23 @@ static void lifecycle_tests(void) {
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
lifecycle_begin(&admin, invalid[i], false); expect_lifecycle("400 Bad Request", false);
}
const char *invalid_rotation[] = {
"{\"action\":\"rotate\",\"generation\":7}",
"{\"action\":\"rotate\",\"identity_generation\":11}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":0}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":4294967295}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":4294967296}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":\"11\"}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":11,\"reset\":true}",
"{\"action\":\"stop\",\"generation\":7,\"identity_generation\":11}"
};
for (unsigned i = 0; i < sizeof(invalid_rotation)/sizeof(*invalid_rotation); ++i) {
lifecycle_begin(&admin, invalid_rotation[i], false); expect_lifecycle("400 Bad Request", false);
}
lifecycle_operation_t parsed = {0};
const char *rotation = "{\"identity_generation\":11,\"generation\":7,\"action\":\"rotate\"}";
assert(parse(rotation, strlen(rotation), &parsed) && parsed.identity_generation == 11 && parsed.action == 3);
for (size_t n = 0; n < strlen(rotation); ++n) assert(!parse(rotation, n, &parsed));
for (size_t n = 0; n < strlen(stop_body); ++n) assert(!parse(stop_body, n, &parsed));
assert(parse(stop_body, strlen(stop_body), &parsed)); assert(!parse(stop_body, strlen(stop_body) + 1, &parsed));
const char *reordered = " { \"generation\":4294967294, \"action\":\"restart\" } ";
@@ -129,7 +152,11 @@ static void lifecycle_tests(void) {
for (unsigned mode = 0; mode < 2; ++mode) {
owner_error = mode ? ESP_FAIL : ESP_OK;
lifecycle_begin(&admin, NULL, true); expect_lifecycle(mode ? "503 Service Unavailable" : "200 OK", true);
if (!mode) assert(!strcmp(output, "{\"generation\":7,\"running\":true,\"transitioning\":false,\"controllable\":true}"));
if (!mode) {
assert(strstr(output, "\"identity_generation\":11") && strstr(output, "\"rotatable\":true"));
assert(strstr(output, "abababababababababababababababababababababababababababababababab"));
assert(!strstr(output, "private") && !strstr(output, "certificate_der"));
}
}
owner_error = ESP_OK;
puts("PASS lifecycle bounded scalar snapshot and optional owner failure isolation");
@@ -165,9 +192,10 @@ static void lifecycle_tests(void) {
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
assert(s_ack_id == s_operation.id); owner_callback(); dispatch(); assert(s_operation.state == OK);
puts("PASS lifecycle original-login expiry/revocation/validation races and shutdown/restart same-owner ABA");
for (unsigned action = 0; action < 3; ++action) {
for (unsigned action = 0; action < 4; ++action) {
auth_reset(); admin = mint(&alice); char body[80];
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7}", s_actions[action]);
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7%s}", s_actions[action],
action == 3 ? ",\"identity_generation\":11" : "");
invalidate_during_owner = true; lifecycle_submit(&admin, body); owner_callback(); before = mutations; dispatch();
invalidate_during_owner = false;
assert(s_operation.state == OK && mutations == before + 1);
+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;
+36 -3
View File
@@ -2,7 +2,7 @@
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
const path = '/api/settings/lifecycle', op = path + '-operation';
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,...extra});
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true,...extra});
const reply = (state='pending', id=42, status=200, action='stop') => new Response(JSON.stringify({id,action,state}), {status});
const n = (b,id) => b.nodes['lifecycle-'+id], posts = b => b.calls.filter(c=>c.url===op && c.method==='POST');
async function open(v=fixture()) { const b=await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json(v)); b.click('settings-lifecycle'); await tick(); return b; }
@@ -28,18 +28,51 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
assert.ok(n(b,'stop').disabled); await refresh(b); assert.equal(n(b,'stop').disabled,false); assert.equal(posts(b).length,1);
}
});
await test('HTTPS identity rotation confirms public fingerprint and both generations, shares pending gate, and requires renewed trust/login',async()=>{
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;};
b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,0);
for(const text of ['ab'.repeat(32),'generation 11','generation 7','UART0','trust','ALL','SSH','USB','sign in freshly']) assert.ok(confirmation.includes(text),text);
assert.match(n(b,'identity').textContent,/Stored HTTPS identity generation 11/);
b.window.confirm=()=>true; await submit(b,'rotate');
assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,identity_generation:11});
b.click('lifecycle-stop'); b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,1);
b.queues[op].push(reply('failed',42,200,'rotate')); b.click('lifecycle-result'); await tick();
assert.match(n(b,'operation-detail').textContent,/identity may already be persisted/);
assert.match(n(b,'operation-detail').textContent,/No rollback/);
assert.ok(n(b,'rotate').disabled); await refresh(b); assert.equal(n(b,'rotate').disabled,false);
assert.equal(posts(b).length,1);
});
await test('HTTPS identity metadata rejects missing generations, secrets and malformed fingerprints; unavailable identity does not disable ordinary service controls',async()=>{
for(const v of [fixture({identity_generation:undefined}),fixture({identity_generation:0}),fixture({identity_generation:4294967295}),fixture({fingerprint:'<script>'}),fixture({fingerprint:'a'.repeat(65)}),fixture({private_key:'secret'})]) {
const b=await open(v); b.click('lifecycle-rotate'); await tick(); assert.ok(n(b,'rotate').disabled); assert.equal(posts(b).length,0);
assert.doesNotMatch(n(b,'identity').textContent,/script|secret/);
}
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));
assert.ok(n(b,'rotate').disabled); assert.equal(n(b,'stop').disabled,false);
assert.doesNotMatch(html,/id="lifecycle-reset"/);
});
await test('HTTPS rotation lost ACK, role loss and post-admission login expiry never replay or restore',async()=>{
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-rotate'); await tick();
b.queues[op].push(reply('ok',42,200,'rotate')); b.click('lifecycle-result'); await tick();
await refresh(b); assert.ok(n(b,'rotate').disabled && n(b,'stop').disabled); assert.equal(posts(b).length,1);
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.equal(posts(b).length,1);
const denied=await open(); denied.queues['/api/session'].push(session({role:'user'})); denied.click('lifecycle-rotate'); await tick(); assert.equal(posts(denied).length,0);
const expired=await open(); await submit(expired,'rotate'); expired.queues[op].push(failure(401)); expired.click('lifecycle-result'); await tick();
assert.deepEqual(expired.redirects,['/login']); assert.ok(expired.sockets.every(s=>s.closed));
const fresh=await open(fixture({identity_generation:12,generation:10})); assert.equal(posts(fresh).length,0); assert.ok(!fresh.calls.some(c=>c.url===op));
});
await test('Lifecycle bounded snapshot schema rejects unavailable malformed transitioning saturated and contradictory state',async()=>{
for(const v of [{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({controllable:1}),fixture({extra:true}),fixture({transitioning:true}),fixture({running:false}),fixture({generation:4294967295})]) {
const b=await open(v); assert.ok(n(b,'stop').disabled && n(b,'restart').disabled && n(b,'reboot').disabled); assert.equal(posts(b).length,0);
}
for(const v of [fixture({transitioning:true,controllable:false}),fixture({generation:4294967295,controllable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
for(const v of [fixture({transitioning:true,controllable:false,rotatable:false}),fixture({generation:4294967295,controllable:false,rotatable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
const b=await open(); b.queues[path].push(failure(503)); b.click('lifecycle-refresh'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(b.sockets.length,2);
});
await test('Lifecycle captures confirmation before delayed original-session validation and gates double click',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.queues[op].push(reply('pending',42,202)); b.click('lifecycle-stop'); await tick();
b.click('lifecycle-reboot'); b.click('lifecycle-refresh'); await tick(); assert.equal(posts(b).length,0);
d.resolve(session({role:'admin'})); await tick(); assert.equal(posts(b).length,1); assert.equal(JSON.parse(posts(b)[0].body).generation,7);
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already have occurred/:/before lifecycle admission/);}
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already be persisted/:/before lifecycle admission/);}
assert.equal(posts(b).length,1);
});
await test('Lifecycle lost ACK/replaced result never clears pending or adopts old action results',async()=>{