Add SSH host identity rotation controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
@@ -253,7 +253,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
*(["-DHOST_LIFECYCLE"] if lifecycle else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
if lifecycle:
|
||||
|
||||
@@ -5,6 +5,15 @@ static uint32_t queued_id;
|
||||
static unsigned mutations, snapshots;
|
||||
static esp_err_t owner_error;
|
||||
static ssh_transport_management_snapshot_t owner_snapshot;
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher);
|
||||
memset(out, 0, sizeof(*out)); out->metadata.generation = 3;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||
assert(on_dispatcher && !host_lock_depth && service == 7 && identity == 3 && !reset);
|
||||
++mutations; *committed = owner_error == ESP_OK; return owner_error;
|
||||
}
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||
*out = owner_snapshot; return owner_error;
|
||||
@@ -132,6 +141,28 @@ static void ssh_settings_tests(void) {
|
||||
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
||||
const char *rotate = "{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":3}";
|
||||
const char *bad_rotations[] = {
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":0}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":9,\"identity_generation\":3}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"target\":0,\"identity_generation\":3}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":4294967295}"};
|
||||
for (unsigned i=0;i<sizeof(bad_rotations)/sizeof(*bad_rotations);++i) {
|
||||
operation_begin(&admin,bad_rotations[i]); expect_ssh("400 Bad Request",false);
|
||||
}
|
||||
for (unsigned i=0;i<2;++i) {
|
||||
owner_error=i ? ESP_FAIL : ESP_OK;
|
||||
operation_begin(&admin,rotate); expect_ssh("202 Accepted",false);
|
||||
before=mutations; execute(); assert(mutations==before+1 && s_operation.state==(i ? FAILED : OK));
|
||||
execute(); assert(mutations==before+1);
|
||||
operation_begin(&other,NULL); expect_ssh("401 Unauthorized",false);
|
||||
}
|
||||
owner_error=ESP_OK;
|
||||
operation_begin(&admin,rotate);expect_ssh("202 Accepted",false);
|
||||
before=mutations;web_session_store_invalidate(admin.view.id);execute();assert(s_operation.state==CANCELLED&&mutations==before);
|
||||
admin=mint(&alice);
|
||||
puts("PASS SSH rotation requires both generations, rejects extras/targets/exhaustion, executes once, preserves login isolation and cancels revoked queued work");
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||
const path='/api/settings/ssh', op=path+'-operation';
|
||||
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
||||
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,sessions:[row(),row(10,{route:2})],...extra});
|
||||
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,identity_generation:3,algorithm:'ecdsa-sha2-nistp256',fingerprint:'SHA256:'+'A'.repeat(43),rotatable:!extra.transitioning&&extra.generation!==4294967295,sessions:[row(),row(10,{route:2})],...extra});
|
||||
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
||||
const n=(b,id)=>b.nodes['ssh-'+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-ssh');await tick();return b;}
|
||||
@@ -13,7 +13,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
||||
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
||||
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
||||
assert.equal(n(b,'values').children[0].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
||||
assert.equal(n(b,'values').children[8].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
||||
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
||||
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
||||
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
||||
@@ -29,6 +29,20 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
||||
}
|
||||
});
|
||||
await test('SSH host rotation confirms fingerprint and both generations, preserves HTTPS and never replays',async()=>{
|
||||
const b=await open(); let text=''; b.window.confirm=t=>{text=t;return false;}; b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
|
||||
for(const pattern of [/SHA256:/,/identity generation 3/,/service generation 7/,/ALL SSH sessions/,/known_hosts/,/trusted UART0/,/ssh host-key info/,/HTTPS stays accessible/,/persistence fails/])assert.match(text,pattern);
|
||||
await submit(b,'rotate');assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,target:0,identity_generation:3});
|
||||
b.click('ssh-rotate');await tick();assert.equal(posts(b).length,1);assert.equal(b.sockets.length,2);
|
||||
b.queues[op].push(reply('failed',42,200,'rotate'));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/persisted even if restart failed/);
|
||||
await refresh(b,fixture({identity_generation:4}));assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('SSH host metadata malformed or unavailable fails closed for rotation only',async()=>{
|
||||
for(const extra of [{identity_generation:0,fingerprint:''},{identity_generation:4294967295},{fingerprint:'private-key'},{algorithm:'ssh-rsa'}]){
|
||||
const b=await open(fixture(extra));assert.ok(n(b,'rotate').disabled);b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
|
||||
}
|
||||
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));assert.ok(n(b,'rotate').disabled);assert.equal(n(b,'stop').disabled,false);
|
||||
});
|
||||
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
||||
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
||||
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
||||
|
||||
Reference in New Issue
Block a user