Add SSH host identity rotation controls

This commit is contained in:
2026-09-13 19:58:05 +02:00
parent aa4bbc2c8c
commit 8df1d2218b
19 changed files with 626 additions and 107 deletions
+32 -1
View File
@@ -40,6 +40,19 @@ static bool s_initialized, s_running, s_transitioning, s_cleanup_pending, s_desi
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
static int s_command_result;
static bool owner_stalled, owner_fail;
static uint32_t identity_generation = 3, identity_token;
static unsigned replacements;
static bool persist_fail;
static esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token) {
(void)reset; assert(mutex_storage && !depth); *token = 0;
if (identity_token || (generation && generation != identity_generation)) return ESP_ERR_INVALID_STATE;
*token = identity_token = 1; return ESP_OK;
}
static esp_err_t ssh_security_replace_reserved(uint32_t token) {
assert(token == identity_token && mutex_storage && !depth && !s_running && !s_cleanup_pending);
++replacements; if (persist_fail) return ESP_FAIL; ++identity_generation; return ESP_OK;
}
static void ssh_security_release_identity(uint32_t token) { if (token) { assert(identity_token == token && mutex_storage && !depth); identity_token = 0; } }
static int s_lock;
#define taskENTER_CRITICAL(p) do { (void)(p); assert(depth++ == 0); } while(0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--depth == 0); } while(0)
@@ -71,6 +84,24 @@ static void reset(void) {
}
}
int main(void) {
reset(); bool committed = true;
assert(ssh_transport_replace_identity(6,3,false,&committed)==ESP_ERR_INVALID_STATE && !committed && !notifications && !replacements);
assert(ssh_transport_replace_identity(7,2,false,&committed)==ESP_ERR_INVALID_STATE && !notifications && !replacements);
identity_token=1; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_INVALID_STATE && !notifications); identity_token=0;
mutex_storage=1; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_TIMEOUT && !notifications); mutex_storage=0;
owner_fail=true; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_FAIL && !committed && !replacements && notifications==1 && s_cleanup_pending && !identity_token);
assert(ssh_transport_start()==ESP_ERR_INVALID_STATE && notifications==1);
reset(); owner_stalled=true;
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_TIMEOUT && !committed && !replacements && notifications==1 && s_transitioning && !identity_token);
reset(); persist_fail=true;
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_FAIL && !committed && replacements==1 && notifications==2 && s_running && identity_generation==3);
reset(); persist_fail=false;
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_OK && committed && s_running && identity_generation==4 && notifications==2);
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_INVALID_STATE && !committed && notifications==2);
reset(); s_running=false;
assert(ssh_transport_replace_identity(7,4,false,&committed)==ESP_OK && committed && !s_running && !notifications);
assert(ssh_transport_replace_host_key(true)==ESP_OK && s_running && notifications==1);
puts("PASS SSH combined generation/service-owner admission, competing reservation, failed-stop no mutation/start, timeout retention, persistence recovery, replay fence and stopped/reset semantics");
reset(); ssh_transport_management_snapshot_t v;
assert(ssh_transport_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.generation == 7 && v.running && !v.transitioning);
@@ -128,7 +159,7 @@ int main(void) {
puts("PASS SSH admitted timeout is not cancellation; failed cleanup and saturated versions preserve CLI recovery");
}
'''
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_replace_identity', 'ssh_transport_replace_host_key', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
# Guard the accept path, which is not executed with the socket double here.
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
assert 'next_generation(slot->generation)' not in source
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Exact production stop/start/process-slot functions with retained-resource doubles."""
from pathlib import Path
import re
import subprocess
import tempfile
root=Path(__file__).resolve().parents[2]
source=(root/'src/ssh_transport.c').read_text()
def function(name):
m=re.search(r'^static [^\n]+\b'+name+r'\([^;]*?\n\{.*?^\}',source,re.M|re.S)
assert m,name
return m.group()+'\n'
fakes=r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#define SSH_TRANSPORT_MAX_SESSIONS 2
#define SSH_TRANSPORT_SESSION_FREE 0
#define SSH_TRANSPORT_SESSION_CLOSING 3
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
#define SSH_TRANSPORT_SESSION_ACTIVE 2
#define ESP_OK 0
#define ESP_FAIL 1
#define ESP_ERR_TIMEOUT 2
#define ESP_ERR_INVALID_STATE 3
#define pdMS_TO_TICKS(n) (n)
typedef int esp_err_t;
typedef struct { int state; bool close_requested; } ssh_slot_t;
static ssh_slot_t s_slots[2];
static void *s_context;
static int s_listen_fd=-1, s_lock;
static unsigned depth, frees, creates;
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail;
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
static void close_socket(int *fd) { assert(!depth); *fd=-1; }
static void vTaskDelay(unsigned n) { (void)n; assert(!depth); }
static void request_slot_close(ssh_slot_t *s,bool revoked) { (void)revoked; if(s->state)s->close_requested=true; }
static bool cleanup_slot(ssh_slot_t *s) { if(cleanup_fail)return false; s->state=0;return true; }
static void publish_slot(ssh_slot_t *s,size_t i) { (void)s;(void)i; }
static bool consume_external_close(ssh_slot_t *s,size_t i) { (void)s;(void)i;return false; }
static void process_handshake(ssh_slot_t *s,size_t i) { (void)s;(void)i;assert(0); }
static void process_active(ssh_slot_t *s,size_t i) { (void)s;(void)i;assert(0); }
static esp_err_t create_context(void) { assert(!s_context && !depth); ++creates;s_context=(void *)1;return ESP_OK; }
static esp_err_t create_listener(void) { if(listener_fail)return ESP_FAIL;s_listen_fd=22;return ESP_OK; }
'''
tests=r'''
int main(void) {
assert(start_runtime()==ESP_OK && creates==1);
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
s_slots[0].state=2;cleanup_fail=true;
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1);
s_cleanup_pending=true;s_running=false;
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
process_slots();assert(s_context && s_cleanup_pending && !frees);
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
process_slots();assert(frees==1);
assert(start_runtime()==ESP_OK && creates==2);assert(stop_runtime()==ESP_OK && frees==2);
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1);
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
}
'''
with tempfile.TemporaryDirectory(prefix='ssh-runtime-') as directory:
out=Path(directory)
(out/'test.c').write_text(fakes+''.join(function(n) for n in ('start_runtime','stop_runtime','process_slots'))+tests)
subprocess.run(['cc','-std=c11','-Wall','-Wextra','-Werror',str(out/'test.c'),'-o',str(out/'test')],check=True,timeout=30)
subprocess.run([str(out/'test')],check=True,timeout=10)
+152
View File
@@ -0,0 +1,152 @@
/* Full canonical storage/crypto; no hardware/power-loss/scheduler claims. */
#include <assert.h>
#include <stdio.h>
#include <sys/random.h>
#include "../../src/ssh_security.c"
static unsigned depth, handles, wipes;
static bool locked, busy, rng_fail, command_locked;
static SemaphoreHandle_t s_command_mutex = (void *)2;
static int s_lock;
static bool s_initialized=true, s_running=true, s_transitioning, s_cleanup_pending, s_desired_running;
static uint32_t s_management_generation=7, s_requested_sequence, s_completed_sequence;
static esp_err_t s_command_result;
static unsigned ticks, notifications;
static bool stop_fail, start_fail;
typedef unsigned TickType_t;
#define pdMS_TO_TICKS(n) (n)
#define SSH_TRANSPORT_COMMAND_TIMEOUT_MS 100
static unsigned xTaskGetTickCount(void) { return ticks; }
static void notify_task(void) { assert(!depth && command_locked); ++notifications; }
static int fault;
static void *task = (void *)1;
static void (*hook)(void);
static ssh_security_blob_t stored, pending, before;
static bool present, staged;
void enter(void) { assert(!depth++); }
void leave(void) { assert(!--depth); }
TaskHandle_t xTaskGetCurrentTaskHandle(void) { return task; }
void vTaskDelay(unsigned n) {
assert(command_locked && !depth && !locked); ticks+=n;
s_completed_sequence=s_requested_sequence;
s_command_result=(s_desired_running ? start_fail : stop_fail) ? ESP_FAIL : ESP_OK;
s_running=s_command_result==ESP_OK && s_desired_running;
s_cleanup_pending=s_command_result!=ESP_OK; s_transitioning=false;
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!depth); return (void *)1; }
int xSemaphoreTake(SemaphoreHandle_t m, unsigned wait) { if(m==s_command_mutex) { assert(!depth);if(command_locked){assert(!wait);return 0;}command_locked=true;return 1;} assert(m && !depth && !locked); if (busy) { assert(!wait); return 0; } locked = true; return 1; }
int xSemaphoreGive(SemaphoreHandle_t m) { if(m==s_command_mutex){assert(!depth && command_locked);command_locked=false;return 1;} assert(m && locked && !depth); locked = false; return 1; }
void secure_wipe(void *p, size_t n) { volatile unsigned char *b = p; for (size_t i=0;i<n;++i) b[i]=0; ++wipes; }
esp_err_t secure_random_init(void) { assert(!depth); return ESP_OK; }
esp_err_t secure_random_fill(void *p, size_t n) {
assert(!depth && (!s_identity_token || !locked));
if (hook) { void (*f)(void)=hook; hook=NULL; f(); }
return !rng_fail && getrandom(p,n,0)==(ssize_t)n ? ESP_OK : ESP_FAIL;
}
int secure_random_mbedtls(void *ctx, unsigned char *p, size_t n) { (void)ctx; return secure_random_fill(p,n)==ESP_OK ? 0 : -1; }
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *h) {
assert(!depth && !strcmp(name,SSH_SECURITY_NVS_NAMESPACE));
if (fault==1 && mode==NVS_READWRITE) return ESP_FAIL;
assert(!handles++); *h=mode; return ESP_OK;
}
esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *p,size_t *n) {
assert(handles && h==NVS_READONLY && !strcmp(key,"material"));
if (!present) return ESP_ERR_NVS_NOT_FOUND;
if (p) { assert(*n>=sizeof(stored)); memcpy(p,&stored,sizeof(stored)); }
*n=sizeof(stored); return ESP_OK;
}
esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *p,size_t n) {
assert(handles && h==NVS_READWRITE && !strcmp(key,"material") && n==312 && !depth);
if (s_identity_token) assert(!locked && !memcmp(&s_material,&before,sizeof(before)));
if (fault==2) return ESP_FAIL;
memcpy(&pending,p,n); staged=true; return ESP_OK;
}
esp_err_t nvs_commit(nvs_handle_t h) {
assert(handles && h==NVS_READWRITE && staged && !depth);
if (fault==3) return ESP_FAIL;
stored=pending; present=true; return ESP_OK;
}
void nvs_close(nvs_handle_t h) { (void)h; assert(handles--==1); staged=false; secure_wipe(&pending,sizeof(pending)); }
#include "owner.inc"
static void competitor(void) {
assert(!depth && !locked);
ssh_security_identity_snapshot_t v;
assert(ssh_security_get_identity_snapshot(&v)==ESP_OK && v.busy);
assert(v.metadata.generation==before.generation);
assert(!memcmp(v.metadata.sha256_fingerprint,before.sha256_fingerprint,32));
assert(ssh_security_rotate()==ESP_ERR_INVALID_STATE);
assert(ssh_security_reset()==ESP_ERR_INVALID_STATE);
task=(void *)2;
if(command_locked) {
bool committed;
assert(ssh_transport_replace_identity(s_management_generation,before.generation,false,&committed)==ESP_ERR_TIMEOUT);
assert(ssh_transport_replace_host_key(true)==ESP_ERR_TIMEOUT);
}
assert(ssh_security_replace_reserved(s_identity_token)==ESP_ERR_INVALID_STATE);
uint32_t token=s_identity_token; ssh_security_release_identity(token); assert(s_identity_token==token);
task=(void *)1;
}
int main(void) {
ssh_security_load_result_t result;
assert(ssh_security_init(&result)==ESP_OK && result==SSH_SECURITY_LOAD_GENERATED_MISSING);
assert(s_material.generation==1 && validate_blob(&s_material)==ESP_OK && !handles);
before=s_material;
uint8_t der[256]; size_t size=0;
assert(ssh_security_copy_private_key(der,sizeof(der),&size)==ESP_OK && size==before.private_key_length);
assert(!memcmp(der,before.private_key_der,size)); secure_wipe(der,sizeof(der));
s_material_ready=false; assert(ssh_security_init(&result)==ESP_OK && !memcmp(&s_material,&before,sizeof(before)));
puts("PASS SSH real P256 generation/validation, bounded DER copy, exact persisted reload and handle closure");
for (fault=1;fault<=3;++fault) {
before=s_material; hook=competitor;
assert(ssh_security_rotate()!=ESP_OK && !s_identity_token && !handles);
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)));
}
fault=0; rng_fail=true; before=s_material;
assert(ssh_security_rotate()!=ESP_OK && !s_identity_token && !handles);
assert(!memcmp(&s_material,&before,sizeof(before))); rng_fail=false;
puts("PASS SSH real crypto RNG/NVS open-set-commit faults, unchanged live/stored bytes, reservation exclusion outside locks and wipes");
bool committed;
before=s_material;
unsigned old_notifications=notifications;
assert(ssh_transport_replace_identity(6,1,false,&committed)==ESP_ERR_INVALID_STATE && notifications==old_notifications);
assert(ssh_transport_replace_identity(7,2,false,&committed)==ESP_ERR_INVALID_STATE && notifications==old_notifications);
stop_fail=true;
assert(ssh_transport_replace_identity(7,1,false,&committed)==ESP_FAIL && !committed && notifications==old_notifications+1);
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)));
stop_fail=false;assert(ssh_transport_stop()==ESP_OK);assert(ssh_transport_start()==ESP_OK);
for(fault=1;fault<=3;++fault) {
before=s_material;hook=competitor;
assert(ssh_transport_replace_identity(s_management_generation,1,false,&committed)==ESP_FAIL && !committed && s_running);
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)) && !handles);
}
fault=0;start_fail=true;before=s_material;hook=competitor;
assert(ssh_transport_replace_identity(s_management_generation,1,false,&committed)==ESP_FAIL && committed && !s_running);
assert(s_material.generation==2 && !memcmp(&stored,&s_material,sizeof(stored)));
start_fail=false;assert(ssh_transport_stop()==ESP_OK);assert(ssh_transport_start()==ESP_OK);
puts("PASS integrated canonical SSH owner + real crypto/NVS: stale admission untouched, failed stop skips crypto/start, persistence failures restore old identity, committed restart failure never rolls back, competing CLI/direct owners excluded");
before=s_material; hook=competitor; assert(ssh_security_rotate()==ESP_OK);
assert(s_material.generation==3 && memcmp(before.sha256_fingerprint,s_material.sha256_fingerprint,32));
assert(validate_blob(&s_material)==ESP_OK && !memcmp(&stored,&s_material,sizeof(stored)));
uint32_t token=0, newer=0;
assert(ssh_security_reserve_identity(1,false,&token)==ESP_ERR_INVALID_STATE && !token);
assert(ssh_security_reserve_identity(3,false,&token)==ESP_OK);
ssh_security_release_identity(token);
assert(ssh_security_reserve_identity(3,false,&newer)==ESP_OK && newer!=token);
ssh_security_release_identity(token); assert(s_identity_token==newer);
assert(ssh_security_replace_reserved(token)==ESP_ERR_INVALID_STATE);
before=s_material; assert(ssh_security_replace_reserved(newer)==ESP_OK);
assert(ssh_security_replace_reserved(newer)==ESP_ERR_INVALID_STATE); ssh_security_release_identity(newer);
puts("PASS SSH expected generation, owner-only nonreused token, stale release/replace and one-shot replacement");
ssh_security_identity_snapshot_t v;
busy=true; assert(ssh_security_get_identity_snapshot(&v)==ESP_ERR_TIMEOUT && !v.metadata.generation);
busy=false; s_next_identity_token=UINT32_MAX;
assert(ssh_security_get_identity_snapshot(&v)==ESP_OK && v.busy);
assert(ssh_security_rotate()==ESP_ERR_INVALID_STATE);
s_next_identity_token=0; s_material.generation=UINT32_MAX;
assert(ssh_security_reset()==ESP_ERR_INVALID_STATE);
s_material_ready=false; stored.schema_version=99;
assert(ssh_security_init(NULL)==ESP_ERR_INVALID_VERSION && stored.schema_version==99);
before=s_material; assert(ssh_security_reset()==ESP_OK && s_material.generation==1 && validate_blob(&s_material)==ESP_OK);
assert(!handles && !locked && !depth && wipes);
puts("PASS SSH zero-wait public snapshot, saturation, corrupt-material fail-closed and canonical reset recovery");
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Full production SSH security with real mbedTLS and fault-injected NVS/RTOS."""
import ast
import re
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
tree = ast.parse((ROOT / 'tests/web_security/run.py').read_text())
headers = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'HEADERS' for t in n.targets)))
headers['freertos/FreeRTOS.h'] += '''
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
void enter(void);
void leave(void);
#define taskENTER_CRITICAL(p) do { (void)(p); enter(); } while (0)
#define taskEXIT_CRITICAL(p) do { (void)(p); leave(); } while (0)
'''
headers['freertos/task.h'] = '''#pragma once
typedef void *TaskHandle_t;
TaskHandle_t xTaskGetCurrentTaskHandle(void);
void vTaskDelay(unsigned);
'''
with tempfile.TemporaryDirectory(prefix='ssh-security-') as directory:
out = Path(directory)
for name, text in headers.items():
path = out / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
source = (ROOT/'src/ssh_transport.c').read_text()
names = ('next_generation','request_running_locked','request_running','ssh_transport_start','ssh_transport_stop','ssh_transport_replace_identity','ssh_transport_replace_host_key')
owner = ''.join(re.search(r'^(?:static )?[^\n]+\b'+name+r'\([^;]*?\n\{.*?^\}',source,re.M|re.S).group()+'\n' for name in names)
(out/'owner.inc').write_text(re.search(r'^#define SSH_TRANSPORT_GENERATION_MAX .+$',source,re.M).group()+'\n'+owner)
subprocess.run(['cc','-std=c11','-Wall','-Wextra','-Werror','-g','-I'+str(out),'-I'+str(ROOT/'src'),str(ROOT/'tests/ssh_management/security.c'),'-lmbedcrypto','-o',str(out/'test')],check=True,timeout=30)
subprocess.run([str(out/'test')],check=True,timeout=30)