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
+2 -1
View File
@@ -8,7 +8,8 @@
#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 };
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND,
ESP_ERR_INVALID_SIZE };
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
#define USER_DATABASE_USERNAME_CAPACITY 16U
typedef struct {
+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
+54
View File
@@ -0,0 +1,54 @@
# SSH authentication admission policy host tests
Run from the repository root:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_auth_policy/run.py
CCACHE_DISABLE=1 CFLAGS='-O1 -g -fsanitize=undefined -fno-sanitize-recover=all' python3 tests/ssh_auth_policy/run.py
```
Requires Python 3 and a host C11 compiler (`cc`, or `CC`). `CFLAGS` may override
optimization/add sanitizers. The runner disables ccache, compiles the actual
`src/ssh_auth_policy.c` with strict warnings, and runs in a temporary directory.
No ESP-IDF, mocks, third-party dependencies, sleeps or real clock are involved.
## Contract
`ssh_auth_policy_t` is zero-initialized, allocation-free, single-owner state
(72 bytes on the tested host, compile-time limit of 72 bytes). Three independent
buckets lazily start at capacity:
| Kind | Capacity | Refill |
| --- | --- | --- |
| `SSH_AUTH_POLICY_HANDSHAKE` | 6 | 1 token / 10 seconds |
| `SSH_AUTH_POLICY_VERIFICATION` | 6 | 1 token / 10 seconds |
| `SSH_AUTH_POLICY_PROBE` | 12 | 1 token / 5 seconds |
The header exposes each capacity and refill interval in microseconds.
`ssh_auth_policy_admit(policy, kind, now_us)` consumes one token on success,
with no refund for subsequent failure. Below capacity, fractional elapsed credit
is retained. Reaching capacity discards all surplus, including fractional credit.
Empty-bucket denials do not shift the refill deadline or incur debt.
Time must be nonnegative and nondecreasing across the shared policy, including
across classes and ordinary rate-limit denials. Equal timestamps are valid.
Negative/regressing time, invalid enum values and NULL fail closed without
mutation. Refill arithmetic remains bounded through `INT64_MAX`.
The integrating SSH owner must retain ONE static policy across callers/sessions,
SSH stop/start, and counter clears; only reboot zeroes it. There are no locks,
timers, clock reads, sleeps, reset or refund APIs. These tests do not integrate
`ssh_transport` or CMake, and do not validate lifecycle wiring.
## Coverage
All three classes: initial burst at zero and `INT64_MAX`; refill boundaries at
minus/exact/plus one microsecond; partial and multi-token credit; 998 consecutive
exact-rate refill cycles with intervening denials; idle saturation with no
fractional surplus; huge forward jumps including zero to `INT64_MAX`; negative
and regressing time with byte-for-byte unchanged state; regression after an
initial zero timestamp and after denial. Also checks invalid enum/NULL handling,
independent class budgets, cross-class monotonic validation, and two logical
callers sharing one exhausted budget.
Hardware validation is deferred to the combined Phase 9 validation.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Compile and run the actual allocation-free firmware policy on the host."""
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
env = dict(os.environ, CCACHE_DISABLE="1")
with tempfile.TemporaryDirectory(prefix="ssh-auth-policy-") as temporary:
binary = Path(temporary) / "test"
command = shlex.split(env.get("CC", "cc")) + [
"-std=c11", "-Wall", "-Wextra", "-Werror", "-pedantic",
*shlex.split(env.get("CFLAGS", """-O2""")),
"-I", str(ROOT / "src"),
str(ROOT / "src/ssh_auth_policy.c"),
str(ROOT / "tests/ssh_auth_policy/test.c"),
"-o", str(binary),
]
subprocess.run(command, env=env, check=True)
subprocess.run([str(binary)], env=env, check=True)
+147
View File
@@ -0,0 +1,147 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_auth_policy.h"
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CHECK(condition) do { \
if (!(condition)) { \
fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #condition); \
exit(EXIT_FAILURE); \
} \
} while (0)
_Static_assert(sizeof(ssh_auth_policy_t) <= 72, "policy must remain small");
_Static_assert(SSH_AUTH_POLICY_HANDSHAKE_CAPACITY == 6, "handshake burst");
_Static_assert(SSH_AUTH_POLICY_VERIFICATION_CAPACITY == 6, "verification burst");
_Static_assert(SSH_AUTH_POLICY_PROBE_CAPACITY == 12, "probe burst");
_Static_assert(SSH_AUTH_POLICY_HANDSHAKE_REFILL_US == 10000000, "handshake rate");
_Static_assert(SSH_AUTH_POLICY_VERIFICATION_REFILL_US == 10000000, "verification rate");
_Static_assert(SSH_AUTH_POLICY_PROBE_REFILL_US == 5000000, "probe rate");
static void drain(ssh_auth_policy_t *p, ssh_auth_policy_kind_t kind,
int64_t now, unsigned count)
{
for (unsigned i = 0; i < count; ++i) {
CHECK(ssh_auth_policy_admit(p, kind, now));
}
CHECK(!ssh_auth_policy_admit(p, kind, now));
}
static void unchanged(ssh_auth_policy_t *p, ssh_auth_policy_kind_t kind, int64_t now)
{
unsigned char before[sizeof(*p)];
memcpy(before, p, sizeof(*p));
CHECK(!ssh_auth_policy_admit(p, kind, now));
CHECK(memcmp(before, p, sizeof(*p)) == 0);
}
static void test_class(ssh_auth_policy_kind_t kind, unsigned capacity, int64_t interval)
{
ssh_auth_policy_t p = {0};
drain(&p, kind, 0, capacity);
unchanged(&p, kind, -1); /* Initial zero is a real timestamp. */
CHECK(!ssh_auth_policy_admit(&p, kind, 1));
unchanged(&p, kind, 0); /* Regression after an empty-bucket denial. */
CHECK(!ssh_auth_policy_admit(&p, kind, interval - 1));
unchanged(&p, kind, interval - 2);
CHECK(ssh_auth_policy_admit(&p, kind, interval));
CHECK(!ssh_auth_policy_admit(&p, kind, interval + 1));
CHECK(ssh_auth_policy_admit(&p, kind, 2 * interval));
/* Many denials cannot extend cooldown; exactly one token each interval. */
for (int64_t n = 3; n <= 1000; ++n) {
CHECK(!ssh_auth_policy_admit(&p, kind, n * interval - 1));
CHECK(ssh_auth_policy_admit(&p, kind, n * interval));
CHECK(!ssh_auth_policy_admit(&p, kind, n * interval));
CHECK(!ssh_auth_policy_admit(&p, kind, n * interval + 1));
}
p = (ssh_auth_policy_t){0};
drain(&p, kind, 0, capacity);
/* Refill multiple tokens without losing fractional elapsed credit. */
CHECK(ssh_auth_policy_admit(&p, kind, 2 * interval + interval / 2));
CHECK(ssh_auth_policy_admit(&p, kind, 3 * interval - 1));
CHECK(!ssh_auth_policy_admit(&p, kind, 3 * interval - 1));
CHECK(ssh_auth_policy_admit(&p, kind, 3 * interval));
p = (ssh_auth_policy_t){0};
CHECK(ssh_auth_policy_admit(&p, kind, 0));
const int64_t idle = 100 * interval + interval / 2;
drain(&p, kind, idle, capacity); /* Full idle discards fractional surplus. */
CHECK(!ssh_auth_policy_admit(&p, kind, idle + interval - 1));
CHECK(ssh_auth_policy_admit(&p, kind, idle + interval));
CHECK(!ssh_auth_policy_admit(&p, kind, idle + interval + 1));
p = (ssh_auth_policy_t){0};
drain(&p, kind, 0, capacity);
const int64_t late = INT64_MAX - interval;
drain(&p, kind, late, capacity); /* Huge forward jump saturates, not wraps. */
CHECK(!ssh_auth_policy_admit(&p, kind, INT64_MAX - 1));
CHECK(ssh_auth_policy_admit(&p, kind, INT64_MAX));
CHECK(!ssh_auth_policy_admit(&p, kind, INT64_MAX));
unchanged(&p, kind, INT64_MAX - 1);
p = (ssh_auth_policy_t){0};
drain(&p, kind, INT64_MAX, capacity); /* Lazy init at maximum timestamp. */
p = (ssh_auth_policy_t){0};
drain(&p, kind, 0, capacity);
drain(&p, kind, INT64_MAX, capacity); /* Direct maximum-sized subtraction. */
}
static bool caller_a(ssh_auth_policy_t *p)
{
return ssh_auth_policy_admit(p, SSH_AUTH_POLICY_HANDSHAKE, 0);
}
static bool caller_b(ssh_auth_policy_t *p)
{
return ssh_auth_policy_admit(p, SSH_AUTH_POLICY_HANDSHAKE, 0);
}
static void test_validation_and_sharing(void)
{
ssh_auth_policy_t p = {0};
CHECK(!ssh_auth_policy_admit(NULL, SSH_AUTH_POLICY_HANDSHAKE, 0));
unchanged(&p, SSH_AUTH_POLICY_HANDSHAKE, INT64_MIN);
unchanged(&p, (ssh_auth_policy_kind_t)-1, 0);
unchanged(&p, SSH_AUTH_POLICY_KIND_COUNT, 0);
unchanged(&p, (ssh_auth_policy_kind_t)INT_MAX, INT64_MAX);
for (unsigned i = 0; i < 3; ++i) {
CHECK(caller_a(&p));
CHECK(caller_b(&p));
}
CHECK(!caller_a(&p));
CHECK(!caller_b(&p));
drain(&p, SSH_AUTH_POLICY_VERIFICATION, 0, 6);
drain(&p, SSH_AUTH_POLICY_PROBE, 0, 12);
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_PROBE, 5000000));
CHECK(!ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_HANDSHAKE, 5000000));
CHECK(!ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_VERIFICATION, 5000000));
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_HANDSHAKE, 10000000));
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_VERIFICATION, 10000000));
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_PROBE, 10000000));
unchanged(&p, (ssh_auth_policy_kind_t)-1, INT64_MAX);
unchanged(&p, SSH_AUTH_POLICY_PROBE, -1);
unchanged(&p, SSH_AUTH_POLICY_VERIFICATION, 9999999);
p = (ssh_auth_policy_t){0};
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_HANDSHAKE, 100));
unchanged(&p, SSH_AUTH_POLICY_PROBE, 99); /* Even an uninitialized class. */
drain(&p, SSH_AUTH_POLICY_PROBE, 100, 12);
}
int main(void)
{
test_class(SSH_AUTH_POLICY_HANDSHAKE, SSH_AUTH_POLICY_HANDSHAKE_CAPACITY,
SSH_AUTH_POLICY_HANDSHAKE_REFILL_US);
test_class(SSH_AUTH_POLICY_VERIFICATION, SSH_AUTH_POLICY_VERIFICATION_CAPACITY,
SSH_AUTH_POLICY_VERIFICATION_REFILL_US);
test_class(SSH_AUTH_POLICY_PROBE, SSH_AUTH_POLICY_PROBE_CAPACITY,
SSH_AUTH_POLICY_PROBE_REFILL_US);
test_validation_and_sharing();
printf("ssh_auth_policy: all tests passed (policy size: %zu bytes)\n", sizeof(ssh_auth_policy_t));
return EXIT_SUCCESS;
}
+35
View File
@@ -0,0 +1,35 @@
# SSH authentication transport host regression
Run from the repository root:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_auth_transport/run.py
```
Requires Python 3 and a C11 host compiler (`cc`, or `CC` override). The runner also sets `CCACHE_DISABLE=1` for its subprocesses. Compilation uses `-Wall -Wextra -Werror`; compilation and execution have timeouts. Generated C and executable live in a temporary directory, not the source tree. Assertion or compiler failures propagate as a nonzero exit.
## Production code under test
Following `tests/ssh_management/runtime.py`, `run.py` extracts named function definitions **verbatim** from `src/ssh_transport.c`. It extracts the actual slot definition, transport constants, session/route enums and counters declaration too, and links the actual `src/ssh_auth_policy.c` with its header. Missing/ambiguous function definitions fail extraction rather than silently falling back to fixture implementations.
The extracted set covers authentication callbacks and helpers, counter saturation/clear, socket acceptance and free-slot selection, start/stop, retirement, admin RX, serial RX and shared TX flushing. The fixture supplies only synchronous boundary doubles for clock, database, socket/library allocation and I/O, broker, console, publication and context/listener construction. No credential verifier, admission algorithm or callback logic is reimplemented. `boot()` resets test state between independent scenarios; the restart tests use actual production start/stop without resetting the policy.
## Coverage
- Password success, invalid password, backend failure and rejected password change; admissions versus completed attempts, principal promotion and pending-principal clearing.
- Signed key authorization denial/backend error, bad signature result, stale principal, currentness backend failure and success. Admission precedes authorization, authorized signed keys await completion, and duplicate/unexpected callbacks cannot create a promotion or count again. Verification-throttled signed requests do not reach authorization or acquire a pending signature-result marker.
- Close/state/pending-result fences, unsupported method handling, keyboard callback rejection with the entire prompt structure cleared and no repeated method count after close.
- Unsigned authorized/rejected probes share a 12-token pool across both slots/reconnects, refill one token at five seconds and never become completed attempts. Password/signed verification shares six tokens, refilling one per ten seconds. Three completed failed password or signed-key attempts close the slot.
- Actual acceptance loop: two-slot capacity, six handshake admissions, allocation failures consume admission, throttle denial before allocation, and one-token/ten-second refill.
- Actual stop/start and counter clear preserve all three exhausted pools. Counter clear initialization guard, 64-bit saturating addition and per-slot 8-bit saturation.
- Partial admin RX consumption (including `false` with consumed bytes), zero-consumption rejection and full drain wipe only consumed spans. WANT_READ, WANT_WRITE, rekey, window/channel retry, zero and error paths preserve pending TX; positive partial sends wipe only accepted admin spans. Binary serial RX/TX remains unchanged, including embedded NUL/0xff and partial broker acceptance.
- Whole-slot retirement wipe including padding and buffers, exact retained generation, `socket_fd == -1` sentinel, broker-disconnect failure retaining a closing slot, and repeated cleanup without double free/close. The old live fd is closed, not retained.
- Source checks for context registration of authentication, type advertisement, result and rejecting keyboard callbacks, plus per-session auth/result/keyboard contexts.
## Limits and deferred validation
The wolfSSH types in `fixture.h` are narrow host shapes, not ABI validation. Library return codes model distinct outcomes; this does not compile wolfSSH or perform cryptographic signature work. Denied authorization is verified to return a non-success result and leave no pending completion; proving the parser actually skips signature processing belongs to the separately delegated real wolfSSH parser-order regression. Do not treat these tests as its replacement.
Context construction is stubbed; callback registration is checked in production source rather than executed. Console/broker/database behavior and real socket scheduling are outside this suite. A fixture principal uses synthetic bytes to inspect lifecycle clearing; there are no real credentials.
No firmware build, device execution, upload or erase is performed. All device validation remains deferred to whole Phase 9, including real clients, timing, concurrency, recovery and physical UART/broker behavior.
+142
View File
@@ -0,0 +1,142 @@
/* Host-only boundary doubles. No authentication/policy/transport logic here. */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include "ssh_auth_policy.h"
typedef unsigned char byte;
typedef uint32_t word32;
typedef int esp_err_t;
typedef int WOLFSSH;
typedef int WOLFSSH_CTX;
typedef unsigned session_broker_client_id_t;
typedef struct { uint32_t id; unsigned char secret[28]; } user_principal_t;
typedef struct { unsigned slot; } admin_ssh_console_token_t;
typedef struct {
byte isCert, hasSignature;
const byte *publicKeyType, *publicKey;
word32 publicKeyTypeSz, publicKeySz;
} WS_UserAuthData_PublicKey;
typedef struct {
byte type;
const byte *username;
word32 usernameSz;
union {
struct { byte hasNewPassword; const byte *password; word32 passwordSz; } password;
WS_UserAuthData_PublicKey publicKey;
} sf;
} WS_UserAuthData;
typedef struct { unsigned promptCount; void *prompts; byte storage[32]; } WS_UserAuthData_Keyboard;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_TIMEOUT, ESP_ERR_NOT_FOUND };
enum { WOLFSSH_USERAUTH_PASSWORD=1, WOLFSSH_USERAUTH_PUBLICKEY=2,
WOLFSSH_USERAUTH_SUCCESS=0, WOLFSSH_USERAUTH_REJECTED=3,
WOLFSSH_USERAUTH_INVALID_AUTHTYPE, WOLFSSH_USERAUTH_INVALID_PASSWORD,
WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_INVALID_PUBLICKEY };
enum { WS_SUCCESS=0, WS_ERROR=-1, WS_WANT_READ=-2, WS_WANT_WRITE=-3,
WS_REKEYING=-4, WS_WINDOW_FULL=-5, WS_CHAN_RXD=-6 };
#define SESSION_BROKER_NO_CLIENT 0U
/* PRODUCTION TYPES */
static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_counters_t s_counters;
static ssh_auth_policy_t s_auth_policy;
static WOLFSSH_CTX *s_context;
static int s_listen_fd=-1, s_lock;
static bool s_initialized;
static unsigned lock_depth, shutdown_calls, close_calls, password_calls, key_calls;
static unsigned current_calls, allocations, frees, context_frees;
static int64_t now_us;
static bool db_accept, db_current, feed_ok, accepts_input=true, allocation_fail;
static esp_err_t db_error, current_error, broker_error;
static size_t feed_consumed, broker_accepted;
static int send_result, read_result, ssh_error, accepts_remaining;
static const byte payload[] = {0x00, 0xff, 'p', 'a', 's', 's', '\r', '\n'};
static const void *wipe_address;
static size_t wipe_size;
#define taskENTER_CRITICAL(p) do { (void)(p); assert(lock_depth++ == 0); } while (0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--lock_depth == 0); } while (0)
#define pdMS_TO_TICKS(n) (n)
static void secure_wipe(void *p, size_t n) {
wipe_address=p; wipe_size=n;
volatile byte *b=p; while(n--) *b++=0;
}
static int64_t esp_timer_get_time(void) { return now_us; }
static int fake_shutdown(int fd, int how) { assert(fd>=0 && how==SHUT_RDWR); ++shutdown_calls; return 0; }
static int fake_close(int fd) { assert(fd>=0); ++close_calls; return 0; }
#define shutdown fake_shutdown
#define close fake_close
static esp_err_t user_database_authenticate_password(const byte *u, word32 un,
const byte *p, word32 pn, user_principal_t *out, bool *ok) {
assert(u && un==1 && p && pn==sizeof(payload)); ++password_calls;
memset(out, 0x5a, sizeof(*out)); *ok=db_accept; return db_error;
}
static esp_err_t user_database_authorize_ssh_public_key(const byte *u, word32 un,
const byte *t, word32 tn, const byte *k, word32 kn, user_principal_t *out, bool *ok) {
assert(u && un==1 && t && tn==1 && k && kn==sizeof(payload)); ++key_calls;
memset(out, 0x5a, sizeof(*out)); *ok=db_accept; return db_error;
}
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *ok) {
assert(p->id==0x5a5a5a5a); ++current_calls; *ok=db_current; return current_error;
}
static admin_ssh_console_token_t admin_console_token(ssh_slot_t *s, size_t i) {
assert(s==&s_slots[i]); return (admin_ssh_console_token_t){(unsigned)i};
}
static void admin_ssh_console_close(const admin_ssh_console_token_t *t) { assert(t->slot<2); }
static int wolfSSH_shutdown(WOLFSSH *s) { assert(s); return 0; }
static void wolfSSH_free(WOLFSSH *s) { assert(s); ++frees; }
static esp_err_t session_broker_disconnect(session_broker_client_id_t id) { assert(id); return broker_error; }
static void publish_slot(ssh_slot_t *s, size_t i) { assert(s==&s_slots[i]); }
static void vTaskDelay(unsigned n) { (void)n; }
static esp_err_t create_context(void) { static WOLFSSH_CTX ctx; s_context=&ctx; return ESP_OK; }
static esp_err_t create_listener(void) { s_listen_fd=10; return ESP_OK; }
static void wolfSSH_CTX_free(WOLFSSH_CTX *c) { assert(c); ++context_frees; }
static int fake_accept(int fd, struct sockaddr *p, socklen_t *n) {
assert(fd==10); memset(p,0,*n);
if(accepts_remaining>0) { --accepts_remaining; return 20+accepts_remaining; }
errno=EAGAIN; return -1;
}
#define accept fake_accept
static int fake_setsockopt(int f,int l,int o,const void *v,socklen_t n) {
(void)f;(void)l;(void)o;(void)v;(void)n; return 0;
}
#define setsockopt fake_setsockopt
static esp_err_t set_nonblocking(int fd) { assert(fd>=0); return ESP_OK; }
static void format_peer(const struct sockaddr_storage *p,char *out,size_t n) {
(void)p; assert(n>4); strcpy(out,"host");
}
static WOLFSSH *wolfSSH_new(WOLFSSH_CTX *c) { static WOLFSSH ssh; assert(c); ++allocations; return allocation_fail ? NULL : &ssh; }
static int wolfSSH_set_fd(WOLFSSH *s,int fd) { assert(s && fd>=0); return WS_SUCCESS; }
#define CONTEXT_SETTER(name) static void name(WOLFSSH *s,void *p) { assert(s && p); }
CONTEXT_SETTER(wolfSSH_SetIOReadCtx)
CONTEXT_SETTER(wolfSSH_SetUserAuthCtx)
CONTEXT_SETTER(wolfSSH_SetUserAuthResultCtx)
CONTEXT_SETTER(wolfSSH_SetKeyboardAuthCtx)
CONTEXT_SETTER(wolfSSH_SetChannelReqCtx)
static int wolfSSH_get_error(WOLFSSH *s) { assert(s); return ssh_error; }
static int wolfSSH_stream_send(WOLFSSH *s,const byte *p,word32 n) {
assert(s && n && (send_result<=0 || (unsigned)send_result<=n));
assert(memcmp(p,payload+sizeof(payload)-n,n)==0); return send_result;
}
static int wolfSSH_stream_read(WOLFSSH *s,byte *p,word32 n) {
assert(s && n>=sizeof(payload));
if(read_result>0) { assert(read_result==(int)sizeof(payload)); memcpy(p,payload,sizeof(payload)); }
return read_result;
}
static bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *t,
const byte *p,size_t n,size_t *consumed) {
assert(t->slot<2 && feed_consumed<=n);
assert(memcmp(p,payload+sizeof(payload)-n,n)==0);
*consumed=feed_consumed; return feed_ok;
}
static bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *t) { assert(t->slot<2); return accepts_input; }
static esp_err_t session_broker_write(session_broker_client_id_t id,const byte *p,
size_t n,size_t *accepted) {
(void)id; assert(broker_accepted<=n);
assert(memcmp(p,payload+sizeof(payload)-n,n)==0);
*accepted=broker_accepted; return broker_error;
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Compile verbatim transport functions with narrow host boundary doubles."""
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
SOURCE = (ROOT / 'src/ssh_transport.c').read_text()
HEADER = (ROOT / 'src/ssh_transport.h').read_text()
def function(name):
matches = list(re.finditer(r'^(?:static )?[^\n;{}]+\b' + re.escape(name)
+ r'\([^;]*?\n\{.*?^\}', SOURCE, re.M | re.S))
assert len(matches) == 1, f'expected exactly one production definition: {name}'
return matches[0].group() + '\n'
def declaration(text, name):
match = re.search(r'^typedef (?:struct|enum) \{[^}]*\} ' + name + ';', text, re.M)
assert match, name
return match.group() + '\n'
for setter, callback in (
('wolfSSH_SetUserAuth', 'authenticate_user'),
('wolfSSH_SetUserAuthTypes', 'allowed_auth_types'),
('wolfSSH_SetUserAuthResult', 'authentication_result'),
('wolfSSH_SetKeyboardAuthPrompts', 'reject_keyboard_auth'),
):
assert re.search(r'\b' + setter + r'\(context,\s*' + callback + r'\)',
function('create_context')), setter
for setter in ('wolfSSH_SetUserAuthCtx', 'wolfSSH_SetUserAuthResultCtx',
'wolfSSH_SetKeyboardAuthCtx'):
assert re.search(r'\b' + setter + r'\(slot->ssh,\s*slot\)',
function('accept_connections')), setter
assert re.search(r'^static ssh_auth_policy_t s_auth_policy;', SOURCE, re.M)
names = '''add_counter make_session_id allowed_auth_types clear_pending_principal
close_authentication admit_authentication reject_keyboard_auth
complete_authentication_attempt authenticate_password authenticate_public_key
authenticate_user authentication_result wolfssh_would_block close_socket
cleanup_slot request_slot_close start_runtime stop_runtime find_free_slot
accept_connections flush_client_input receive_client_input flush_client_output
flush_admin_input receive_admin_input ssh_transport_clear_counters'''.split()
constants = '\n'.join(line for text in (HEADER, SOURCE) for line in text.splitlines()
if line.startswith('#define SSH_TRANSPORT_')) + '\n'
types = ''.join(declaration(HEADER, n) for n in (
'ssh_transport_session_state_t', 'ssh_transport_session_route_t',
'ssh_transport_counters_t')) + declaration(SOURCE, 'ssh_slot_t')
fixture = (HERE / 'fixture.h').read_text()
unit = fixture.replace('/* PRODUCTION TYPES */', constants + types)
unit += '\n'.join(function(n) for n in names)
unit += (HERE / 'test.c').read_text()
env = dict(os.environ, CCACHE_DISABLE='1')
with tempfile.TemporaryDirectory(prefix='ssh-auth-transport-') as directory:
out = Path(directory)
(out / 'test.c').write_text(unit)
subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + [
'-std=c11', '-Wall', '-Wextra', '-Werror', '-g',
'-I', str(ROOT / 'src'), str(out / 'test.c'),
str(ROOT / 'src/ssh_auth_policy.c'), '-o', str(out / 'test')],
env=env, check=True, timeout=30)
subprocess.run([str(out / 'test')], env=env, check=True, timeout=10)
print('PASS production callback registration and per-slot context source checks')
+249
View File
@@ -0,0 +1,249 @@
/* Included after verbatim production definitions by run.py. */
static void zero_bytes(const void *p,size_t n) {
const byte *b=p; for(size_t i=0;i<n;++i) assert(b[i]==0);
}
static ssh_slot_t *fresh(unsigned i) {
assert(i<2); memset(&s_slots[i],0,sizeof(s_slots[i]));
s_slots[i].state=SSH_TRANSPORT_SESSION_HANDSHAKE;
s_slots[i].socket_fd=30+(int)i; s_slots[i].ssh=(WOLFSSH *)&s_lock;
return &s_slots[i];
}
static void boot(void) {
memset(s_slots,0,sizeof(s_slots)); memset(&s_counters,0,sizeof(s_counters));
memset(&s_auth_policy,0,sizeof(s_auth_policy));
s_context=NULL; s_listen_fd=-1; s_initialized=true; now_us=0;
password_calls=key_calls=current_calls=shutdown_calls=close_calls=0;
allocations=frees=context_frees=0; db_error=current_error=broker_error=ESP_OK;
db_accept=db_current=true; allocation_fail=false;
}
static WS_UserAuthData password(void) {
WS_UserAuthData a={.type=WOLFSSH_USERAUTH_PASSWORD,.username=(const byte *)"u",.usernameSz=1};
a.sf.password.password=payload; a.sf.password.passwordSz=sizeof(payload); return a;
}
static WS_UserAuthData key(bool signed_key) {
WS_UserAuthData a={.type=WOLFSSH_USERAUTH_PUBLICKEY,.username=(const byte *)"u",.usernameSz=1};
a.sf.publicKey=(WS_UserAuthData_PublicKey){.hasSignature=signed_key,
.publicKeyType=(const byte *)"k",.publicKeyTypeSz=1,
.publicKey=payload,.publicKeySz=sizeof(payload)}; return a;
}
static int auth(ssh_slot_t *s,WS_UserAuthData *a) { return authenticate_user(a->type,a,s); }
static void passwords(void) {
for(unsigned mode=0;mode<4;++mode) {
boot(); ssh_slot_t *s=fresh(0); WS_UserAuthData a=password();
db_accept=mode==0; db_error=mode==2 ? ESP_FAIL : ESP_OK;
a.sf.password.hasNewPassword=mode==3;
const int expected[]={WOLFSSH_USERAUTH_SUCCESS,WOLFSSH_USERAUTH_INVALID_PASSWORD,
WOLFSSH_USERAUTH_FAILURE,WOLFSSH_USERAUTH_INVALID_AUTHTYPE};
assert(auth(s,&a)==expected[mode]);
assert(password_calls==(mode==3 ? 0U : 1U));
assert(s->authenticated==(mode==0) && s->principal_valid==(mode==0));
assert(s->authentication_attempts==1 && s_counters.authentication_attempts==1);
assert(s_counters.authentication_failures==(mode!=0));
assert(s_counters.authentication_backend_errors==(mode==2));
assert(s_counters.authentication_admissions==1);
zero_bytes(&s->pending_principal,sizeof(s->pending_principal));
}
boot(); db_accept=false; WS_UserAuthData a=password();
for(unsigned i=0;i<2;++i) {
ssh_slot_t *s=fresh(i);
for(unsigned j=0;j<3;++j) {
assert(auth(s,&a)==(j==2 ? WOLFSSH_USERAUTH_REJECTED : WOLFSSH_USERAUTH_INVALID_PASSWORD));
assert(s->close_requested==(j==2));
}
}
assert(s_counters.authentication_limit_disconnects==2 && shutdown_calls==2);
ssh_slot_t *s=fresh(0);
assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED && password_calls==6);
assert(s->authentication_attempts==0 && s_counters.authentication_throttle_rejections==1);
WS_UserAuthData signed_key=key(true); s=fresh(1);
assert(auth(s,&signed_key)==WOLFSSH_USERAUTH_REJECTED && key_calls==0);
assert(!s->awaiting_auth_result && !s->pending_principal_valid);
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,&signed_key,s)==WS_ERROR);
assert(s_counters.authentication_attempts==6 && current_calls==0 && !s->authenticated);
now_us=9999999; s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
now_us=10000000; s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_INVALID_PASSWORD);
s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED && password_calls==7);
puts("PASS password outcomes, shared verification budget/refill, reconnect and three-failure closure");
}
static void signed_keys(void) {
for(unsigned mode=0;mode<6;++mode) {
boot(); ssh_slot_t *s=fresh(0); WS_UserAuthData a=key(true);
db_accept=mode!=0; db_error=mode==1 ? ESP_FAIL : ESP_OK;
int result=auth(s,&a); assert(key_calls==1);
if(mode<2) {
assert(result==(mode==0 ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY : WOLFSSH_USERAUTH_FAILURE));
assert(!s->awaiting_auth_result && !s->pending_principal_valid);
assert(s_counters.authentication_attempts==1);
/* Denied authorization never grants permission for signature work. */
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,&a,s)==WS_ERROR);
} else {
assert(result==WOLFSSH_USERAUTH_SUCCESS && s->awaiting_auth_result);
assert(!s->authenticated && s_counters.authentication_attempts==0);
db_current=mode!=3; current_error=mode==4 ? ESP_FAIL : ESP_OK;
assert(authentication_result(mode==2 ? WOLFSSH_USERAUTH_FAILURE : WOLFSSH_USERAUTH_SUCCESS,
&a,s)==((mode==3 || mode==4) ? WS_ERROR : WS_SUCCESS));
assert(s->authenticated==(mode==5));
assert(current_calls==(mode==2 ? 0U : 1U));
assert(s_counters.authentication_attempts==1);
assert(s_counters.authentication_admissions==1);
assert(s_counters.authentication_failures==(mode!=5));
assert(s_counters.authentication_backend_errors==(mode==4));
bool before=s->authenticated;
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,&a,s)==WS_ERROR);
assert(s->authenticated==before);
}
assert(s_counters.authentication_attempts==1 && s->close_requested);
assert(!s->awaiting_auth_result && !s->pending_principal_valid);
zero_bytes(&s->pending_principal,sizeof(s->pending_principal));
}
boot(); WS_UserAuthData a=key(true); ssh_slot_t *s=fresh(0);
for(unsigned i=0;i<3;++i) {
assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
assert(authentication_result(WOLFSSH_USERAUTH_FAILURE,&a,s)==WS_SUCCESS);
assert(s->close_requested==(i==2));
}
assert(s_counters.authentication_limit_disconnects==1);
puts("PASS signed authorization, result/currentness failures, success, exactly-once completion and limits");
}
static void fences_and_probes(void) {
boot(); WS_UserAuthData a=key(false);
for(unsigned i=0;i<12;++i) {
ssh_slot_t *s=fresh(i%2); db_accept=i%2==0;
assert(auth(s,&a)==(db_accept ? WOLFSSH_USERAUTH_SUCCESS : WOLFSSH_USERAUTH_INVALID_PUBLICKEY));
assert(!s->awaiting_auth_result && !s->authenticated && !s->authentication_attempts);
}
ssh_slot_t *s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
assert(key_calls==12 && s_counters.authentication_attempts==0);
assert(s_counters.authentication_probe_admissions==12 && s_counters.authentication_probe_rejections==1);
now_us=4999999; s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
now_us=5000000; db_accept=true; s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED && key_calls==13);
a=key(true); s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
assert(s_counters.authentication_admissions==1); /* Probe pool independent. */
for(unsigned mode=0;mode<7;++mode) {
boot(); s=fresh(0); a=key(true);
if(mode<3) assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
if(mode==0) s->close_requested=true;
if(mode==1) s->state=SSH_TRANSPORT_SESSION_CLOSING;
if(mode==2) { assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED); }
if(mode==4) a.sf.publicKey.hasSignature=0;
if(mode==5) a=password();
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,mode==6 ? NULL : &a,s)==WS_ERROR);
assert(!s->authenticated && !s->principal_valid && !s->awaiting_auth_result);
assert(s_counters.authentication_attempts==0 && current_calls==0);
assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
}
boot(); s=fresh(0); a=password();
assert(authenticate_user(99,&a,s)==WOLFSSH_USERAUTH_REJECTED);
assert(s_counters.authentication_method_rejections==1 && !password_calls);
assert(allowed_auth_types(NULL,NULL)==(WOLFSSH_USERAUTH_PASSWORD|WOLFSSH_USERAUTH_PUBLICKEY));
boot(); s=fresh(0); a=key(true); assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
WS_UserAuthData_Keyboard keyboard; memset(&keyboard,0xa5,sizeof(keyboard));
assert(reject_keyboard_auth(&keyboard,s)==WS_ERROR); zero_bytes(&keyboard,sizeof(keyboard));
assert(s->close_requested && !s->pending_principal_valid && !s->awaiting_auth_result);
assert(s_counters.authentication_method_rejections==1 && !s_counters.authentication_attempts);
assert(reject_keyboard_auth(NULL,s)==WS_ERROR && s_counters.authentication_method_rejections==1);
assert(reject_keyboard_auth(NULL,NULL)==WS_ERROR);
puts("PASS bounded unsigned probes, independent pools, result/close fences and keyboard decline without prompts");
}
static void admissions_lifecycle(void) {
boot(); assert(start_runtime()==ESP_OK);
accepts_remaining=3; accept_connections();
assert(allocations==2 && s_counters.capacity_rejections==1);
assert(s_counters.handshake_admissions==2);
assert(s_slots[0].generation==1 && s_slots[1].generation==1);
assert(cleanup_slot(&s_slots[0]) && cleanup_slot(&s_slots[1]));
allocation_fail=true; accepts_remaining=4; accept_connections();
assert(allocations==6 && s_counters.handshake_admissions==6 && s_counters.handshake_failures==4);
accepts_remaining=1; accept_connections();
assert(allocations==6 && s_counters.handshake_throttle_rejections==1);
WS_UserAuthData p=password(), k=key(false); db_accept=false;
for(unsigned i=0;i<6;++i) assert(auth(fresh(i%2),&p)==WOLFSSH_USERAUTH_INVALID_PASSWORD);
for(unsigned i=0;i<12;++i) assert(auth(fresh(i%2),&k)==WOLFSSH_USERAUTH_INVALID_PUBLICKEY);
ssh_auth_policy_t saved=s_auth_policy;
assert(stop_runtime()==ESP_OK && start_runtime()==ESP_OK);
assert(memcmp(&saved,&s_auth_policy,sizeof(saved))==0);
s_initialized=false; assert(ssh_transport_clear_counters()==ESP_ERR_INVALID_STATE);
s_initialized=true; assert(ssh_transport_clear_counters()==ESP_OK);
zero_bytes(&s_counters,sizeof(s_counters));
assert(memcmp(&saved,&s_auth_policy,sizeof(saved))==0);
accepts_remaining=1; accept_connections(); assert(allocations==6);
assert(auth(fresh(0),&p)==WOLFSSH_USERAUTH_REJECTED);
assert(auth(fresh(1),&k)==WOLFSSH_USERAUTH_REJECTED);
assert(s_counters.handshake_throttle_rejections==1 && s_counters.authentication_throttle_rejections==1 && s_counters.authentication_probe_rejections==1);
assert(cleanup_slot(&s_slots[0]) && cleanup_slot(&s_slots[1]));
now_us=9999999; accepts_remaining=1; accept_connections(); assert(allocations==6);
now_us=10000000; allocation_fail=false; accepts_remaining=2; accept_connections();
assert(allocations==7 && s_counters.handshake_admissions==1);
uint64_t count=UINT64_MAX-1; add_counter(&count,1); assert(count==UINT64_MAX);
add_counter(&count,1); assert(count==UINT64_MAX); count=1;
add_counter(&count,UINT64_MAX); assert(count==UINT64_MAX);
s_counters.authentication_attempts=UINT64_MAX;
s_counters.authentication_failures=UINT64_MAX;
ssh_slot_t *s=fresh(1); s->authentication_attempts=UINT8_MAX;
assert(!complete_authentication_attempt(s,true));
assert(s->authentication_attempts==UINT8_MAX && s_counters.authentication_attempts==UINT64_MAX && s_counters.authentication_failures==UINT64_MAX);
puts("PASS actual accept loop capacity/budget, failed allocations consume admission, restart/clear retain all pools, saturation");
}
static void buffers(void) {
boot(); ssh_slot_t *s=fresh(0); s->route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
memcpy(s->rx_buffer,payload,sizeof(payload)); s->rx_length=sizeof(payload);
feed_ok=true; feed_consumed=2; assert(flush_admin_input(s,0));
zero_bytes(s->rx_buffer,2); assert(s->rx_offset==2 && !memcmp(s->rx_buffer+2,payload+2,6));
feed_ok=false; feed_consumed=2; assert(flush_admin_input(s,0));
zero_bytes(s->rx_buffer,4); assert(s->rx_offset==4 && !memcmp(s->rx_buffer+4,payload+4,4));
assert(s_counters.rx_accepted_bytes==4 && !s_counters.admin_console_input_rejections);
feed_consumed=0; assert(flush_admin_input(s,0)); assert(s->rx_offset==4 && s_counters.admin_console_input_rejections==1);
feed_ok=true; feed_consumed=4; assert(flush_admin_input(s,0));
zero_bytes(s->rx_buffer,sizeof(payload)); assert(!s->rx_length && !s->rx_offset);
const int retry[]={0,WS_WANT_READ,WS_WANT_WRITE,WS_REKEYING,WS_WINDOW_FULL,WS_CHAN_RXD};
for(unsigned route=SSH_TRANSPORT_ROUTE_BROKER;route<=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;++route) {
s->route=route; memcpy(s->tx_buffer,payload,sizeof(payload)); s->tx_length=sizeof(payload); s->tx_offset=0;
for(size_t i=0;i<sizeof(retry)/sizeof(retry[0]);++i) {
send_result=retry[i]; ssh_error=0; assert(flush_client_output(s));
assert(!s->tx_offset && s->tx_length==sizeof(payload) && !memcmp(s->tx_buffer,payload,sizeof(payload)));
}
send_result=3; assert(flush_client_output(s)); assert(s->tx_offset==3);
if(route==SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) zero_bytes(s->tx_buffer,3);
else assert(!memcmp(s->tx_buffer,payload,3));
assert(!memcmp(s->tx_buffer+3,payload+3,5));
send_result=WS_ERROR; ssh_error=WS_WANT_WRITE; assert(flush_client_output(s)); assert(s->tx_offset==3);
ssh_error=WS_ERROR; assert(!flush_client_output(s)); assert(!memcmp(s->tx_buffer+3,payload+3,5));
send_result=5; assert(flush_client_output(s)); assert(!s->tx_offset && !s->tx_length);
if(route==SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) zero_bytes(s->tx_buffer,sizeof(payload));
else assert(!memcmp(s->tx_buffer,payload,sizeof(payload)));
}
for(size_t i=0;i<sizeof(retry)/sizeof(retry[0]);++i) {
read_result=retry[i]; ssh_error=0; assert(receive_admin_input(s,0)); assert(!s->rx_length);
assert(receive_client_input(s));
}
read_result=WS_ERROR; ssh_error=WS_ERROR; assert(!receive_admin_input(s,0)); assert(!receive_client_input(s));
read_result=sizeof(payload); feed_consumed=3; feed_ok=false; assert(receive_admin_input(s,0));
zero_bytes(s->rx_buffer,3); assert(s->rx_offset==3 && !memcmp(s->rx_buffer+3,payload+3,5));
feed_consumed=5; assert(receive_admin_input(s,0)); zero_bytes(s->rx_buffer,sizeof(payload));
s->route=SSH_TRANSPORT_ROUTE_BROKER; s->writer=true; broker_accepted=3;
assert(receive_client_input(s)); assert(s->rx_offset==3 && !memcmp(s->rx_buffer,payload,sizeof(payload)));
broker_error=ESP_ERR_TIMEOUT; broker_accepted=0; assert(flush_client_input(s)); assert(s->rx_offset==3);
broker_error=ESP_OK; broker_accepted=5; assert(flush_client_input(s)); assert(!s->rx_length && !memcmp(s->rx_buffer,payload,sizeof(payload)));
puts("PASS consumed admin RX including false+consumed, retry/partial TX wiping, binary serial buffers unchanged");
}
static void retirement(void) {
boot(); ssh_slot_t *s=&s_slots[0]; memset(s,0xa5,sizeof(*s));
s->generation=0x12345678; s->socket_fd=42; s->ssh=(WOLFSSH *)&s_lock;
s->route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE; s->broker_client_id=7;
broker_error=ESP_FAIL; assert(!cleanup_slot(s));
assert(s->state==SSH_TRANSPORT_SESSION_CLOSING && s->generation==0x12345678);
assert(s->socket_fd==-1 && s->ssh==NULL && frees==1);
broker_error=ESP_OK; assert(cleanup_slot(s));
assert(wipe_address==s && wipe_size==sizeof(*s));
ssh_slot_t expected; memset(&expected,0,sizeof(expected));
expected.state=SSH_TRANSPORT_SESSION_FREE; expected.generation=0x12345678; expected.socket_fd=-1;
assert(!memcmp(s,&expected,sizeof(*s)) && frees==1 && close_calls==1);
assert(cleanup_slot(s)); assert(!memcmp(s,&expected,sizeof(*s)));
puts("PASS retirement whole-slot wipe, exact generation and fd=-1 sentinel, deferred broker cleanup");
}
int main(void) {
passwords(); signed_keys(); fences_and_probes(); admissions_lifecycle(); buffers(); retirement();
puts("PASS SSH transport extracted-production host suite");
return 0;
}
+96
View File
@@ -0,0 +1,96 @@
# Pinned wolfSSH authentication control-flow contract
Run from the repository root:
```sh
CCACHE_DISABLE=1 python3 tests/wolfssh_auth_contract/run.py
```
Requires Python 3, a host C compiler (`CC`, default `cc`), installed managed
wolfSSH, and an existing firmware compilation database/toolchain. No packages
are downloaded and no firmware build or device commands run. Generated C and
the executable live in a temporary directory and are removed on exit. Compile,
preprocess and execution subprocesses have 30/30/10-second limits.
The runner prefers the sole `.pio/build/*/compile_commands.json`, otherwise the
root database. Select another existing database with `--compile-commands PATH`.
It preprocesses the actual wolfSSH `internal.c` compile command (`-E -dM`) and
checks this reviewed profile:
- `LIBWOLFSSH_VERSION_HEX == 0x01004020` (1.4.20).
- RSA disabled; ECDSA and Ed25519 not disabled.
- Certificates, `none` authentication and `NO_FAILURE_ON_REJECTED` not defined.
For hosts without the ESP compiler/database, explicitly use `--host-only`.
This prints a **SKIP** for production feature verification; it still checks the
source/version/pin and executes the host contract with the reviewed feature
profile. A stale compilation database is not proof of the next firmware build's
configuration.
## What executes
`run.py` checks the exact application wolfSSH pin, installed version header and
reviewed SHA-256 of `managed_components/wolfssl__wolfssh/src/internal.c`:
```text
81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9
```
Any same-version source change fails before compilation. **Re-audit before
updating this hash**; do not automatically bless a dependency update.
The runner extracts actual function definitions by balancing braces after
masking comments/string literals. It does not rewrite their bodies:
- `GetBoolean`, `GetUint32`, `GetSize`, `GetStringRef`
- `DoUserAuthRequestPassword`, `DoUserAuthRequestPublicKey`, `DoUserAuthRequest`
- `SendUserAuthKeyboardRequest`, `GetAllowedAuth`
- `SendChannelData`
Callback data structures, auth result constants and method masks are extracted
from the installed public header. `contract.c` supplies small session/context
models, name/algorithm lookup, crypto and packet-output doubles. Binary request
fixtures execute the extracted parsers; ordered event traces assert callback,
hashing/signature and response order, rather than inspecting source substrings.
The 35 cases cover:
- Ed25519 and ECDSA: signed authorization rejection (`INVALID_PUBLICKEY`,
`FAILURE`, `REJECTED`, `INVALID_USER`, `INVALID_AUTHTYPE`) never hashes,
verifies or calls the result callback.
- Both unsigned probe outcomes: no signature work/result callback; an accepted
probe sends PK_OK but does not complete authentication.
- Bad signatures, good signatures, success-result veto, ignored failure-result
callback return, and auth `WOULD_BLOCK`.
- Password success/failure, rejected password change, and the installed parser's
callback on a truncated new-password-length field. No password result callback.
- Disabled `none`, unknown methods/key algorithms and truncated signed framing.
- Direct keyboard-interactive dispatch invokes a **registered non-NULL rejecting
prompt callback**, returns error and purges without preparing/building/sending
a prompt. The actual library still writes the message-ID byte into its existing
output buffer on this path; the test models that buffer and checks this detail.
- The actual advertised-method builder excludes keyboard despite the registered
keyboard callback, because the allowed-types callback overrides the defaults.
- Actual `SendChannelData` copies the bounded consumed prefix before returning a
positive count, both on send success and `WS_WANT_WRITE`. Wiping that caller
prefix leaves the library copy intact. A blocked flush of earlier data returns
a negative code without consuming new data.
## Limits / ownership
This is a library parser/control-flow regression, **not application callback
integration coverage**. Its rejecting keyboard callback models the parent's
registration and return policy; it does not prove production registration,
admission counters, awaiting-result state, principal promotion, or admin wiping.
Those belong to the separate application unit suite.
Crypto helpers are instrumented doubles; algorithm name lookup is limited to
fixture names. Packet construction/network send, hashing and session internals
are modeled. This does not validate cryptographic correctness, encrypted packet
decoding, real sockets, asynchronous re-entry, complete malformed-input safety,
allocation failure, memory erasure throughout wolfSSH, or device behavior. The
send test proves only the extracted copy/consumed control flow with successful
packet preparation/bundling and the specified send outcomes—not the entire
admin transmit loop or TLS/SSH buffer lifecycle.
All Phase9 hardware validation remains deferred to the combined phase.
+246
View File
@@ -0,0 +1,246 @@
/* Control-flow doubles only: no cryptographic implementation or real credentials. */
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef uint8_t byte;
typedef uint32_t word32;
#include "auth_types.h"
/* Reviewed production feature profile. Never enable certificates/none silently. */
#define WOLFSSH_NO_RSA
#if defined(WOLFSSH_CERTS) || defined(WOLFSSH_ALLOW_USERAUTH_NONE) || \
defined(WOLFSSH_NO_ECDSA) || defined(WOLFSSH_NO_ED25519) || \
defined(NO_FAILURE_ON_REJECTED)
#error "Unexpected wolfSSH auth-contract feature profile"
#endif
#define WLOG(...) ((void)0)
#define WMEMSET memset
#define WMEMCPY memcpy
#define WSTRNCAT strncat
#define XSTRLEN strlen
#define BOOLEAN_SZ 1
#define UINT32_SZ 4
#define LENGTH_SZ 4
#define MSG_ID_SZ 1
#define MAX_AUTH_STRING 80
#define WOLFSSH_MAX_PROMPTS 8
#define WC_MAX_DIGEST_SIZE 64
#define min(a,b) ((a) < (b) ? (a) : (b))
enum { WS_SUCCESS = 0, WS_ERROR = -1, WS_BUFFER_E = -2,
WS_BAD_ARGUMENT = -3, WS_USER_AUTH_E = -4, WS_AUTH_PENDING = -5,
WS_INVALID_ALGO_ID = -6, WS_CRYPTO_FAILED = -7, WS_BAD_USAGE = -8,
WS_WANT_WRITE = -9, WS_REKEYING = -10, WS_INVALID_CHANID = -11,
WS_WINDOW_FULL = -12 };
enum { ID_NONE, ID_UNKNOWN, ID_USERAUTH_PASSWORD, ID_USERAUTH_KEYBOARD,
ID_USERAUTH_PUBLICKEY, ID_ED25519, ID_ECDSA_SHA2_NISTP256,
ID_ECDSA_SHA2_NISTP384, ID_ECDSA_SHA2_NISTP521 };
enum { WOLFSSH_ENDPOINT_SERVER, CLIENT_USERAUTH_DONE = 20,
MSGID_USERAUTH_REQUEST, MSGID_USERAUTH_INFO_REQUEST, MSGID_CHANNEL_DATA,
WS_CHANNEL_ID_SELF };
enum wc_HashType { WC_HASH_TYPE_SHA };
typedef int wc_HashAlg;
typedef struct WOLFSSH WOLFSSH;
typedef struct {
int (*userAuthCb)(byte, WS_UserAuthData *, void *);
int (*userAuthResultCb)(byte, WS_UserAuthData *, void *);
int (*keyboardAuthCb)(WS_UserAuthData_Keyboard *, void *);
int (*userAuthTypesCb)(WOLFSSH *, void *);
} WOLFSSH_CTX;
struct WOLFSSH {
WOLFSSH_CTX *ctx;
void *userAuthCtx, *userAuthResultCtx, *keyboardAuthCtx;
int clientState, isKeying, error;
byte sessionId[32];
word32 sessionIdSz;
struct { byte *buffer; word32 length, plainSz; } outputBuffer;
struct { word32 promptCount; } kbAuth;
};
typedef struct {
word32 peerWindowSz, peerMaxPacketSz, maxPacketSz, peerChannel;
} WOLFSSH_CHANNEL;
static WOLFSSH_CHANNEL channel;
static const byte cannedKeyAlgoClient[] = { ID_ED25519, ID_ECDSA_SHA2_NISTP256 };
static const word32 cannedKeyAlgoClientSz = sizeof(cannedKeyAlgoClient);
static char events[64];
static unsigned event_count, groups;
static int auth_return, crypto_return, result_return, send_return;
static int expect_new_password;
static void event(char c) { assert(event_count + 1 < sizeof(events)); events[event_count++] = c; }
static void ato32(const byte *b, word32 *v) {
*v = (word32)b[0] << 24 | (word32)b[1] << 16 | (word32)b[2] << 8 | b[3];
}
static void c32toa(word32 v, byte *b) {
b[0] = v >> 24; b[1] = v >> 16; b[2] = v >> 8; b[3] = v;
}
static byte NameToId(const char *s, word32 n) {
static const struct { const char *s; byte id; } names[] = {
{"none", ID_NONE}, {"password", ID_USERAUTH_PASSWORD},
{"keyboard-interactive", ID_USERAUTH_KEYBOARD}, {"publickey", ID_USERAUTH_PUBLICKEY},
{"ssh-ed25519", ID_ED25519}, {"ecdsa-sha2-nistp256", ID_ECDSA_SHA2_NISTP256}
};
for (unsigned i = 0; i < sizeof(names)/sizeof(names[0]); ++i)
if (strlen(names[i].s) == n && !memcmp(s, names[i].s, n)) return names[i].id;
return ID_UNKNOWN;
}
static byte MatchIdLists(int side, const byte *id, word32 count, const byte *list, word32 n) {
for (word32 i = 0; i < n; ++i) if (*id == list[i]) return *id;
return ID_UNKNOWN;
}
static int wolfSSH_SetUsernameRaw(WOLFSSH *s, const byte *u, word32 n) { return WS_SUCCESS; }
static int SendUserAuthFailure(WOLFSSH *s, byte partial) { event('F'); return WS_SUCCESS; }
static int SendUserAuthPkOk(WOLFSSH *s, const byte *a, word32 an, const byte *k, word32 kn) {
event('P'); return WS_SUCCESS;
}
static int DoUserAuthRequestEd25519(WOLFSSH *s, WS_UserAuthData_PublicKey *p, WS_UserAuthData *a) {
event('C'); return crypto_return;
}
static int DoUserAuthRequestEcc(WOLFSSH *s, WS_UserAuthData_PublicKey *p,
enum wc_HashType h, byte *d, word32 n) {
event('C'); return crypto_return;
}
static enum wc_HashType HashForId(byte id) { return WC_HASH_TYPE_SHA; }
static int wc_HashGetDigestSize(enum wc_HashType h) { return 32; }
static int wc_HashInit(wc_HashAlg *h, enum wc_HashType id) { event('H'); return 0; }
static int HashUpdate(wc_HashAlg *h, enum wc_HashType id, const byte *b, word32 n) { return 0; }
static int wc_HashFinal(wc_HashAlg *h, enum wc_HashType id, byte *b) { return 0; }
static void wc_HashFree(wc_HashAlg *h, enum wc_HashType id) {}
static int PrepareUserAuthRequestKeyboard(WOLFSSH *s, word32 *n, WS_UserAuthData *a) {
event('Q'); return WS_SUCCESS;
}
static int BuildUserAuthRequestKeyboard(WOLFSSH *s, byte *b, word32 *n, WS_UserAuthData *a) {
event('B'); return WS_SUCCESS;
}
static int PreparePacket(WOLFSSH *s, word32 n) { event('T'); return WS_SUCCESS; }
static int BundlePacket(WOLFSSH *s) { event('B'); return WS_SUCCESS; }
static int wolfSSH_SendPacket(WOLFSSH *s) { event('S'); s->error = send_return; return send_return; }
static void PurgePacket(WOLFSSH *s) { event('X'); }
static WOLFSSH_CHANNEL *ChannelFind(WOLFSSH *s, word32 id, int kind) { return &channel; }
#include "actual.c"
static int authorize(byte method, WS_UserAuthData *a, void *ctx) {
event('A');
assert(method == a->type);
assert(a->usernameSz == 4 && !memcmp(a->username, "test", 4));
if (method == WOLFSSH_USERAUTH_PASSWORD) {
assert(a->sf.password.hasNewPassword == expect_new_password);
assert(a->sf.password.passwordSz == 5);
assert(!memcmp(a->sf.password.password, "dummy", 5));
}
return auth_return;
}
static int result(byte outcome, WS_UserAuthData *a, void *ctx) {
assert(a->type == WOLFSSH_USERAUTH_PUBLICKEY && a->sf.publicKey.hasSignature);
event(outcome == WOLFSSH_USERAUTH_SUCCESS ? 'R' : 'r');
return result_return;
}
/* Models the parent's registered rejecting prompt callback, not app accounting. */
static int reject_keyboard(WS_UserAuthData_Keyboard *k, void *ctx) {
event('K'); memset(k, 0, sizeof(*k)); return WS_ERROR;
}
static int allowed(WOLFSSH *s, void *ctx) {
return WOLFSSH_USERAUTH_PASSWORD | WOLFSSH_USERAUTH_PUBLICKEY;
}
static byte output[1024], packet[1024];
static word32 length;
static WOLFSSH_CTX context = { authorize, result, reject_keyboard, allowed };
static WOLFSSH ssh;
static void reset(void) {
memset(&ssh, 0, sizeof(ssh)); memset(output, 0, sizeof(output));
memset(events, 0, sizeof(events)); event_count = 0;
ssh.ctx = &context; ssh.outputBuffer.buffer = output; ssh.sessionIdSz = 32;
auth_return = WOLFSSH_USERAUTH_SUCCESS; crypto_return = WS_SUCCESS;
result_return = WS_SUCCESS; send_return = WS_SUCCESS; expect_new_password = 0;
length = 0;
}
static void blob(const void *s, word32 n) {
assert(length + 4 + n <= sizeof(packet));
c32toa(n, packet + length); length += 4;
memcpy(packet + length, s, n); length += n;
}
static void string(const char *s) { blob(s, (word32)strlen(s)); }
static void request(const char *method) { string("test"); string("ssh-connection"); string(method); }
static void key_request(int signed_key, const char *algorithm) {
request("publickey"); packet[length++] = signed_key; string(algorithm);
byte nested[128]; word32 n = (word32)strlen(algorithm);
c32toa(n, nested); memcpy(nested + 4, algorithm, n); nested[4 + n] = 42;
blob(nested, n + 5);
if (signed_key) {
c32toa(1, nested + 4 + n); nested[8 + n] = 42;
blob(nested, n + 9);
}
}
static int dispatch(void) { word32 idx = 0; return DoUserAuthRequest(&ssh, packet, length, &idx); }
static void check(const char *trace, int done) {
assert(!strcmp(events, trace));
assert((ssh.clientState == CLIENT_USERAUTH_DONE) == done); ++groups;
}
int main(void) {
const int rejected[] = { WOLFSSH_USERAUTH_INVALID_PUBLICKEY,
WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_REJECTED,
WOLFSSH_USERAUTH_INVALID_USER, WOLFSSH_USERAUTH_INVALID_AUTHTYPE };
const char *algorithms[] = { "ssh-ed25519", "ecdsa-sha2-nistp256" };
for (unsigned a = 0; a < 2; ++a) {
for (unsigned r = 0; r < sizeof(rejected)/sizeof(rejected[0]); ++r) {
reset(); key_request(1, algorithms[a]); auth_return = rejected[r];
assert(dispatch() == WS_SUCCESS); check("AF", 0);
}
reset(); key_request(0, algorithms[a]); assert(dispatch() == WS_SUCCESS); check("AP", 0);
reset(); key_request(0, algorithms[a]); auth_return = WOLFSSH_USERAUTH_INVALID_PUBLICKEY;
assert(dispatch() == WS_SUCCESS); check("AF", 0);
reset(); key_request(1, algorithms[a]); crypto_return = WS_CRYPTO_FAILED;
result_return = WS_ERROR; /* Failure-result return is ignored. */
assert(dispatch() == WS_SUCCESS); check(a ? "AHCrF" : "ACrF", 0);
reset(); key_request(1, algorithms[a]); assert(dispatch() == WS_SUCCESS);
check(a ? "AHCR" : "ACR", 1);
reset(); key_request(1, algorithms[a]); result_return = WS_ERROR;
assert(dispatch() == WS_SUCCESS); check(a ? "AHCRF" : "ACRF", 0);
reset(); key_request(1, algorithms[a]); auth_return = WOLFSSH_USERAUTH_WOULD_BLOCK;
assert(dispatch() == WS_AUTH_PENDING); check("A", 0);
}
for (int fail = 0; fail < 2; ++fail) {
reset(); request("password"); packet[length++] = 0; string("dummy");
auth_return = fail ? WOLFSSH_USERAUTH_INVALID_PASSWORD : WOLFSSH_USERAUTH_SUCCESS;
assert(dispatch() == WS_SUCCESS); check(fail ? "AF" : "A", !fail);
}
reset(); request("password"); packet[length++] = 1; string("dummy"); string("new-dummy");
expect_new_password = 1; auth_return = WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
assert(dispatch() == WS_SUCCESS); check("AF", 0);
/* Actual parser still calls auth when the new-password length is truncated. */
reset(); request("password"); packet[length++] = 1; string("dummy");
expect_new_password = 1; auth_return = WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
assert(dispatch() == WS_SUCCESS); check("AF", 0);
const char *unsupported[] = { "none", "unrecognized" };
for (unsigned i = 0; i < 2; ++i) {
reset(); request(unsupported[i]); assert(dispatch() == WS_SUCCESS); check("F", 0);
}
reset(); key_request(1, "unsupported-key"); assert(dispatch() == WS_SUCCESS); check("F", 0);
reset(); key_request(1, "ssh-ed25519"); --length;
assert(dispatch() == WS_BUFFER_E); check("", 0);
reset(); request("keyboard-interactive"); string(""); string("");
assert(context.keyboardAuthCb != NULL); assert(dispatch() == WS_ERROR); check("KX", 0);
/* The library writes the message byte even on rejection, but does not send. */
assert(ssh.outputBuffer.length == 0 && output[0] == MSGID_USERAUTH_INFO_REQUEST);
char methods[MAX_AUTH_STRING]; int n = GetAllowedAuth(&ssh, methods);
methods[n] = '\0'; assert(!strcmp(methods, "publickey,password")); ++groups;
/* Execute actual copy/positive-consumed path, including deferred network send. */
for (int deferred = 0; deferred < 2; ++deferred) {
reset(); byte data[] = { 11, 22, 33, 44, 55 };
channel = (WOLFSSH_CHANNEL){ 100, 3, 10, 7 };
send_return = deferred ? WS_WANT_WRITE : WS_SUCCESS;
assert(SendChannelData(&ssh, 1, data, sizeof(data)) == 3);
assert(!memcmp(output + 9, data, 3)); memset(data, 0, 3);
assert(output[9] == 11 && output[10] == 22 && output[11] == 33);
assert(data[3] == 44 && channel.peerWindowSz == 97);
assert(ssh.outputBuffer.plainSz == (deferred ? 3U : 0U)); check("TBS", 0);
}
reset(); ssh.outputBuffer.plainSz = 2; send_return = WS_WANT_WRITE;
byte data[] = { 1, 2 }; assert(SendChannelData(&ssh, 1, data, 2) == WS_WANT_WRITE);
assert(output[9] == 0); check("S", 0);
printf("PASS: %u actual wolfSSH parser/control-flow cases\n", groups);
return 0;
}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Execute extracted, hash-pinned wolfSSH control flow; no downloads or build writes."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
VENDOR = ROOT / "managed_components/wolfssl__wolfssh"
REVIEWED_SHA256 = "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9"
def extract(source, name):
# Mask comments/strings without changing offsets, then balance actual braces.
masked = re.sub(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'',
lambda m: " " * len(m[0]), source, flags=re.S)
matches = list(re.finditer(r"(?m)^(?:static )?(?:int|void|byte|word32)\s+" +
re.escape(name) + r"\s*\([^;{}]*\)\s*\{", masked))
if len(matches) != 1:
raise RuntimeError(f"Expected one definition of {name}, found {len(matches)}")
start = matches[0].start()
brace = masked.index("{", start)
depth = 1
end = brace + 1
while depth:
depth += (masked[end] == "{") - (masked[end] == "}")
end += 1
return source[start:end] + "\n"
def check_build_profile(database):
entries = json.loads(database.read_text())
entry = next(e for e in entries if Path(e["file"]).resolve() ==
(VENDOR / "src/internal.c").resolve())
args = entry.get("arguments") or shlex.split(entry["command"])
# Strip output/dependency-writing flags: this must only preprocess to stdout.
clean = []
skip = False
for arg in args:
if skip:
skip = False
elif arg in ("-o", "-MF", "-MT", "-MQ"):
skip = True
elif arg not in ("-c", "-MD", "-MMD", "-MP"):
clean.append(arg)
result = subprocess.run(clean + ["-E", "-dM"], cwd=entry["directory"],
capture_output=True, text=True, check=True, timeout=30,
env={**os.environ, "CCACHE_DISABLE": "1"})
macros = dict(re.findall(r'^#define (\w+)(?: (.*))?$', result.stdout, re.M))
if macros.get("LIBWOLFSSH_VERSION_HEX") != "0x01004020":
raise RuntimeError("Resolved wolfSSH version differs from reviewed version")
absent = ("WOLFSSH_CERTS", "WOLFSSH_ALLOW_USERAUTH_NONE", "WOLFSSH_NO_ECDSA",
"WOLFSSH_NO_ED25519", "NO_FAILURE_ON_REJECTED")
if "WOLFSSH_NO_RSA" not in macros or any(m in macros for m in absent):
raise RuntimeError("Resolved wolfSSH auth feature profile changed; re-audit")
print("PASS: actual compiler preprocessing matches reviewed auth feature profile", flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
databases = sorted((ROOT / ".pio/build").glob("*/compile_commands.json"))
default_database = databases[0] if len(databases) == 1 else ROOT / "compile_commands.json"
parser.add_argument("--compile-commands", type=Path, default=default_database)
parser.add_argument("--host-only", action="store_true",
help="explicitly skip production compile-command feature verification")
options = parser.parse_args()
raw = (VENDOR / "src/internal.c").read_bytes()
actual = hashlib.sha256(raw).hexdigest()
if actual != REVIEWED_SHA256:
raise RuntimeError(f"wolfSSH internal.c changed: {actual}; re-audit before updating hash")
version = (VENDOR / "wolfssh/version.h").read_text()
if not re.search(r'#define\s+LIBWOLFSSH_VERSION_HEX\s+0x01004020\b', version):
raise RuntimeError("Expected wolfSSH 1.4.20 header")
if not re.search(r'#define\s+LIBWOLFSSH_VERSION_STRING\s+"1\.4\.20"', version):
raise RuntimeError("Unexpected wolfSSH version string")
manifest = (ROOT / "src/idf_component.yml").read_text()
if not re.search(r'^\s*wolfssl/wolfssh:\s*"1\.4\.20"\s*$', manifest, re.M):
raise RuntimeError("Application must pin wolfSSH exactly to 1.4.20")
if options.host_only:
print("SKIP: production feature verification (--host-only)", flush=True)
else:
check_build_profile(options.compile_commands)
source = raw.decode()
# Use the installed public callback data layouts, not hand-maintained copies.
header = (VENDOR / "wolfssh/ssh.h").read_text()
types = header[header.index("typedef struct WS_UserAuthData_Password {"):
header.index("} WS_UserAuthData;") + len("} WS_UserAuthData;")]
results_start = header.index("enum WS_UserAuthResults")
types += "\n" + header[results_start:header.index("};", results_start) + 2]
types += "\n" + "\n".join(re.findall(
r'^#define WOLFSSH_USERAUTH_(?:PASSWORD|PUBLICKEY|KEYBOARD|NONE)\s+.*$', header, re.M))
names = ["GetBoolean", "GetUint32", "GetSize", "GetStringRef",
"DoUserAuthRequestPassword", "DoUserAuthRequestPublicKey",
"SendUserAuthKeyboardRequest", "DoUserAuthRequest", "GetAllowedAuth",
"SendChannelData"]
extracted = "\n".join(extract(source, name) for name in names)
with tempfile.TemporaryDirectory(prefix="wolfssh-auth-contract-") as temp:
temp = Path(temp)
(temp / "auth_types.h").write_text(types)
(temp / "actual.c").write_text(extracted)
binary = temp / "contract"
cc = shlex.split(os.environ.get("CC", "cc"))
subprocess.run(cc + ["-std=c99", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-I", str(temp),
str(HERE / "contract.c"), "-o", str(binary)],
check=True, timeout=30, env={**os.environ, "CCACHE_DISABLE": "1"})
subprocess.run([str(binary)], check=True, timeout=10)
print("PASS: installed source SHA-256, version header and exact application pin")
if __name__ == "__main__":
main()