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
+1
View File
@@ -25,6 +25,7 @@ idf_component_register(
"admin_command_gate.c"
"admin_ssh_console.c"
"ssh_transport.c"
"ssh_auth_policy.c"
"ssh_console.c"
"usb_cdc_transport.c"
"usb_console.c"
+15 -3
View File
@@ -69,6 +69,7 @@ typedef struct {
bool discard_next_lf;
admin_prompt_state_t prompt_state;
bool prompt_hidden;
bool prompt_rejected;
size_t prompt_capacity;
size_t prompt_length;
uint8_t prompt_input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
@@ -385,6 +386,7 @@ esp_err_t admin_ssh_console_dispatch_read_input(
session->prompt_length = 0U;
session->prompt_capacity = capacity;
session->prompt_hidden = hidden;
session->prompt_rejected = false;
session->prompt_state = ADMIN_PROMPT_WAITING;
bool published = append_output_locked(session, (const uint8_t *)prompt, strlen(prompt));
if (!published) {
@@ -413,14 +415,19 @@ esp_err_t admin_ssh_console_dispatch_read_input(
if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
result = ESP_ERR_NOT_FOUND;
} else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
memcpy(output, session->prompt_input, session->prompt_length);
*output_length = session->prompt_length;
result = ESP_OK;
if (session->prompt_rejected) {
result = ESP_ERR_INVALID_SIZE;
} else {
memcpy(output, session->prompt_input, session->prompt_length);
*output_length = session->prompt_length;
result = ESP_OK;
}
}
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
session->prompt_length = 0U;
session->prompt_capacity = 0U;
session->prompt_hidden = false;
session->prompt_rejected = false;
session->prompt_state = ADMIN_PROMPT_NONE;
taskEXIT_CRITICAL(&s_lock);
return result;
@@ -1209,9 +1216,14 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
if (!session->prompt_hidden) {
(void)append_output_locked(session, &value, 1U);
}
} else if (session->prompt_hidden) {
session->prompt_rejected = true;
} else {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
}
} else if (session->prompt_hidden) {
/* Do not silently normalize unrepresentable credential bytes. */
session->prompt_rejected = true;
}
}
++*consumed;
+11
View File
@@ -43,6 +43,7 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
return error;
}
bool rejected = false;
for (;;) {
uint8_t byte = 0U;
if (uart_read_bytes(CONSOLE_INPUT_UART, &byte, 1U, portMAX_DELAY) != 1) {
@@ -71,6 +72,11 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
continue;
}
if (byte < 0x20U || byte > 0x7eU || *output_length >= capacity - 1U) {
/* Hidden credentials must never accept a truncated/normalized prefix. */
if (hidden) {
rejected = true;
continue;
}
putchar('\a');
fflush(stdout);
continue;
@@ -82,6 +88,11 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
}
}
putchar('\n');
if (rejected) {
secure_wipe(output, capacity);
*output_length = 0U;
return ESP_ERR_INVALID_SIZE;
}
return ESP_OK;
}
+4
View File
@@ -8,6 +8,10 @@
#include "esp_err.h"
/* Hidden input accepts printable ASCII with CR/LF submit, BS/DEL editing and
* Ctrl-C cancellation. Overflow or any other byte rejects the entire prompt on
* submit (ESP_ERR_INVALID_SIZE), even after editing; rejected input is wiped.
* capacity includes the trailing NUL. Visible line editing is unchanged. */
esp_err_t console_input_read_hidden(const char *prompt,
uint8_t *output, size_t capacity,
size_t minimum_length, size_t maximum_length,
+50
View File
@@ -0,0 +1,50 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_auth_policy.h"
#include <stddef.h>
bool ssh_auth_policy_admit(ssh_auth_policy_t *policy,
ssh_auth_policy_kind_t kind, int64_t now_us)
{
if (policy == NULL || (unsigned)kind >= SSH_AUTH_POLICY_KIND_COUNT || now_us < 0) {
return false;
}
for (unsigned i = 0; i < SSH_AUTH_POLICY_KIND_COUNT; ++i) {
if (policy->buckets[i].initialized && now_us < policy->buckets[i].last_seen_us) {
return false;
}
}
const unsigned capacity = kind == SSH_AUTH_POLICY_PROBE
? SSH_AUTH_POLICY_PROBE_CAPACITY
: kind == SSH_AUTH_POLICY_HANDSHAKE
? SSH_AUTH_POLICY_HANDSHAKE_CAPACITY : SSH_AUTH_POLICY_VERIFICATION_CAPACITY;
const int64_t interval = kind == SSH_AUTH_POLICY_PROBE
? SSH_AUTH_POLICY_PROBE_REFILL_US
: kind == SSH_AUTH_POLICY_HANDSHAKE
? SSH_AUTH_POLICY_HANDSHAKE_REFILL_US : SSH_AUTH_POLICY_VERIFICATION_REFILL_US;
ssh_auth_policy_bucket_t *bucket = &policy->buckets[kind];
if (!bucket->initialized) {
bucket->tokens = (uint8_t)capacity;
bucket->refill_us = now_us;
bucket->initialized = true;
} else {
/* Both timestamps are nonnegative and ordered. Divide before adding
* to avoid overflow even for a jump from zero to INT64_MAX. */
const int64_t elapsed = now_us - bucket->refill_us;
const int64_t earned = elapsed / interval;
if (earned >= (int64_t)(capacity - bucket->tokens)) {
bucket->tokens = (uint8_t)capacity;
bucket->refill_us = now_us;
} else {
bucket->tokens += (uint8_t)earned;
bucket->refill_us = now_us - elapsed % interval;
}
}
bucket->last_seen_us = now_us;
if (bucket->tokens == 0) {
return false;
}
--bucket->tokens;
return true;
}
+44
View File
@@ -0,0 +1,44 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#define SSH_AUTH_POLICY_HANDSHAKE_CAPACITY 6U
#define SSH_AUTH_POLICY_HANDSHAKE_REFILL_US INT64_C(10000000)
#define SSH_AUTH_POLICY_VERIFICATION_CAPACITY 6U
#define SSH_AUTH_POLICY_VERIFICATION_REFILL_US INT64_C(10000000)
#define SSH_AUTH_POLICY_PROBE_CAPACITY 12U
#define SSH_AUTH_POLICY_PROBE_REFILL_US INT64_C(5000000)
typedef enum {
SSH_AUTH_POLICY_HANDSHAKE,
SSH_AUTH_POLICY_VERIFICATION,
SSH_AUTH_POLICY_PROBE,
SSH_AUTH_POLICY_KIND_COUNT
} ssh_auth_policy_kind_t;
typedef struct {
int64_t refill_us;
int64_t last_seen_us;
uint8_t tokens;
bool initialized;
} ssh_auth_policy_bucket_t;
typedef struct {
ssh_auth_policy_bucket_t buckets[SSH_AUTH_POLICY_KIND_COUNT];
} ssh_auth_policy_t;
/* Single-owner only: no allocation, locks, clock reads, timers or sleeps.
* Start zero-initialized; each class lazily starts at capacity. The owner must
* retain one shared instance across sessions, stop/start and counter clears;
* only reboot resets it. Do not modify fields directly or refund admissions.
* Each true result consumes one token, regardless of subsequent auth outcome.
* Refill adds one token per class-specific interval, preserving partial credit
* below capacity and discarding all surplus (including fractions) at capacity.
* now_us must be nonnegative and nondecreasing across ALL classes (equal is OK).
* NULL, invalid kind, negative time and regression reject without mutation.
* An empty-bucket denial records time but never postpones the refill deadline.
*/
bool ssh_auth_policy_admit(ssh_auth_policy_t *policy,
ssh_auth_policy_kind_t kind, int64_t now_us);
+10
View File
@@ -138,6 +138,16 @@ static int show_counters(void)
counter->handshake_successes, counter->handshake_failures,
counter->handshake_timeouts, counter->authentication_attempts,
counter->authentication_failures, counter->request_rejections);
printf("Auth admission: handshakes=%" PRIu64 " handshake-throttled=%" PRIu64
" verifications=%" PRIu64 " verification-throttled=%" PRIu64 "\n",
counter->handshake_admissions, counter->handshake_throttle_rejections,
counter->authentication_admissions, counter->authentication_throttle_rejections);
printf("Auth policy: probes=%" PRIu64 " probe-throttled=%" PRIu64
" attempt-limit-closes=%" PRIu64 " backend-errors=%" PRIu64
" method-rejects=%" PRIu64 "\n",
counter->authentication_probe_admissions, counter->authentication_probe_rejections,
counter->authentication_limit_disconnects, counter->authentication_backend_errors,
counter->authentication_method_rejections);
printf("Broker: connect=%" PRIu64 " failures=%" PRIu64
" disconnect=%" PRIu64 " writer-requests=%" PRIu64
" grants=%" PRIu64 " denials=%" PRIu64
+115 -17
View File
@@ -24,10 +24,21 @@
#include "secure_random.h"
#include "serial_service.h"
#include "ssh_security.h"
#include "ssh_auth_policy.h"
#include "user_database.h"
#include <wolfssl/wolfcrypt/memory.h>
#include <wolfssl/wolfcrypt/random.h>
#include <wolfssh/ssh.h>
#include <wolfssh/version.h>
/* Admission precedes signature work; result callbacks only follow authorized
* signed keys. Re-audit the parser/callback contract when upgrading wolfSSH. */
#if LIBWOLFSSH_VERSION_HEX != 0x01004020
#error "Re-audit SSH authentication callback ordering for this wolfSSH version"
#endif
#if defined(WOLFSSH_CERTS) || defined(WOLFSSH_ALLOW_USERAUTH_NONE)
#error "SSH admission policy requires certificate and none authentication disabled"
#endif
#if defined(CONFIG_MBEDTLS_HARDWARE_AES) && CONFIG_MBEDTLS_HARDWARE_AES
#error "Concurrent mbedTLS/wolfSSH operation requires mbedTLS software AES"
@@ -64,6 +75,7 @@ typedef struct {
user_principal_t pending_principal;
bool principal_valid;
bool pending_principal_valid;
bool awaiting_auth_result;
bool authenticated;
bool shell_requested;
uint8_t console_slot_index;
@@ -110,11 +122,14 @@ static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
/* Owned exclusively by the transport task. */
static WOLFSSH_CTX *s_context;
static int s_listen_fd = -1;
/* Boot-lifetime admission state: neither service restart nor counter clear
* replenishes it. Only the SSH owner accesses this fixed-size policy. */
static ssh_auth_policy_t s_auth_policy;
static void add_counter(uint64_t *counter, uint64_t value)
{
taskENTER_CRITICAL(&s_lock);
*counter += value;
*counter = value > UINT64_MAX - *counter ? UINT64_MAX : *counter + value;
taskEXIT_CRITICAL(&s_lock);
}
@@ -384,9 +399,52 @@ static void clear_pending_principal(ssh_slot_t *slot)
if (slot != NULL) {
secure_wipe(&slot->pending_principal, sizeof(slot->pending_principal));
slot->pending_principal_valid = false;
slot->awaiting_auth_result = false;
}
}
static void close_authentication(ssh_slot_t *slot)
{
if (slot != NULL && !slot->close_requested) {
slot->close_requested = true;
if (slot->socket_fd >= 0) {
(void)shutdown(slot->socket_fd, SHUT_RDWR);
}
}
}
static bool admit_authentication(ssh_slot_t *slot, bool probe)
{
ssh_auth_policy_kind_t kind = probe ? SSH_AUTH_POLICY_PROBE
: SSH_AUTH_POLICY_VERIFICATION;
if (!ssh_auth_policy_admit(&s_auth_policy, kind, esp_timer_get_time())) {
add_counter(probe ? &s_counters.authentication_probe_rejections
: &s_counters.authentication_throttle_rejections, 1U);
clear_pending_principal(slot);
close_authentication(slot);
return false;
}
add_counter(probe ? &s_counters.authentication_probe_admissions
: &s_counters.authentication_admissions, 1U);
return true;
}
/* Advertisement is not a dispatch filter in wolfSSH 1.4.20. Supply a rejecting
* callback so a direct keyboard-interactive request cannot call through NULL. */
static int reject_keyboard_auth(WS_UserAuthData_Keyboard *keyboard, void *context)
{
if (keyboard != NULL) {
secure_wipe(keyboard, sizeof(*keyboard));
}
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot != NULL && !slot->close_requested) {
add_counter(&s_counters.authentication_method_rejections, 1U);
}
clear_pending_principal(slot);
close_authentication(slot);
return WS_ERROR;
}
static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
{
add_counter(&s_counters.authentication_attempts, 1U);
@@ -401,10 +459,8 @@ static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
return true;
}
slot->close_requested = true;
if (slot->socket_fd >= 0) {
(void)shutdown(slot->socket_fd, SHUT_RDWR);
}
add_counter(&s_counters.authentication_limit_disconnects, 1U);
close_authentication(slot);
return false;
}
@@ -430,10 +486,14 @@ static int authenticate_password(ssh_slot_t *slot,
slot->principal = principal;
slot->principal_valid = true;
slot->authenticated = true;
secure_wipe(&principal, sizeof(principal));
return WOLFSSH_USERAUTH_SUCCESS;
}
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK) {
add_counter(&s_counters.authentication_backend_errors, 1U);
}
bool retry = complete_authentication_attempt(slot, true);
if (!retry) {
return WOLFSSH_USERAUTH_REJECTED;
@@ -463,6 +523,9 @@ static int authenticate_public_key(ssh_slot_t *slot,
if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK) {
add_counter(&s_counters.authentication_backend_errors, 1U);
}
if (public_key->hasSignature == 0U) {
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY
: WOLFSSH_USERAUTH_FAILURE;
@@ -478,6 +541,7 @@ static int authenticate_public_key(ssh_slot_t *slot,
if (public_key->hasSignature != 0U) {
slot->pending_principal = principal;
slot->pending_principal_valid = true;
slot->awaiting_auth_result = true;
}
secure_wipe(&principal, sizeof(principal));
return WOLFSSH_USERAUTH_SUCCESS;
@@ -488,20 +552,29 @@ static int authenticate_user(byte authentication_type,
void *context)
{
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot == NULL || authentication == NULL ||
authentication_type != authentication->type) {
if (slot == NULL || slot->state != SSH_TRANSPORT_SESSION_HANDSHAKE ||
slot->close_requested || slot->authenticated || slot->awaiting_auth_result) {
clear_pending_principal(slot);
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
close_authentication(slot);
return WOLFSSH_USERAUTH_REJECTED;
}
if (authentication_type == WOLFSSH_USERAUTH_PASSWORD) {
return authenticate_password(slot, authentication);
}
if (authentication_type == WOLFSSH_USERAUTH_PUBLICKEY) {
return authenticate_public_key(slot, authentication);
if (authentication == NULL || authentication_type != authentication->type ||
(authentication_type != WOLFSSH_USERAUTH_PASSWORD &&
authentication_type != WOLFSSH_USERAUTH_PUBLICKEY)) {
add_counter(&s_counters.authentication_method_rejections, 1U);
clear_pending_principal(slot);
close_authentication(slot);
return WOLFSSH_USERAUTH_REJECTED;
}
clear_pending_principal(slot);
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
bool probe = authentication_type == WOLFSSH_USERAUTH_PUBLICKEY &&
authentication->sf.publicKey.hasSignature == 0U;
if (!admit_authentication(slot, probe)) {
return WOLFSSH_USERAUTH_REJECTED;
}
return authentication_type == WOLFSSH_USERAUTH_PASSWORD
? authenticate_password(slot, authentication)
: authenticate_public_key(slot, authentication);
}
static int authentication_result(byte result, WS_UserAuthData *authentication,
@@ -510,10 +583,17 @@ static int authentication_result(byte result, WS_UserAuthData *authentication,
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot == NULL || authentication == NULL ||
authentication->type != WOLFSSH_USERAUTH_PUBLICKEY ||
authentication->sf.publicKey.hasSignature == 0U) {
authentication->sf.publicKey.hasSignature == 0U ||
slot->state != SSH_TRANSPORT_SESSION_HANDSHAKE ||
slot->close_requested || slot->authenticated ||
!slot->awaiting_auth_result || !slot->pending_principal_valid) {
clear_pending_principal(slot);
close_authentication(slot);
return WS_ERROR;
}
/* The authorization callback already consumed the verification token.
* Take the completion marker before any result/currentness processing. */
slot->awaiting_auth_result = false;
if (result != WOLFSSH_USERAUTH_SUCCESS) {
(void)complete_authentication_attempt(slot, true);
@@ -527,6 +607,9 @@ static int authentication_result(byte result, WS_UserAuthData *authentication,
&slot->pending_principal, &current)
: ESP_ERR_INVALID_STATE;
if (error != ESP_OK || !current) {
if (error != ESP_OK) {
add_counter(&s_counters.authentication_backend_errors, 1U);
}
(void)complete_authentication_attempt(slot, true);
clear_pending_principal(slot);
return WS_ERROR;
@@ -606,7 +689,7 @@ static bool cleanup_slot(ssh_slot_t *slot)
}
uint32_t generation = slot->generation;
memset(slot, 0, sizeof(*slot));
secure_wipe(slot, sizeof(*slot));
slot->state = SSH_TRANSPORT_SESSION_FREE;
slot->generation = generation;
slot->socket_fd = -1;
@@ -662,6 +745,7 @@ static esp_err_t create_context(void)
wolfSSH_SetUserAuth(context, authenticate_user);
wolfSSH_SetUserAuthTypes(context, allowed_auth_types);
wolfSSH_SetUserAuthResult(context, authentication_result);
wolfSSH_SetKeyboardAuthPrompts(context, reject_keyboard_auth);
(void)wolfSSH_CTX_SetChannelReqShellCb(context, accept_shell);
(void)wolfSSH_CTX_SetChannelReqExecCb(context, reject_channel_request);
(void)wolfSSH_CTX_SetChannelReqSubsysCb(context, reject_channel_request);
@@ -897,6 +981,13 @@ static void accept_connections(void)
close(socket_fd);
continue;
}
if (!ssh_auth_policy_admit(&s_auth_policy, SSH_AUTH_POLICY_HANDSHAKE,
esp_timer_get_time())) {
add_counter(&s_counters.handshake_throttle_rejections, 1U);
close(socket_fd);
continue;
}
add_counter(&s_counters.handshake_admissions, 1U);
if (set_nonblocking(socket_fd) != ESP_OK) {
add_counter(&s_counters.io_failures, 1U);
close(socket_fd);
@@ -927,6 +1018,7 @@ static void accept_connections(void)
wolfSSH_SetIOReadCtx(slot->ssh, slot);
wolfSSH_SetUserAuthCtx(slot->ssh, slot);
wolfSSH_SetUserAuthResultCtx(slot->ssh, slot);
wolfSSH_SetKeyboardAuthCtx(slot->ssh, slot);
wolfSSH_SetChannelReqCtx(slot->ssh, slot);
publish_slot(slot, slot_index);
}
@@ -1224,6 +1316,11 @@ static bool flush_client_output(ssh_slot_t *slot)
slot->ssh, slot->tx_buffer + slot->tx_offset,
(word32)(slot->tx_length - slot->tx_offset));
if (result > 0) {
/* Positive stream_send means copied/consumed by wolfSSH, not peer
* receipt. Preserve pending bytes on retry and the binary serial path. */
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
secure_wipe(slot->tx_buffer + slot->tx_offset, (size_t)result);
}
slot->tx_offset += (size_t)result;
add_counter(&s_counters.tx_bytes, (uint64_t)result);
if (slot->tx_offset >= slot->tx_length) {
@@ -1285,6 +1382,7 @@ static bool flush_admin_input(ssh_slot_t *slot, size_t slot_index)
&token, slot->rx_buffer + slot->rx_offset,
slot->rx_length - slot->rx_offset, &consumed);
if (consumed > 0U) {
secure_wipe(slot->rx_buffer + slot->rx_offset, consumed);
slot->rx_offset += consumed;
add_counter(&s_counters.rx_accepted_bytes, consumed);
}
+11
View File
@@ -44,6 +44,17 @@ typedef struct {
uint64_t handshake_timeouts;
uint64_t authentication_attempts;
uint64_t authentication_failures;
/* Admission is before work; completion above excludes denied requests and
* unsigned probes. All values are counts, never submitted identity data. */
uint64_t handshake_admissions;
uint64_t handshake_throttle_rejections;
uint64_t authentication_admissions;
uint64_t authentication_throttle_rejections;
uint64_t authentication_probe_admissions;
uint64_t authentication_probe_rejections;
uint64_t authentication_limit_disconnects;
uint64_t authentication_backend_errors;
uint64_t authentication_method_rejections;
uint64_t request_rejections;
uint64_t broker_connections;
uint64_t broker_failures;