Harden SSH Admission And Credential Input

This commit is contained in:
2026-09-15 20:49:04 +02:00
parent 436c27adb1
commit 751dfb9ddb
32 changed files with 1751 additions and 39 deletions
+13
View File
@@ -0,0 +1,13 @@
# Hidden credential input regression
From the repository root:
```sh
CCACHE_DISABLE=1 python3 tests/hidden_input/run.py
```
The host harness exercises production UART0/shared remote prompt handling and the extracted password-confirmation helper with synthetic input. It covers maximum capacity, sticky overflow (including differing suffixes and later backspace), unsupported bytes, cancellation, read failure, remote disconnect/revocation, confirmation rejection, secret-free output, remote CRLF handling and unchanged visible editing.
A rejected hidden prompt must return an error with zero length and wiped output, never a truncated credential prefix. These tests do not change password policy or persistence contracts.
The UART fake does not reproduce driver flushing or delayed paired-CRLF timing. No device, real transport or hardware validation is implied. Those checks are deferred to the [combined Phase 9 target session](../../docs/security_hardening.md#combined-phase-9-target-validation--deferred-not-run).
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Actual UART/shared remote prompt readers with deterministic host IO/RTOS fakes."""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
def stripped(name):
return "\n".join(line for line in (ROOT / name).read_text().splitlines()
if not line.startswith(("#include", "#pragma once"))) + "\n"
user = (ROOT / "src/user_console.c").read_text()
password = user[user.index("static esp_err_t read_password("):user.index("static void show_generated_password(")]
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ stripped("src/admin_ssh_console.h") + stripped("src/admin_ssh_console.c")
+ (ROOT / "tests/hidden_input/uart_fakes.h").read_text()
+ stripped("src/console_input.c")
+ "\n#undef printf\n#undef putchar\n#undef fflush\n"
+ "#define USER_DATABASE_PASSWORD_CAPACITY 64U\n"
+ "#define USER_DATABASE_PASSWORD_MIN_LENGTH 12U\n"
+ "#define ESP_ERR_INVALID_RESPONSE 100\n" + password
+ (ROOT / "tests/hidden_input/test.c").read_text())
with tempfile.TemporaryDirectory(prefix="hidden-input-") as directory:
path = Path(directory)
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
+126
View File
@@ -0,0 +1,126 @@
static admin_ssh_console_token_t token={.session_id=7,.slot_generation=1};
static user_principal_t admin={.role=USER_ROLE_ADMIN,.username="admin",.username_length=5};
static bool live=true, disconnect_input, revoke_input, password_mode, visible;
static esp_err_t expected;
static uint8_t answer[65];
static size_t answer_length;
static unsigned calls;
static bool owner_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
{ (void)t; (void)p; assert(!lock_depth); return live; }
static bool drained(const admin_ssh_console_token_t *t)
{ (void)t; assert(!lock_depth); 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)action; (void)arg; ++actions; return ESP_ERR_NOT_SUPPORTED; }
static const admin_console_owner_t owner={.is_current=owner_current,.drained=drained,.perform=perform};
static void zeroed(const void *data, size_t n)
{ const uint8_t *p=data; for(size_t i=0;i<n;++i) assert(p[i]==0); }
static void reply(void)
{
++publications;
/* Feed bytewise to exercise persistent state across transport packets. */
while(input_offset<input_size) {
uint8_t byte=input[input_offset++]; size_t consumed=0;
assert(admin_ssh_console_feed_input(&token,&byte,1,&consumed) && consumed==1);
if(s_sessions[0].prompt_state!=ADMIN_PROMPT_WAITING) break;
}
if(disconnect_input) admin_ssh_console_close(&token);
if(revoke_input) live=false;
}
static void command(void)
{
++calls;
memset(answer,0xa5,sizeof(answer)); answer_length=999;
esp_err_t error=password_mode ? read_password(answer,&answer_length) :
visible ? console_input_read_line("Input: ",answer,sizeof(answer),&answer_length) :
console_input_read_hidden("Password: ",answer,sizeof(answer),12,64,&answer_length);
assert(error==expected);
if(error==ESP_OK) {
assert(answer_length==64 && answer[64]==0);
for(size_t i=0;i<64;++i) assert(answer[i]=='Q');
} else {
assert(!answer_length); zeroed(answer,sizeof(answer));
}
if(s_dispatch_remote) {
zeroed(s_sessions[0].prompt_input,sizeof(s_sessions[0].prompt_input));
assert(!s_sessions[0].prompt_rejected && !s_sessions[0].prompt_length);
}
}
static void run_case(int route, const uint8_t *bytes, size_t n, esp_err_t result,
bool passwords, bool disconnect, bool revoke, bool show)
{
input=bytes; input_size=n; input_offset=0; expected=result;
password_mode=passwords; disconnect_input=disconnect; revoke_input=revoke; visible=show;
publications=0; calls=0; live=true; uart_output_length=0; uart_output[0]=0;
s_dispatch_remote=false;
if(route==0) command();
else {
++token.slot_generation; token.transport=route==1 ? ADMIN_CONSOLE_TRANSPORT_SSH : ADMIN_CONSOLE_TRANSPORT_WEB;
assert(admin_ssh_console_open_owned(&token,&admin,&owner)==ESP_OK);
uint8_t out[4096]; size_t count;
assert(admin_ssh_console_read_output(&token,out,sizeof(out),&count)==ESP_OK);
size_t consumed;
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)"user password target\r",21,&consumed));
assert(consumed==21);
prompt_hook=reply; command_hook=command;
if(!setjmp(loop_done)) worker_task(NULL);
prompt_hook=NULL; command_hook=NULL;
if(s_sessions[0].active) {
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&count)==ESP_OK);
out[count]=0;
if(!show) assert(!strstr((char *)out,"QQQ"));
admin_ssh_console_close(&token);
}
zeroed(&s_sessions[0],sizeof(s_sessions[0]));
}
assert(calls==1 && !lock_depth);
assert(input_offset==n);
if(!show) assert(!strstr(uart_output,"QQQ"));
assert(publications==(passwords && n>=130 ? 2U : 1U));
}
int main(void)
{
assert(owner_drained && actions==0);
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
uint8_t bytes[140];
for(int route=0;route<3;++route) {
memset(bytes,'Q',64); bytes[64]='\r';
run_case(route,bytes,65,ESP_OK,false,false,false,false);
bytes[64]='\n'; run_case(route,bytes,65,ESP_OK,false,false,false,false);
/* Confirmation accepts the exact maximum, not a truncated prefix. */
memcpy(bytes+65,bytes,65);
run_case(route,bytes,130,ESP_OK,true,false,false,false);
if(route) {
bytes[64]='\r'; bytes[65]='\n'; memset(bytes+66,'Q',64); bytes[130]='\r';
run_case(route,bytes,131,ESP_OK,true,false,false,false);
}
for(int suffix='X';suffix<='Y';++suffix) {
memset(bytes,'Q',64); bytes[64]=(uint8_t)suffix; bytes[65]='\r';
run_case(route,bytes,66,ESP_ERR_INVALID_SIZE,true,false,false,false);
bytes[65]=8; bytes[66]=127; bytes[67]='Q'; bytes[68]='\r';
run_case(route,bytes,69,ESP_ERR_INVALID_SIZE,false,false,false,false);
}
/* Unsupported controls/high bytes cannot silently disappear, even if erased. */
for(unsigned byte=0;byte<256;++byte) {
if((byte>=32 && byte<=126) || byte==3 || byte==8 || byte==127 || byte==10 || byte==13) continue;
memset(bytes,'Q',63); bytes[63]=(uint8_t)byte; bytes[64]=8;
bytes[65]='Q'; bytes[66]='Q'; bytes[67]='\r';
run_case(route,bytes,68,ESP_ERR_INVALID_SIZE,false,false,false,false);
}
memset(bytes,'Q',64); bytes[64]=8; bytes[65]='Q'; bytes[66]=127; bytes[67]='Q'; bytes[68]='\r';
run_case(route,bytes,69,ESP_OK,false,false,false,false);
memset(bytes,'Q',65); bytes[65]=3;
run_case(route,bytes,66,ESP_ERR_INVALID_STATE,false,false,false,false);
run_case(route,bytes,65,route ? ESP_ERR_NOT_FOUND : ESP_FAIL,false,route!=0,false,false);
if(route) run_case(route,bytes,65,ESP_ERR_NOT_FOUND,false,false,true,false);
/* Visible prompts retain their existing truncation/edit behavior. */
memset(bytes,'Q',65); bytes[65]=8; bytes[66]='Q'; bytes[67]='\r';
run_case(route,bytes,68,ESP_OK,false,false,false,true);
/* A failed confirmation also wipes the first full password. */
memset(bytes,'Q',64); bytes[64]='\r'; memset(bytes+65,'Q',65); bytes[130]='\r';
run_case(route,bytes,131,ESP_ERR_INVALID_SIZE,true,false,false,false);
}
puts("PASS: UART0/SSH/web exact capacity, sticky overflow/suffix/backspace, all unsupported bytes, cancellation/IO failure/disconnect/revocation, no echo, confirmation and visible editing");
}
+29
View File
@@ -0,0 +1,29 @@
#include <stdarg.h>
#define UART_NUM_0 0
static const uint8_t *input;
static size_t input_size, input_offset;
static char uart_output[4096];
static size_t uart_output_length;
static unsigned publications;
static esp_err_t uart_flush_input(int uart) { assert(uart==0); ++publications; return ESP_OK; }
static int uart_read_bytes(int uart, void *out, size_t n, unsigned wait)
{
assert(uart==0 && n==1 && wait==portMAX_DELAY && !lock_depth);
if (input_offset==input_size) return -1;
*(uint8_t *)out=input[input_offset++];
return 1;
}
static int capture_printf(const char *format, ...)
{
va_list ap; va_start(ap,format);
int n=vsnprintf(uart_output+uart_output_length,
sizeof(uart_output)-uart_output_length,format,ap);
va_end(ap); assert(n>=0 && (size_t)n<sizeof(uart_output)-uart_output_length);
uart_output_length+=(size_t)n; return n;
}
static int capture_putchar(int c) { return capture_printf("%c",c); }
static int capture_fflush(FILE *f) { (void)f; return 0; }
#define printf capture_printf
#define putchar capture_putchar
#define fflush capture_fflush