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
+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;
}