Harden SSH Admission And Credential Input
This commit is contained in:
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user