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
+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");
}