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)