Add SSH Console Ownership Boundary (Phase 8D.4)

Implement transport-qualified session identity and immutable owner
adapters
for SSH console lifecycle and output-drain operations. Add focused host
tests
covering admission, stale identities, deferred actions, completion
races,
prompts, backpressure, and slot reuse. Update Phase 8D documentation and
current-state tracking.
This commit is contained in:
2026-09-06 09:07:11 +02:00
parent f9ee6eec9c
commit 117c694cd4
13 changed files with 488 additions and 60 deletions
+35
View File
@@ -0,0 +1,35 @@
#define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef struct { bool active, tx_pending; uint32_t session_id, generation; } ssh_transport_session_snapshot_t;
static ssh_transport_session_snapshot_t s_session_snapshots[2];
static unsigned stopped, disconnected, rotated, reset, restarted;
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; }
static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; }
static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; }
static void esp_restart(void) { ++restarted; }
static bool admin_console_drained(const admin_ssh_console_token_t *);
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
static void test_adapter(void)
{
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
user_principal_t admin={ .role=USER_ROLE_ADMIN };
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
assert(!admin_console_drained(&token));
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
assert(admin_console_drained(&token));
token.transport=1; assert(!admin_console_drained(&token));
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
token.slot_generation=3; s_session_snapshots[0].tx_pending=true;
assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false;
assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_OK && stopped==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
puts("PASS: actual SSH adapter identity/drain checks, legacy admission and lifecycle action routing");
}
+77
View File
@@ -0,0 +1,77 @@
#include <assert.h>
#include <errno.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <setjmp.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
typedef struct { int role; } user_principal_t;
typedef unsigned TickType_t;
typedef void *TaskHandle_t;
typedef int portMUX_TYPE;
typedef struct { size_t size; unsigned count; unsigned char bytes[2048]; } StaticQueue_t;
typedef StaticQueue_t *QueueHandle_t;
typedef int StaticSemaphore_t;
typedef int *SemaphoreHandle_t;
#define portMUX_INITIALIZER_UNLOCKED 0
#define pdTRUE 1
#define pdPASS 1
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions;
static bool principal_current = true, queue_full, owner_drained = true;
static TaskHandle_t current_task = (void *)1;
static jmp_buf loop_done;
static void (*delay_hook)(void), (*prompt_hook)(void), (*completion_hook)(void);
static void (*command_hook)(void);
#define taskENTER_CRITICAL(p) ((void)(p), ++lock_depth)
#define taskEXIT_CRITICAL(p) ((void)(p), --lock_depth)
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
static size_t strlcpy(char *d, const char *s, size_t n) {
size_t len = strlen(s); if (n) { size_t k = len < n-1 ? len : n-1;
memcpy(d, s, k); d[k] = 0; } return len;
}
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
{ (void)p; *c = principal_current; return ESP_OK; }
static const char *esp_err_to_name(int e) { (void)e; return "fake"; }
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
static unsigned xTaskGetTickCount(void) { return ticks; }
static void vTaskDelay(unsigned n) { assert(!lock_depth); ticks += n; if (delay_hook) delay_hook(); }
static int xTaskCreate(void (*f)(void *), const char *n, unsigned s, void *c,
unsigned p, TaskHandle_t *t)
{ (void)f; (void)n; (void)s; (void)c; (void)p; *t = (void *)1; return pdPASS; }
static void vTaskDelete(TaskHandle_t t) { (void)t; }
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
{ (void)n; (void)b; q->size = s; return q; }
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
{ (void)t; if (queue_full) return 0; assert(!q->count); memcpy(q->bytes,p,q->size); q->count=1; return 1; }
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; }
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ if (t && prompt_hook) prompt_hook(); int r=*s; *s=0; return r; }
static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
static char *linenoise(const char *p) { (void)p; return NULL; }
static int linenoiseHistoryAdd(const char *p) { (void)p; return 1; }
static void linenoiseFree(char *p) { (void)p; }
static bool console_completion_expand(const char *s, char *d, size_t n)
{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; }
static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len)
{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
static size_t esp_console_split_argv(char *s, char **v, size_t n)
{ (void)s; (void)v; (void)n; return 0; } /* Real parser tested by admin_ssh_policy. */
static esp_err_t esp_console_run(const char *s, int *r)
{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; }
typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
static int esp_console_cmd_register(const esp_console_cmd_t *c) { (void)c; return 0; }
static FILE *funopen(void *c, void *r, int (*w)(void *,const char *,int), void *s, void *f)
{ (void)c; (void)r; (void)w; (void)s; (void)f; return tmpfile(); }
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Compile actual console implementation with deterministic host RTOS/IO fakes.
No target scheduler, socket library, or hardware execution is claimed.
"""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / "src/admin_ssh_console.c").read_text()
header = (ROOT / "src/admin_ssh_console.h").read_text()
def strip_includes(text):
return "\n".join(line for line in text.splitlines()
if not line.startswith(("#include", "#pragma once")))
with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
path = Path(directory)
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/test.c").read_text())
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "test.c"),
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static bool admin_console_drained("):
ssh.index("static bool consume_external_close(")]
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
+ adapter + "\nint main(void) { test_adapter(); }\n")
(path / "adapter.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-variable", str(path / "adapter.c"),
"-o", str(path / "adapter")], check=True, timeout=30)
subprocess.run([str(path / "adapter")], check=True, timeout=10)
+132
View File
@@ -0,0 +1,132 @@
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
static bool drained(const admin_ssh_console_token_t *t)
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg)
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
static const admin_console_owner_t owner = {
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
};
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
static void feed(const admin_ssh_console_token_t *t, const char *s)
{ size_t n=0; assert(admin_ssh_console_feed_input(t,(const uint8_t *)s,strlen(s),&n)); assert(n==strlen(s)); }
static void clear_output(const admin_ssh_console_token_t *t)
{ uint8_t out[4096]; size_t n; assert(admin_ssh_console_read_output(t,out,sizeof(out),&n)==ESP_OK); }
static void competing_completion(void)
{
size_t n=99;
assert(!admin_ssh_console_feed_input(&b,(const uint8_t *)"\t",1,&n));
assert(n==0 && s_completion_busy);
}
static void reopen_during_completion(void)
{
assert(!lock_depth && s_completion_busy);
admin_ssh_console_close(&a);
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
clear_output(&a);
}
static void hidden_reply(void) { feed(&a,"secret\r"); }
static void cancel_reply(void) { feed(&a,"secret\x03"); }
static void close_prompt(void) { admin_ssh_console_close(&a); }
static void close_during_delay(void) { if (ticks>=200) admin_ssh_console_close(&a); }
static void close_during_command(void)
{
assert(s_sessions[0].executing);
admin_ssh_console_close(&a);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
}
static void setup_dispatch(void)
{ s_dispatch_remote=true; s_dispatch_token=a; s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
principal_current=false;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
principal_current=true;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
assert(admin_ssh_console_open_owned(&b,&admin,&owner)==ESP_OK);
admin_ssh_console_token_t other=a; other.transport=1;
assert(admin_ssh_console_open_owned(&other,&admin,&owner)==ESP_ERR_INVALID_STATE);
admin_ssh_console_close(&other);
assert(!admin_ssh_console_accepts_input(&other));
other=a; ++other.slot_generation; admin_ssh_console_close(&other);
assert(admin_ssh_console_accepts_input(&a));
clear_output(&a); clear_output(&b);
completion_hook=competing_completion; feed(&a,"\t"); completion_hook=NULL;
assert(!s_completion_busy && s_sessions[0].output_length && !s_sessions[1].output_length);
/* Keep the in-flight token unchanged; only the reopened session advances. */
admin_ssh_console_token_t completing=a;
completion_hook=reopen_during_completion; feed(&completing,"\t"); completion_hook=NULL;
assert(a.slot_generation==completing.slot_generation+1);
assert(!s_completion_busy && !s_sessions[0].output_length && !s_sessions[0].input_length);
assert(!admin_ssh_console_accepts_input(&completing));
feed(&a,"\t"); assert(s_sessions[0].output_length); clear_output(&a);
feed(&a,"help\r"); assert(runs==0); pump(worker_task); assert(runs==1);
feed(&a,"\x1b[A"); assert(!strcmp((char *)s_sessions[0].input,"help"));
feed(&a,"\x03");
feed(&a,"stale\r"); admin_ssh_console_close(&a);
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
pump(worker_task); assert(runs==1);
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply;
uint8_t secret[32]; size_t n;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK);
assert(n==6 && !memcmp(secret,"secret",6));
assert(s_sessions[0].output_length==strlen("Password: \r\n"));
clear_output(&a);
assert(admin_ssh_console_dispatch_read_input("Visible: ",secret,sizeof(secret),false,&n)==ESP_OK);
assert(s_sessions[0].output_length==strlen("Visible: secret\r\n"));
prompt_hook=cancel_reply;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_INVALID_STATE);
assert(n==0 && secret[0]==0 && s_sessions[0].prompt_input[0]==0);
prompt_hook=close_prompt;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_NOT_FOUND);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a);
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(!s_sessions[0].deferred_action_pending);
queue_full=true;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
pump(control_task); assert(ticks==10000 && actions==0);
clear_output(&a); owner_drained=true;
/* The fake esp_console_run does not invoke registered command callbacks. */
assert(command_exit(1,NULL)==0);
assert(s_control_queue->count==1);
admin_control_request_t exit_request;
memcpy(&exit_request,s_control_queue->bytes,sizeof(exit_request));
assert(exit_request.action==ADMIN_CONSOLE_DEFER_SELF_CLOSE);
assert(token_matches(&s_sessions[0],&exit_request.token));
assert(exit_request.owner==&owner && exit_request.argument==a.session_id);
ticks=0; pump(control_task); assert(ticks==200 && actions==1);
clear_output(&a);
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
ticks=0; delay_hook=close_during_delay; pump(control_task); delay_hook=NULL; assert(actions==1);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
s_sessions[0].output_length=4096; ticks=0;
assert(ssh_output_write(&a,"x",1)==-1 && errno==EAGAIN && ticks==5000);
clear_output(&a); s_dispatch_output_previous_cr=false;
assert(ssh_output_write(&a,"a\nb\r\n",6)==6);
assert(s_sessions[0].output_length==7);
admin_ssh_console_close(&a);
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
assert(!lock_depth);
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}