Add Phase 9C security hardening

Generate exact-hash SDK source overrides without modifying dependencies.
Harden
SSH allocation and algorithm policy, tighten web authentication cleanup,
and add
focused host contract tests and documentation.
This commit is contained in:
2026-09-15 22:12:57 +02:00
parent 751dfb9ddb
commit cdc9c7335a
41 changed files with 3597 additions and 89 deletions
+134
View File
@@ -0,0 +1,134 @@
# Pinned SDK security overrides
The root `CMakeLists.txt` includes `cmake/security_overrides.cmake` **after**
`project()`. No component/vendor file is edited, and no global crypto feature or
client ciphersuite setting is changed.
## Build contract
`tools/security_overrides.py` requires the installed ESP-IDF version header to
identify **5.5.0**, and checks each complete original source against its reviewed
SHA256. Every text substitution must match **exactly once**. The entire input
plan is validated before any output is written. A changed SDK, missing source,
ambiguous edit, duplicate source, or missing/ambiguous component target fails
configuration; there is no unpatched fallback or automatic hash repinning.
Derived **full files**, retaining the original copyright/license notices, live
only at `<CMAKE_BINARY_DIR>/security_overrides/<entry-name>/<basename>`.
The manifest and derived files are atomically replaced only when their bytes
change. Output paths cannot escape the binary tree or alias SDK/source files.
CMake tracks the generator, version header, originals, and derived sources for
reconfiguration. The included CMake file is itself an ordinary CMake input.
Requirements: Python 3.9+ and CMake 3.18+ (directory-scoped source properties).
CMake replaces the exact original entry in the existing component's `SOURCES`;
it does not add a second definition or replace the component target. Target
compile settings remain intact. Source compile flags/options/definitions,
per-configuration definitions, source includes and object dependencies are
copied in the target's owning directory. The original C file's directory is
prepended to that source's include search path, preserving quoted local headers.
Source generator expressions are rejected rather than guessed through.
### Current corrections
- `esp_https_server:src/https_server.c`: delete TLS if post-handshake transport
allocation fails; destroy the complete secure context if HTTPD start fails;
wipe exactly `serverkey_bytes` before releasing the raw private-key copy.
Failed start restores the original open callback and clears stale transport
context/destructor pointers. Failed stop retains live ownership.
- `esp_http_server:src/httpd_parse.c`: allocate/copy/wipe/free scratch resize,
retaining old storage on failure; wipe current scratch on final cleanup.
Initial reads avoid null-pointer subtraction and preserve a null parser
position until a callback sets it; existing positions relocate with scratch.
Existing shrink/grow behavior and bounds remain. Resizing briefly owns old
plus new allocations; no persistent maximum-size buffer, socket, task or
limit increase. Pending/unread bytes are not erased or drained by the patch.
- `esp-tls:esp_tls_mbedtls.c`: **server-local** static-lifetime allowlist of
`TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` and
`TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384`, terminated by zero, configured after
server defaults and before setup. Both version limits are TLS 1.2;
renegotiation is disabled (or absent at compile time). Required TLS/ECDHE/
ECDSA/AES/GCM/SHA features are compile-guarded. IDF dynamic buffers are rejected
because their destructor bypasses the audited upstream record-buffer wipe.
`set_client_config()` and the shared handle/setup path are unchanged.
- `wolfssl__wolfssh:src/internal.c`: the fourth override pins wolfSSH 1.4.20's
original source SHA256 to
`81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9`.
`GetSize()` bounds password and new-password fields before authentication;
malformed parsing cannot reach the auth callback. The checked packet suffix
is wiped before failure responses, preserving the caller's prefix. Pending
asynchronous authentication retains the payload for retry; this is **not**
an async secret-lifetime/wipe guarantee. Generated parser/control-flow tests
live in `tests/wolfssh_auth_contract/`.
Clients that cannot negotiate this server profile will no longer connect.
Live interoperability and resource/latency testing remain hardware gates.
These corrections do not claim comprehensive zeroization of every TLS/library
copy, compiler spill, accelerator register, browser buffer or allocator region.
## Parent extension point
Add an `Entry` to `tools/security_overrides.py:ENTRIES` with:
- unique `name`;
- exact IDF `component` name (used by `idf_component_get_property`);
- `root="idf"` for installed IDF sources, or `root="project"` for project/vendor
sources;
- exact relative `source`, full reviewed `sha256`, and a tuple of `Edit(old,new)`
exact-once substitutions.
`render_entry()` validates/patches an entry; `generate()` accepts an explicit
entry tuple as well as the default registry. The manifest maps each entry to
its component, original and derived source. CMake's
`sak_security_replace_source(component original generated)` handles replacement
without backend assumptions. The current registry uses this for three IDF
sources and the project-managed wolfSSH source described above. Update the
corresponding library-specific feature/behavior tests when extending the registry. Multiple
sources in the same real component are supported by the replacement function.
Do not change a source hash merely to make a new SDK configure. Re-audit ownership,
cleanup, feature resolution and patches against that source revision first.
## Validation
```sh
python3 tests/sdk_security_overrides/run.py
python3 tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8
```
Optional `--idf-path` selects an existing installed SDK. Tests need host `cc`,
CMake and Ninja; they install nothing, use temporary directories under `.pio/`, and never edit
the selected SDK. The second command also checks the existing real firmware
Ninja registration: exactly one compilation of each derived source, no original
compilation, and exact generated bytes. It does **not** run a firmware build.
Coverage:
- Generator full-source hashes, version, missing/duplicate/ambiguous inputs,
exact edit counts, validation-before-output, unchanged-byte/mtime idempotence,
unsafe output rejection, and preserved upstream notices.
- Extracted **patched actual functions**, not reimplemented cleanup logic:
HTTPS allocation failure matrix; handshake failure; post-handshake allocation
failure; HTTPD start failure; normal close/stop; failed stop preserving ownership.
The unmodified installed `httpd_stop()` is extracted and separately pinned.
Allocator doubles assert key bytes are zero **before** free, with trailing
canaries to reject over-wiping, and detect leaks/double frees.
- Actual patched scratch helper/read/cleanup functions: grow, shrink, no-change,
null-initial first-read success/failure, nullable parser-position preservation,
resize failure preserving the old pointer, receive errors/timeouts, size bounds, final wipe and
original right-aligned pending-byte behavior. Installed pending/unrecv functions
are separately pinned and executed. This is not a full HTTP parser fuzz test.
- Actual server/client configuration functions with crypto/config doubles:
allowlist order/terminator/static lifetime, defaults failure, PKI failure,
version limits, renegotiation enabled/compiled-out variants, untouched default
and caller-provided client suites. Every required feature is individually
removed in compile-failure tests; dynamic-buffer enablement also fails.
- The actual CMake include under fake IDF target discovery, including missing and
duplicate sources/targets. A separate real host compile tests the project-root
extension, child-directory relative `SOURCES`, quoted and source-specific
includes, source/target flags and per-config source definitions. Changing that
fixture's original file makes the next ordinary build reconfigure and reject
its hash instead of compiling stale derived code.
No tests here perform real TLS handshakes, network/device operations, allocation
failure on the target, or whole-Phase-9 hardware acceptance.
+68
View File
@@ -0,0 +1,68 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct { void *p; size_t n; bool wipe; } allocation;
static allocation allocations[64];
static unsigned live, calls, fail_at, wiped_frees;
static size_t secret_size;
static bool all_secret;
static void *test_alloc(size_t n, bool clear)
{
if (++calls == fail_at) return NULL;
void *p = clear ? calloc(1, n + 16) : malloc(n + 16);
assert(p);
if (!clear) memset(p, 0xa5, n);
memset((unsigned char *)p + n, 0x7b, 16);
for (unsigned i = 0; i < 64; ++i) if (!allocations[i].p) {
allocations[i] = (allocation){p, n, all_secret || n == secret_size};
++live;
return p;
}
abort();
}
static void test_free(void *p)
{
if (!p) return;
for (unsigned i = 0; i < 64; ++i) if (allocations[i].p == p) {
for (size_t j = 0; j < 16; ++j)
assert(((unsigned char *)p)[allocations[i].n + j] == 0x7b);
if (allocations[i].wipe) {
for (size_t j = 0; j < allocations[i].n; ++j)
assert(((unsigned char *)p)[j] == 0);
++wiped_frees;
}
memset(p, 0xdd, allocations[i].n);
allocations[i].p = NULL;
--live;
free(p);
return;
}
assert(!"double free or unowned allocation");
}
static void mark_secret(void *p)
{
for (unsigned i = 0; i < 64; ++i) if (allocations[i].p == p) {
allocations[i].wipe = true;
return;
}
abort();
}
#define malloc(n) test_alloc((n), false)
#define calloc(n, s) test_alloc((n) * (s), true)
#define free(p) test_free(p)
#define ESP_LOGE(...) ((void)0)
#define ESP_LOGD(...) ((void)0)
#define ESP_LOGI(...) ((void)0)
#define ESP_LOGW(...) ((void)0)
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM -2
#define ESP_ERR_INVALID_ARG -3
#define ESP_ERR_INVALID_STATE -4
#define ESP_ERR_NOT_SUPPORTED -5
typedef int esp_err_t;
+159
View File
@@ -0,0 +1,159 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "alloc.h"
typedef void *httpd_handle_t;
typedef int (*httpd_open_func_t)(httpd_handle_t, int);
typedef void esp_https_server_user_cb(void *);
typedef struct { unsigned char secret[37]; } esp_tls_t;
typedef void *esp_tls_error_handle_t;
typedef struct { int last_error, esp_tls_error_code, esp_tls_flags; } esp_https_server_last_error_t;
typedef struct { int user_cb_state; esp_tls_t *tls; } esp_https_server_user_cb_arg_t;
typedef struct {
const unsigned char *cacert_buf, *servercert_buf, *serverkey_buf;
unsigned cacert_bytes, servercert_bytes, serverkey_bytes;
void *userdata; const char **alpn_protos;
unsigned tls_handshake_timeout_ms; bool use_secure_element;
} esp_tls_cfg_server_t;
typedef struct {
void *global_transport_ctx, *global_user_ctx;
void (*global_transport_ctx_free_fn)(void *), (*global_user_ctx_free_fn)(void *);
httpd_open_func_t open_fn;
int server_port, ctrl_port;
} httpd_config_t;
struct httpd_ssl_config {
httpd_config_t httpd;
bool session_tickets, use_secure_element, use_ecdsa_peripheral;
const unsigned char *cacert_pem, *servercert, *prvtkey_pem;
unsigned cacert_len, servercert_len, prvtkey_len, tls_handshake_timeout_ms;
void *ssl_userdata; const char **alpn_protos;
esp_https_server_user_cb *user_cb;
int transport_mode, port_secure, port_insecure;
};
struct httpd_data {
httpd_config_t config; int msg_fd;
struct { int status; } hd_td;
void *transport;
void (*close_fn)(void *);
};
struct httpd_ctrl_data { int hc_msg; };
#define HTTPD_SSL_TRANSPORT_SECURE 1
#define HTTPD_SSL_USER_CB_SESS_CLOSE 2
#define HTTPD_SSL_USER_CB_SESS_CREATE 3
#define HTTPS_SERVER_EVENT_ERROR 4
#define HTTPS_SERVER_EVENT_ON_CONNECTED 5
#define HTTPS_SERVER_EVENT_DISCONNECTED 6
#define HTTPS_SERVER_EVENT_START 7
#define HTTPS_SERVER_EVENT_STOP 8
#define HTTP_SERVER_EVENT_STOP 9
#define HTTPD_CTRL_SHUTDOWN 10
#define THREAD_STOPPED 11
static bool start_failure, stop_failure, handshake_failure;
static unsigned deletes, creates, closes;
static struct httpd_data *active;
static void http_dispatch_event_to_event_loop(int id, const void *v, size_t n) {}
static void esp_http_server_dispatch_event(int id, const void *v, size_t n) {}
static int esp_tls_cfg_server_session_tickets_init(esp_tls_cfg_server_t *cfg) { return 0; }
static void esp_tls_cfg_server_session_tickets_free(esp_tls_cfg_server_t *cfg) {}
static esp_tls_t *esp_tls_init(void) {
esp_tls_t *tls = calloc(1, sizeof(*tls));
if (tls) { memset(tls, 0xb6, sizeof(*tls)); mark_secret(tls); }
return tls;
}
static int esp_tls_server_session_create(esp_tls_cfg_server_t *cfg, int fd, esp_tls_t *tls) {
return handshake_failure ? -1 : 0;
}
static void esp_tls_server_session_delete(esp_tls_t *tls) {
assert(tls); ++deletes; memset(tls, 0, sizeof(*tls)); free(tls);
}
static int esp_tls_get_error_handle(esp_tls_t *tls, esp_tls_error_handle_t *e) { return -1; }
static int esp_tls_get_and_clear_last_error(esp_tls_error_handle_t e, int *a, int *b) { return 0; }
static void *httpd_get_global_transport_ctx(httpd_handle_t h) { return ((struct httpd_data *)h)->config.global_transport_ctx; }
static void httpd_sess_set_transport_ctx(httpd_handle_t h, int fd, void *ctx, void (*fn)(void *)) {
struct httpd_data *hd = h; assert(!hd->transport); hd->transport = ctx; hd->close_fn = fn;
}
static int httpd_ssl_send(void) { return 0; }
static int httpd_ssl_recv(void) { return 0; }
static int httpd_ssl_pending(void) { return 0; }
static void httpd_sess_set_send_override(httpd_handle_t h, int fd, int (*fn)(void)) {}
static void httpd_sess_set_recv_override(httpd_handle_t h, int fd, int (*fn)(void)) {}
static void httpd_sess_set_pending_override(httpd_handle_t h, int fd, int (*fn)(void)) {}
static int httpd_start(httpd_handle_t *h, httpd_config_t *cfg) {
if (start_failure) return ESP_FAIL;
struct httpd_data *hd = calloc(1, sizeof(*hd));
if (!hd) return ESP_ERR_NO_MEM;
hd->config = *cfg; *h = hd; active = hd; return ESP_OK;
}
static int cs_send_to_ctrl_sock(int fd, int port, void *msg, size_t n) { return stop_failure ? -1 : 0; }
static void httpd_os_thread_sleep(int ms) {
if (active->transport) {
active->close_fn(active->transport);
active->transport = NULL;
}
active->hd_td.status = THREAD_STOPPED;
}
static void httpd_delete(struct httpd_data *hd) { assert(!hd->transport); free(hd); active = NULL; }
static void user_callback(void *arg) {
esp_https_server_user_cb_arg_t *a = arg;
if (a->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE) ++creates;
if (a->user_cb_state == HTTPD_SSL_USER_CB_SESS_CLOSE) ++closes;
}
/* SDK_FUNCTIONS */
static struct httpd_ssl_config config(void) {
static unsigned char ca[13], cert[19], key[23];
memset(ca, 1, sizeof(ca)); memset(cert, 2, sizeof(cert)); memset(key, 3, sizeof(key));
return (struct httpd_ssl_config){.transport_mode=HTTPD_SSL_TRANSPORT_SECURE,
.cacert_pem=ca, .cacert_len=sizeof(ca), .servercert=cert, .servercert_len=sizeof(cert),
.prvtkey_pem=key, .prvtkey_len=sizeof(key), .user_cb=user_callback};
}
int main(void) {
secret_size = 23;
for (unsigned fail = 1; fail <= 6; ++fail) {
struct httpd_ssl_config cfg = config(); httpd_handle_t h = NULL;
calls = 0; fail_at = fail;
assert(httpd_ssl_start(&h, &cfg) != ESP_OK);
assert(!h && live == 0);
}
fail_at = 0;
struct httpd_ssl_config cfg = config(); httpd_handle_t h = NULL;
start_failure = true;
unsigned wipes = wiped_frees;
assert(httpd_ssl_start(&h, &cfg) != ESP_OK && live == 0);
assert(wiped_frees == wipes + 1);
assert(!cfg.httpd.global_transport_ctx && !cfg.httpd.global_transport_ctx_free_fn);
assert(!cfg.httpd.open_fn); /* no stale HTTPS wrapper on a retry */
start_failure = false;
assert(httpd_ssl_start(&h, &cfg) == ESP_OK);
assert(httpd_ssl_stop(h) == ESP_OK && live == 0);
h = NULL;
for (unsigned missing = 0; missing < 2; ++missing) {
cfg = config();
if (missing) cfg.prvtkey_pem = NULL; else cfg.servercert = NULL;
assert(httpd_ssl_start(&h, &cfg) != ESP_OK && live == 0);
}
cfg = config();
assert(httpd_ssl_start(&h, &cfg) == ESP_OK);
unsigned baseline = live;
for (unsigned fail = 1; fail <= 2; ++fail) {
calls = 0; fail_at = fail; unsigned before = deletes;
assert(httpd_ssl_open(h, 42) == ESP_ERR_NO_MEM);
assert(live == baseline && !active->transport);
assert(deletes == before + (fail == 2));
}
fail_at = 0; handshake_failure = true;
unsigned before = deletes;
assert(httpd_ssl_open(h, 42) != ESP_OK && live == baseline);
assert(deletes == before + 1);
handshake_failure = false;
assert(httpd_ssl_open(h, 42) == ESP_OK && creates == 1);
void *retained = active->transport; unsigned retained_live = live;
stop_failure = true; before = deletes; wipes = wiped_frees;
assert(httpd_ssl_stop(h) != ESP_OK);
assert(active->transport == retained && live == retained_live);
assert(deletes == before && wiped_frees == wipes && closes == 0);
stop_failure = false;
assert(httpd_ssl_stop(h) == ESP_OK && live == 0 && closes == 1);
assert(deletes == before + 1 && wiped_frees == wipes + 2);
assert(httpd_ssl_stop(NULL) == ESP_ERR_INVALID_ARG);
cfg = config(); cfg.transport_mode = 0; start_failure = true;
assert(httpd_ssl_start(&h, &cfg) != ESP_OK && live == 0);
puts("HTTPS allocation/handshake/start/stop ownership and wipe matrix PASS");
}
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Read installed SDK sources; compile extracted patched functions with host doubles.
No dependency writes or network. --build-dir additionally verifies a real IDF
build's Ninja source registration; it does not run a firmware build.
"""
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import argparse
from dataclasses import replace
import hashlib
import importlib.util
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SPEC = importlib.util.spec_from_file_location("security_overrides", ROOT / "tools/security_overrides.py")
sdk = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = sdk
SPEC.loader.exec_module(sdk)
TLS_ENTRY = next(e for e in sdk.ENTRIES if e.name == "esp_tls_mbedtls")
def source_path(entry, idf, project=ROOT):
return {"idf": idf, "project": project}[entry.root] / entry.source
AUXILIARY = {
"components/esp_http_server/src/httpd_main.c": "a16ef65069dda13889c67b922f25eb566573983d6c24f01c089a902d5fd26149",
"components/esp_http_server/src/httpd_txrx.c": "7659ad52c32f29b9a08208dc8b22d023edf274047835ed58107d82a47ccce00e",
}
FEATURES = ["MBEDTLS_SSL_PROTO_TLS1_2", "MBEDTLS_SSL_SRV_C",
"MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED", "MBEDTLS_ECDH_C",
"MBEDTLS_ECDSA_C", "MBEDTLS_AES_C", "MBEDTLS_GCM_C",
"MBEDTLS_SHA256_C", "MBEDTLS_SHA384_C"]
def run(command, *, ok=True, cwd=None):
env = dict(os.environ, CCACHE_DISABLE="1", PYTHONDONTWRITEBYTECODE="1",
TMPDIR=str(ROOT / ".pio"))
result = subprocess.run([str(x) for x in command], cwd=cwd, env=env,
capture_output=True, text=True, timeout=60)
if (result.returncode == 0) != ok:
raise AssertionError(f"command: {command}\n{result.stdout}\n{result.stderr}")
return result.stdout + result.stderr
def extract(text, name):
matches = list(re.finditer(r"^[A-Za-z_][\w* \t]*\b" + re.escape(name) + r"\([^;]*?\)\s*\{", text, re.M))
assert len(matches) == 1, (name, len(matches))
start = matches[0].start()
brace = matches[0].end() - 1
depth = 0
tokens = re.finditer(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', text[brace:], re.S)
for token in tokens:
if token.group() == "{": depth += 1
elif token.group() == "}":
depth -= 1
if depth == 0: return text[start:brace + token.end()] + "\n"
raise AssertionError(name)
def typedef(text, name):
match = re.search(r"typedef struct " + name + r"(?:_t)? \{.*?\} " + name + r"_t;", text, re.S)
assert match, name
return match.group() + "\n"
def expect_error(function, phrase):
try:
function()
except (sdk.OverrideError, OSError) as error:
assert phrase in str(error), str(error)
else:
raise AssertionError("expected rejection: " + phrase)
def generator_tests(idf, work):
binary = work / "generated"
manifest = sdk.generate(idf, ROOT, binary)
before = {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in binary.rglob("*") if p.is_file()}
assert sdk.generate(idf, ROOT, binary) == manifest
assert before == {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in before}
for entry in sdk.ENTRIES:
original = source_path(entry, idf).read_bytes()
derived = (binary / "security_overrides" / entry.name / Path(entry.source).name).read_bytes()
assert derived.startswith(original[:original.index(b"*/") + 2])
assert derived != original
expect_error(lambda: sdk.apply_edits("x", (sdk.Edit("missing", "z"),)), "got 0")
expect_error(lambda: sdk.apply_edits("xx", (sdk.Edit("x", "z"),)), "got 2")
expect_error(lambda: sdk.generate(idf, ROOT, binary, ()), "absent")
expect_error(lambda: sdk.generate(idf, ROOT, binary, (sdk.ENTRIES[0],) * 2), "duplicate")
expect_error(lambda: sdk.generate(idf, ROOT, binary,
(sdk.ENTRIES[0], replace(sdk.ENTRIES[0], name="alias"))), "ambiguous")
expect_error(lambda: sdk.generate(idf, ROOT, idf / "forbidden"), "separate")
fake = work / "sdk"
version = Path("components/esp_common/include/esp_idf_version.h")
for rel in [version] + [Path(e.source) for e in sdk.ENTRIES if e.root == "idf"]:
target = fake / rel; target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(idf / rel, target)
last = fake / TLS_ENTRY.source
last.write_bytes(last.read_bytes() + b"\n/* changed dependency */\n")
failed = work / "failed"
expect_error(lambda: sdk.generate(fake, ROOT, failed), "SHA256 mismatch")
assert not failed.exists(), "must validate all inputs before output"
# A failed regeneration must not silently update even the first derived file.
expect_error(lambda: sdk.generate(fake, ROOT, binary), "SHA256 mismatch")
assert before == {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in before}
shutil.copyfile(idf / TLS_ENTRY.source, last)
(fake / version).write_text((fake / version).read_text().replace("VERSION_PATCH 0", "VERSION_PATCH 1"))
expect_error(lambda: sdk.generate(fake, ROOT, failed), "5.5.0")
shutil.copyfile(idf / version, fake / version)
last.unlink()
expect_error(lambda: sdk.generate(fake, ROOT, failed), "No such file")
escaped = work / "escaped_output"; escaped.mkdir()
(escaped / "security_overrides").symlink_to(fake, target_is_directory=True)
expect_error(lambda: sdk.generate(idf, ROOT, escaped), "output escapes")
print("Generator exact hashes/version/absent/ambiguous/atomic-plan/idempotence/path safety PASS")
return binary
def extracted_tests(idf, binary, work):
texts = {e.name: (binary / "security_overrides" / e.name / Path(e.source).name).read_text() for e in sdk.ENTRIES}
aux = {}
for rel, expected in AUXILIARY.items():
raw = (idf / rel).read_bytes()
assert hashlib.sha256(raw).hexdigest() == expected, rel
aux[Path(rel).name] = raw.decode()
https = texts["https_server"]
functions = typedef(https, "httpd_ssl_ctx") + typedef(https, "httpd_ssl_transport_ctx")
functions += extract(aux["httpd_main.c"], "httpd_stop")
for name in ("security_override_wipe", "httpd_ssl_close", "httpd_ssl_open",
"free_secure_context", "create_secure_context", "httpd_ssl_start", "httpd_ssl_stop"):
functions += extract(https, name)
source = (HERE / "https.c").read_text().replace("/* SDK_FUNCTIONS */", functions)
compile_run("https", source, work)
scratch = texts["httpd_parse"]
functions = "".join(extract(aux["httpd_txrx.c"], name) for name in ("httpd_recv_pending", "httpd_unrecv"))
functions += "".join(extract(scratch, name) for name in ("security_override_wipe", "security_override_resize_scratch", "read_block", "httpd_req_cleanup"))
compile_run("scratch", (HERE / "scratch.c").read_text().replace("/* SDK_FUNCTIONS */", functions), work)
tls = texts["esp_tls_mbedtls"]
original = (idf / TLS_ENTRY.source).read_text()
assert extract(tls, "set_client_config") == extract(original, "set_client_config")
assert extract(tls, "esp_create_mbedtls_handle") == extract(original, "esp_create_mbedtls_handle")
guards = tls[tls.index("/* The server profile"):tls.index('static const char *TAG = "esp-tls-mbedtls";')]
functions = extract(tls, "set_server_config") + extract(tls, "set_client_config")
source = (HERE / "tls.c").read_text().replace("/* SDK_FUNCTIONS */", functions)
source = source.replace("/* SDK_PKI */", typedef(tls, "esp_tls_pki")).replace("/* TLS_GUARDS */", guards)
defines = ["-D" + f for f in FEATURES]
compile_run("tls", source, work, defines + ["-DMBEDTLS_SSL_RENEGOTIATION", "-DCONFIG_MBEDTLS_SSL_RENEGOTIATION"])
compile_run("tls_no_renegotiation", source, work, defines)
# Compile actual injected guards independently of the behavioral doubles.
guard_file = work / "guards.c"; guard_file.write_text(guards)
for feature in FEATURES:
run(["cc", "-E", "-x", "c", *["-D" + f for f in FEATURES if f != feature], guard_file], ok=False)
run(["cc", "-E", "-x", "c", *defines, "-DCONFIG_MBEDTLS_DYNAMIC_BUFFER", guard_file], ok=False)
print("TLS feature guard matrix (each required feature + dynamic buffer rejection) PASS")
def compile_run(name, source, work, flags=()):
c = work / (name + ".c"); exe = work / name
c.write_text("/* Extracted SDK sections retain their upstream Apache-2.0 license. */\n" + source)
run(["cc", "-std=gnu11", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-Wno-unused-function", "-Wno-unused-variable", *flags, "-I", HERE, c, "-o", exe])
print(run([exe]).strip())
def cmake_fixture_tests(idf, work):
# Use real component inputs with mock IDF target discovery. No SDK compilation.
fixture = work / "cmake_fixture"; fixture.mkdir()
lines = ["cmake_minimum_required(VERSION 3.18)", "project(security_fixture C)",
f'set(TEST_IDF "{idf}")',
'function(idf_build_get_property out property)',
' set(${out} "${TEST_IDF}" PARENT_SCOPE)', 'endfunction()',
'function(idf_component_get_property out component property)',
' set(${out} "test_${component}" PARENT_SCOPE)', 'endfunction()']
for e in sdk.ENTRIES:
if e.root == "project":
copied = fixture / e.source
copied.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source_path(e, idf), copied)
lines += [f'add_library(test_{e.component} STATIC "{source_path(e, idf, fixture)}")']
lines += ['if(TEST_MISSING)', f'set_property(TARGET test_{sdk.ENTRIES[0].component} PROPERTY SOURCES missing.c)', 'endif()',
'if(TEST_AMBIGUOUS)', f'set_property(TARGET test_{sdk.ENTRIES[0].component} APPEND PROPERTY SOURCES "{source_path(sdk.ENTRIES[0], idf, fixture)}")', 'endif()',
'if(TEST_TARGET_MISSING)', 'function(idf_component_get_property out component property)',
'set(${out} nonexistent PARENT_SCOPE)', 'endfunction()', 'endif()']
for e in sdk.ENTRIES:
lines += [f'set_source_files_properties("{source_path(e, idf, fixture)}" PROPERTIES COMPILE_FLAGS "-DSOURCE_FLAG" COMPILE_DEFINITIONS "SOURCE_DEFINE" COMPILE_OPTIONS "-fno-common")']
lines += [f'include("{ROOT / "cmake/security_overrides.cmake"}")']
for e in sdk.ENTRIES:
lines += [f'file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/{e.name}.sources" CONTENT "$<TARGET_PROPERTY:test_{e.component},SOURCES>")',
f'get_property(flags SOURCE "${{SAK_SECURITY_{e.name}_GENERATED}}" PROPERTY COMPILE_FLAGS)',
'if(NOT flags STREQUAL "-DSOURCE_FLAG")', 'message(FATAL_ERROR "lost compile flags")', 'endif()',
f'get_property(inc SOURCE "${{SAK_SECURITY_{e.name}_GENERATED}}" PROPERTY INCLUDE_DIRECTORIES)',
f'if(NOT inc MATCHES "{source_path(e, idf, fixture).parent}")', 'message(FATAL_ERROR "lost original quoted include directory")', 'endif()']
(fixture / "CMakeLists.txt").write_text("\n".join(lines) + "\n")
build = work / "cmake_good"
run(["cmake", "-G", "Ninja", "-S", fixture, "-B", build])
for e in sdk.ENTRIES:
source = (build / (e.name + ".sources")).read_text()
assert source == str(build / "security_overrides" / e.name / Path(e.source).name)
ninja = (build / "build.ninja").read_text()
for path in [ROOT / "tools/security_overrides.py", idf / "components/esp_common/include/esp_idf_version.h"] + [source_path(e, idf, fixture) for e in sdk.ENTRIES]:
assert str(path) in next(line for line in ninja.splitlines() if ": RERUN_CMAKE" in line), path
for flag, phrase in (("TEST_MISSING", "found 0"), ("TEST_AMBIGUOUS", "found 2"), ("TEST_TARGET_MISSING", "missing component target")):
output = run(["cmake", "-G", "Ninja", "-S", fixture, "-B", work / flag, "-D" + flag + "=ON"], ok=False)
assert phrase in output, output
print("CMake actual include: exact target replacement/properties/reconfigure/fail-closed matrix PASS")
def extension_fixture_tests(idf, work):
# Prove the extension API, relative SOURCES in a child directory, real quoted
# includes, target/source flags, and automatic fail-closed reconfiguration.
fixture = work / "extension"
for directory in ("cmake", "tools", "component/src", "component/include"):
(fixture / directory).mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / "cmake/security_overrides.cmake", fixture / "cmake/security_overrides.cmake")
c = fixture / "component/src/example.c"
c.write_text('#include "local.h"\n#include "extra.h"\n'
'#if !defined(SOURCE_FLAG) || !defined(SOURCE_DEFINE) || !defined(SOURCE_OPTION) || !defined(TARGET_DEFINE)\n'
'#error "compile properties were lost"\n#endif\n'
'int example(void) { return LOCAL + EXTRA + 1; }\n')
original = c.read_bytes(); digest = hashlib.sha256(original).hexdigest()
(c.parent / "local.h").write_text("#define LOCAL 10\n")
(fixture / "component/include/extra.h").write_text("#define EXTRA 20\n")
(fixture / "component/CMakeLists.txt").write_text('add_library(test_extension STATIC src/example.c)\n'
'target_compile_definitions(test_extension PRIVATE TARGET_DEFINE)\n'
'set_source_files_properties(src/example.c PROPERTIES COMPILE_FLAGS "-DSOURCE_FLAG" '
'COMPILE_OPTIONS "-DSOURCE_OPTION" COMPILE_DEFINITIONS "SOURCE_DEFINE" '
'COMPILE_DEFINITIONS_DEBUG "CONFIG_DEFINE" INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/include")\n')
(fixture / "main.c").write_text('int example(void); int main(void) { return example() != 32; }\n')
wrapper = ('import sys\nfrom pathlib import Path\nsys.dont_write_bytecode = True\n'
f'sys.path.insert(0, {str(ROOT / "tools")!r})\nimport security_overrides as sdk\n'
'import argparse\np=argparse.ArgumentParser()\n'
'[p.add_argument(a, type=Path, required=True) for a in ("--idf-path", "--project-dir", "--binary-dir")]\n'
'a=p.parse_args()\n'
f'e=sdk.Entry("extension", "extension", "project", "component/src/example.c", {digest!r}, '
'(sdk.Edit("LOCAL + EXTRA + 1", "LOCAL + EXTRA + 2"),))\n'
'sdk.generate(a.idf_path, a.project_dir, a.binary_dir, (e,))\n')
(fixture / "tools/security_overrides.py").write_text(wrapper)
(fixture / "CMakeLists.txt").write_text('cmake_minimum_required(VERSION 3.18)\nproject(extension C)\n'
f'set(TEST_IDF "{idf}")\n'
'function(idf_build_get_property out property)\nset(${out} "${TEST_IDF}" PARENT_SCOPE)\nendfunction()\n'
'function(idf_component_get_property out component property)\nset(${out} "test_${component}" PARENT_SCOPE)\nendfunction()\n'
'add_subdirectory(component)\ninclude(cmake/security_overrides.cmake)\n'
'get_property(config_def SOURCE "${SAK_SECURITY_extension_GENERATED}" DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/component" PROPERTY COMPILE_DEFINITIONS_DEBUG)\n'
'if(NOT config_def STREQUAL "CONFIG_DEFINE")\nmessage(FATAL_ERROR "lost per-config source definitions")\nendif()\n'
'add_executable(check main.c)\ntarget_link_libraries(check PRIVATE test_extension)\n')
build = work / "extension_build"
run(["cmake", "-G", "Ninja", "-S", fixture, "-B", build])
run(["cmake", "--build", build]); run([build / "check"])
assert c.read_bytes() == original
generated = build / "security_overrides/extension/example.c"
stamp = generated.stat().st_mtime_ns
run(["cmake", "--build", build]); assert generated.stat().st_mtime_ns == stamp
c.write_bytes(original + b"\n/* upstream changed */\n")
output = run(["cmake", "--build", build], ok=False)
assert "SHA256 mismatch" in output, output
assert generated.stat().st_mtime_ns == stamp
print("Extension mapping + child relative source/includes/flags real compile + automatic mismatch rejection PASS")
def build_registration(build, idf):
ninja = (build / "build.ninja").read_text()
compile_lines = [line for line in ninja.splitlines() if ": C_COMPILER" in line]
for e in sdk.ENTRIES:
generated = build / "security_overrides" / e.name / Path(e.source).name
matches = [line for line in compile_lines if str(generated) in line]
assert len(matches) == 1, (e.name, matches)
assert not any(str(source_path(e, idf)) in line for line in compile_lines), e.name
assert generated.read_bytes() == sdk.render_entry(e, {"idf": idf, "project": ROOT})[1]
print("Real IDF Ninja registration: each generated source once, originals absent, bytes verified PASS")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, default=Path.home() / ".platformio/packages/framework-espidf")
parser.add_argument("--build-dir", type=Path)
args = parser.parse_args()
idf = args.idf_path.resolve()
sdk.verify_version(idf)
(ROOT / ".pio").mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="sdk-security-", dir=ROOT / ".pio") as tmp:
work = Path(tmp)
binary = generator_tests(idf, work)
extracted_tests(idf, binary, work)
cmake_fixture_tests(idf, work)
extension_fixture_tests(idf, work)
if args.build_dir: build_registration(args.build_dir.resolve(), idf)
print("SDK security overrides: all requested host checks PASS (not live TLS/hardware)")
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <sys/types.h>
#include "alloc.h"
#define MIN(a,b) ((a) < (b) ? (a) : (b))
#define HTTPD_SOCK_ERR_TIMEOUT -10
#define HTTPD_SOCK_ERR_FAIL -11
#define HTTPD_408_REQ_TIMEOUT 408
struct sock_db {
char pending_data[128]; size_t pending_len;
void *ctx; void (*free_ctx)(void *); bool ignore_sess_ctx_changes;
};
struct httpd_req_aux {
struct sock_db *sd;
char *scratch; size_t scratch_cur_size, scratch_size_limit, remaining_len;
};
typedef struct httpd_req {
struct httpd_req_aux *aux; void *sess_ctx, *handle, *user_ctx;
void (*free_ctx)(void *); bool ignore_sess_ctx_changes;
} httpd_req_t;
typedef struct { void *data; } http_parser;
typedef struct { struct { char *at; } last; } parser_data_t;
static int receive_result = 1, receive_calls;
static int httpd_req_handle_err(httpd_req_t *r, int err) { return ESP_FAIL; }
static void httpd_sess_free_ctx(void **ctx, void (*fn)(void *)) { assert(!*ctx); }
static int httpd_recv_with_opt(httpd_req_t *r, char *buf, size_t n, bool halt_after_pending);
/* SDK_FUNCTIONS */
static int httpd_recv_with_opt(httpd_req_t *r, char *buf, size_t n, bool halt_after_pending) {
++receive_calls;
assert(halt_after_pending);
if (r->aux->sd->pending_len) return (int)httpd_recv_pending(r, buf, n);
if (receive_result <= 0) return receive_result;
memset(buf, 'x', n); return (int)n;
}
static void cleanup(httpd_req_t *r, struct httpd_req_aux *ra, struct sock_db *sd) {
r->aux = ra; ra->sd = sd;
httpd_req_cleanup(r);
assert(!ra->scratch && !ra->scratch_cur_size && !r->aux && !live);
}
int main(void) {
all_secret = true;
struct sock_db sd = {0};
struct httpd_req_aux ra = {.sd=&sd, .scratch_size_limit=64, .remaining_len=37};
httpd_req_t r = {.aux=&ra};
parser_data_t data = {0}; http_parser parser = {.data=&data};
/* Equivalent pointer/size initialization to parse_init/init_req_aux:
* the first read must allocate its own scratch, with no parser position. */
assert(!data.last.at && !ra.scratch && !ra.scratch_cur_size);
unsigned initial_wipes = wiped_frees;
int initial_reads = receive_calls;
fail_at = calls + 1;
assert(read_block(&r, &parser, 0, 8) == 0);
assert(!data.last.at && !ra.scratch && !ra.scratch_cur_size && !live);
assert(receive_calls == initial_reads && wiped_frees == initial_wipes);
cleanup(&r, &ra, &sd);
assert(wiped_frees == initial_wipes);
fail_at = 0;
r.aux = &ra; ra.sd = &sd; ra.scratch_size_limit = 64;
assert(read_block(&r, &parser, 0, 8) == 8);
assert(ra.scratch && ra.scratch_cur_size == 8 && !data.last.at);
assert(receive_calls == initial_reads + 1 && wiped_frees == initial_wipes);
assert(!memcmp(ra.scratch, "xxxxxxxx", 8));
/* A fragmented request can need another read before the URL callback. */
char *initial = ra.scratch;
fail_at = calls + 1;
assert(read_block(&r, &parser, 8, 8) == 0);
assert(ra.scratch == initial && ra.scratch_cur_size == 8 && !data.last.at);
assert(receive_calls == initial_reads + 1 && wiped_frees == initial_wipes);
fail_at = 0;
assert(read_block(&r, &parser, 8, 8) == 8);
assert(ra.scratch != initial && ra.scratch_cur_size == 16 && !data.last.at);
assert(!memcmp(ra.scratch, "xxxxxxxxxxxxxxxx", 16));
assert(wiped_frees == initial_wipes + 1);
cleanup(&r, &ra, &sd);
assert(wiped_frees == initial_wipes + 2);
r.aux = &ra; ra.sd = &sd; ra.scratch_size_limit = 64;
assert(read_block(&r, &parser, 0, 16) == 16);
assert(!data.last.at);
memcpy(ra.scratch, "Cookie: secret!!", 16);
data.last.at = ra.scratch + 7;
char *old = ra.scratch; unsigned before = wiped_frees;
assert(read_block(&r, &parser, 16, 8) == 8);
assert(ra.scratch != old && ra.scratch_cur_size == 24);
assert(!memcmp(ra.scratch, "Cookie: secret!!", 16));
assert(data.last.at == ra.scratch + 7 && wiped_frees == before + 1);
old = ra.scratch; unsigned saved_calls = calls;
assert(security_override_resize_scratch(&ra, 24) && ra.scratch == old && calls == saved_calls);
fail_at = calls + 1;
int reads = receive_calls;
assert(read_block(&r, &parser, 24, 8) == 0);
assert(ra.scratch == old && ra.scratch_cur_size == 24 && receive_calls == reads);
assert(data.last.at == old + 7 && !memcmp(old, "Cookie: secret!!", 16));
assert(ra.remaining_len == 37);
fail_at = 0;
assert(httpd_unrecv(&r, "NEXT-REQUEST", 12) == 12);
assert(read_block(&r, &parser, 4, 4) == 4); /* actual shrink + pending RX */
assert(ra.scratch_cur_size == 8 && !memcmp(ra.scratch, "CookNEXT", 8));
assert(sd.pending_len == 8 && !memcmp(sd.pending_data + 120, "-REQUEST", 8));
assert(ra.remaining_len == 37);
char pending[128]; memcpy(pending, sd.pending_data, sizeof(pending));
cleanup(&r, &ra, &sd);
assert(sd.pending_len == 8 && !memcmp(pending, sd.pending_data, sizeof(pending)));
r.aux = &ra; ra.sd = &sd;
char out[12] = {0}; assert(httpd_recv_pending(&r, out, 8) == 8 && !memcmp(out, "-REQUEST", 8));
ra.scratch_size_limit = 64;
fail_at = calls + 1;
assert(!security_override_resize_scratch(&ra, 8) && !ra.scratch);
cleanup(&r, &ra, &sd); fail_at = 0;
for (int result = 0; result >= -2; --result) {
r.aux = &ra; ra.sd = &sd; ra.scratch_size_limit = 64;
assert(security_override_resize_scratch(&ra, 8)); data.last.at = ra.scratch;
receive_result = result == -2 ? HTTPD_SOCK_ERR_TIMEOUT : result;
assert(read_block(&r, &parser, 0, 8) == HTTPD_SOCK_ERR_FAIL);
int before_reads = receive_calls;
assert(read_block(&r, &parser, 64, 1) == 0 && before_reads == receive_calls);
cleanup(&r, &ra, &sd);
}
puts("HTTPD null-initial read/grow/shrink/failure/final wipe/bounds/pending-unread matrix PASS");
}
+105
View File
@@ -0,0 +1,105 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "alloc.h"
#define MBEDTLS_SSL_IS_SERVER 1
#define MBEDTLS_SSL_IS_CLIENT 0
#define MBEDTLS_SSL_TRANSPORT_STREAM 0
#define MBEDTLS_SSL_PRESET_DEFAULT 0
#define MBEDTLS_SSL_VERIFY_NONE 0
#define MBEDTLS_SSL_VERIFY_REQUIRED 2
#define MBEDTLS_SSL_VERSION_TLS1_2 0x303
#define MBEDTLS_SSL_RENEGOTIATION_DISABLED 0
#define MBEDTLS_SSL_RENEGOTIATION_ENABLED 1
#define MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 0xc02b
#define MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 0xc02c
#define ESP_ERR_MBEDTLS_SSL_CONFIG_DEFAULTS_FAILED -11
#define ESP_ERR_MBEDTLS_SSL_SET_HOSTNAME_FAILED -12
#define ESP_ERR_MBEDTLS_SSL_SETUP_FAILED -13
#define ESP_INT_EVENT_TRACKER_CAPTURE(...) ((void)0)
/* TLS_GUARDS */
typedef int mbedtls_x509_crt;
typedef int mbedtls_pk_context;
typedef struct {
const int *suites;
int endpoint, min, max, reneg, policy_calls;
void *userdata;
} mbedtls_ssl_config;
typedef struct {
mbedtls_ssl_config conf;
int ssl, servercert, serverkey, clientcert, clientkey, error_handle;
void *cacert_ptr;
} esp_tls_t;
typedef struct {
void *userdata; const char **alpn_protos;
bool use_secure_element, use_ecdsa_peripheral;
const unsigned char *cacert_buf, *servercert_buf, *serverkey_buf, *serverkey_password;
unsigned cacert_bytes, servercert_bytes, serverkey_bytes, serverkey_password_len;
} esp_tls_cfg_server_t;
typedef struct {
bool skip_common_name, use_global_ca_store, use_secure_element, use_ecdsa_peripheral;
const char *common_name; const char **alpn_protos;
void *crt_bundle_attach, *ds_data;
const unsigned char *cacert_buf, *clientcert_buf, *clientkey_buf, *clientkey_password;
const unsigned char *clientcert_pem_buf, *clientkey_pem_buf;
unsigned cacert_bytes, clientcert_bytes, clientkey_bytes, clientkey_password_len;
const int *ciphersuites_list;
} esp_tls_cfg_t;
/* SDK_PKI */
static int defaults_fail, pki_fail;
static const int default_suites[] = {123, 456, 0};
static int mbedtls_ssl_config_defaults(mbedtls_ssl_config *c, int endpoint, int transport, int preset) {
if (defaults_fail) return -1;
*c = (mbedtls_ssl_config){.suites=default_suites, .endpoint=endpoint,
.min=11, .max=22, .reneg=1};
return 0;
}
static void mbedtls_ssl_conf_ciphersuites(mbedtls_ssl_config *c, const int *list) { c->suites=list; ++c->policy_calls; }
static void mbedtls_ssl_conf_min_tls_version(mbedtls_ssl_config *c, int v) { c->min=v; }
static void mbedtls_ssl_conf_max_tls_version(mbedtls_ssl_config *c, int v) { c->max=v; }
static void mbedtls_ssl_conf_renegotiation(mbedtls_ssl_config *c, int v) { c->reneg=v; }
static void mbedtls_ssl_conf_set_user_data_p(mbedtls_ssl_config *c, void *p) { c->userdata=p; }
static void mbedtls_ssl_conf_authmode(mbedtls_ssl_config *c, int mode) {}
static void mbedtls_ssl_conf_ca_chain(mbedtls_ssl_config *c, void *p, void *q) {}
static int mbedtls_ssl_set_hostname(void *ssl, const char *host) { return 0; }
static void mbedtls_print_error_msg(int e) {}
static int set_ca_cert(esp_tls_t *tls, const unsigned char *cert, size_t n) { return 0; }
static int set_global_ca_store(esp_tls_t *tls) { return 0; }
static void check_policy(mbedtls_ssl_config *c) {
assert(c->endpoint == MBEDTLS_SSL_IS_SERVER && c->policy_calls == 1);
assert(c->suites[0] == 0xc02b && c->suites[1] == 0xc02c && c->suites[2] == 0);
assert(c->min == 0x303 && c->max == 0x303);
#ifdef MBEDTLS_SSL_RENEGOTIATION
assert(c->reneg == 0);
#endif
}
static int set_pki_context(esp_tls_t *tls, esp_tls_pki_t *pki) {
if (tls->conf.endpoint == MBEDTLS_SSL_IS_SERVER) check_policy(&tls->conf);
return pki_fail ? -1 : 0;
}
/* SDK_FUNCTIONS */
int main(void) {
static const unsigned char cert[] = {1}, key[] = {2};
esp_tls_cfg_server_t cfg = {.servercert_buf=cert, .serverkey_buf=key, .userdata=&cfg};
esp_tls_t server = {0}, second = {0}, client = {0};
defaults_fail = 1;
assert(set_server_config(&cfg, &server) == ESP_ERR_MBEDTLS_SSL_CONFIG_DEFAULTS_FAILED);
assert(!server.conf.policy_calls && !server.conf.suites);
defaults_fail = 0;
assert(set_server_config(&cfg, &server) == 0); check_policy(&server.conf);
assert(server.conf.userdata == &cfg);
assert(set_server_config(&cfg, &second) == 0); check_policy(&second.conf);
assert(server.conf.suites == second.conf.suites); /* retained static lifetime */
esp_tls_cfg_t ccfg = {.skip_common_name=true, .use_global_ca_store=true};
assert(set_client_config("host", 4, &ccfg, &client) == 0);
assert(client.conf.suites == default_suites && !client.conf.policy_calls);
assert(client.conf.min == 11 && client.conf.max == 22 && client.conf.reneg == 1);
static const int custom[] = {999, 0}; ccfg.ciphersuites_list = custom;
assert(set_client_config("host", 4, &ccfg, &client) == 0);
assert(client.conf.suites == custom && client.conf.policy_calls == 1);
check_policy(&server.conf);
pki_fail = 1;
assert(set_server_config(&cfg, &second) != 0); check_policy(&second.conf);
pki_fail = 0; cfg.serverkey_buf = NULL;
assert(set_server_config(&cfg, &second) == ESP_ERR_INVALID_STATE);
assert(!live);
puts("TLS server-only allowlist/version/renegotiation/config-failure/client isolation PASS");
}
+59
View File
@@ -0,0 +1,59 @@
# SSH memory hook tests
Run from the repository root:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_memory/run.py
```
Optionally verify the installed, audited SDK source contract too (no downloads):
```sh
CCACHE_DISABLE=1 python3 tests/ssh_memory/run.py --idf-path /home/mscholz/.platformio/packages/framework-espidf
```
The runner compiles the actual `src/ssh_memory.c` and extracts the actual volatile
`secure_wipe()` body from `src/secure_random.c`. Only SDK headers and heap calls
are doubled. Temporary build files stay outside the repository. `CC` and `CFLAGS`
are supported; the runner also forces `CCACHE_DISABLE=1` for child processes.
Coverage:
- NULL free, malloc/realloc NULL and zero-size delegation, secure zero-size free.
- Rounded usable capacity larger than the original request; whole-capacity wipe
checked **before** the fake heap actually frees the backing allocation.
- Equal-capacity and shrink pointer retention, discarded-tail wiping, unchanged
prefix, retained capacity and logical regrowth without allocation.
- Growth copies every byte of the old usable extent, including rounding, without
over-copying into the new suffix. Both allocations are live during growth.
- PSRAM-first/internal-fallback capability order on every allocation; successful
fallback and migration back to preferred PSRAM on a later growth.
- Failed allocation/growth leaves the old pointer and full contents live and
unchanged, with no SDK realloc fallback (none is supplied by the test).
- Base-pointer-only extent queries, live-pointer checks, aligned payloads and
prefix/suffix guards, request sizes unchanged including `SIZE_MAX`.
- Six rejected poisoning configurations, explicit-zero inactive options, and
four rejected IDF versions. The supported profile is unpoisoned IDF 5.5.0.
- Optional exact normalized function-body contracts for installed heap extent
queries/TLSF size accessor, public declaration and implementation alias; compile
the module with the installed IDF version header. This is a narrow source
contract check, not execution of the target SDK heap or a complete heap audit.
## Integration and limits
The parent must add `ssh_memory.c` to its build and register these three hooks
before wolfSSH/wolfSSL allocations begin. This change does not integrate them.
There are no production headers preceding allocations, metadata tables, locks or
additional tasks. Allocator alignment and allocation-size failure semantics are
preserved by passing the size straight to `heap_caps_malloc_prefer()`. A shrink
retains capacity rather than reclaiming heap; growth temporarily needs old plus
new allocations. PSRAM remains preferred, but internal fallback can transiently
need the full new allocation while the old one is still live. No runtime reserve
or hardware performance claim follows from these host tests.
Heap poisoning is deliberately unsupported: its canary layout is not compatible
with blindly wiping a rounded extent. The version guard requires re-audit on SDK
updates. Cleanup covers retired allocations and explicit realloc tails, not
still-live library buffers, parser spans, stack temporaries or all library
secrets. Hardware validation is deferred to whole-phase testing.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Build actual ssh_memory.c with a guarded heap double; no device/network work."""
import argparse
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
ENV = dict(os.environ, CCACHE_DISABLE="1")
def function(source, name):
match = re.search(r"\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", source)
if not match:
raise AssertionError(f"Missing function {name}")
start = source.index("{", match.start())
depth = 1
end = start + 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[match.start():end]
def normalized(text):
text = re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S)
return re.sub(r"\s+", "", text)
def sdk_contract(sdk):
heap = sdk / "components/heap"
contracts = [
("heap_caps.c", "heap_caps_get_allocated_size", """
heap_caps_get_allocated_size(void *ptr) {
ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(ptr);
heap_t *heap = find_containing_heap(ptr);
assert(heap);
size_t size = multi_heap_get_allocated_size(heap->heap, ptr);
return MULTI_HEAP_REMOVE_BLOCK_OWNER_SIZE(size);
}"""),
("multi_heap.c", "multi_heap_get_allocated_size_impl", """
multi_heap_get_allocated_size_impl(multi_heap_handle_t heap, void *p) {
return tlsf_block_size(p);
}"""),
("tlsf/tlsf.c", "tlsf_block_size", """
tlsf_block_size(void* ptr) {
size_t size = 0;
if (ptr) {
const block_header_t* block = block_from_ptr(ptr);
size = block_size(block);
}
return size;
}"""),
]
for path, name, expected in contracts:
actual = function((heap / path).read_text(), name)
assert normalized(actual) == normalized(expected), f"Reaudit {path}:{name}"
multi = normalized((heap / "multi_heap.c").read_text())
assert normalized('size_t multi_heap_get_allocated_size(multi_heap_handle_t heap, void *p) '
'__attribute__((alias("multi_heap_get_allocated_size_impl")));') in multi
header = (heap / "include/esp_heap_caps.h").read_text()
assert "size_t heap_caps_get_allocated_size(void *ptr);" in header
print("PASS installed SDK source contract: extent query, multi_heap alias, TLSF size accessor")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, help="also check installed IDF extent source and compile with its version header")
args = parser.parse_args()
cc = shlex.split(ENV.get("CC", "cc"))
with tempfile.TemporaryDirectory(prefix="ssh-memory-") as temporary:
directory = Path(temporary)
(directory / "esp_err.h").write_text("typedef int esp_err_t;\n")
(directory / "esp_heap_caps.h").write_text("""
#pragma once
#include <stddef.h>
#define MALLOC_CAP_SPIRAM (1U << 10)
#define MALLOC_CAP_INTERNAL (1U << 11)
#define MALLOC_CAP_8BIT (1U << 2)
void *heap_caps_malloc_prefer(size_t size, size_t count, ...);
size_t heap_caps_get_allocated_size(void *pointer);
void heap_caps_free(void *pointer);
""")
version = """
#define ESP_IDF_VERSION_VAL(a,b,c) (((a) << 16) | ((b) << 8) | (c))
#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(5,5,0)
"""
version_header = directory / "esp_idf_version.h"
version_header.write_text(version)
config = directory / "sdkconfig.h"
config.write_text("#define CONFIG_HEAP_POISONING_DISABLED 1\n")
# Use the real volatile wipe body, without pulling in unrelated DRBG/IDF.
wipe = function((ROOT / "src/secure_random.c").read_text(), "secure_wipe")
(directory / "wipe.c").write_text("#include <stddef.h>\n#include <stdint.h>\nvoid " + wipe + "\n")
common = cc + ["-std=c11", "-Wall", "-Wextra", "-Werror", "-pedantic",
*shlex.split(ENV.get("CFLAGS", "-O2")),
"-I", str(directory), "-I", str(ROOT / "src")]
source = str(ROOT / "src/ssh_memory.c")
binary = directory / "test"
subprocess.run(common + [source, str(ROOT / "tests/ssh_memory/test.c"),
str(directory / "wipe.c"), "-o", str(binary)], env=ENV, check=True)
subprocess.run([str(binary)], env=ENV, check=True)
def compile_only(expected_error=None):
result = subprocess.run(common + [source, "-c", "-o", str(directory / "memory.o")],
env=ENV, text=True, capture_output=True)
if expected_error is None:
assert result.returncode == 0, result.stderr
else:
assert result.returncode != 0 and expected_error in result.stderr, result.stderr
for flags in ("", "#define CONFIG_HEAP_POISONING_DISABLED 0\n",
"#define CONFIG_HEAP_POISONING_LIGHT 1\n",
"#define CONFIG_HEAP_POISONING_COMPREHENSIVE 1\n",
"#define CONFIG_HEAP_POISONING_DISABLED 1\n#define CONFIG_HEAP_POISONING_LIGHT 1\n",
"#define CONFIG_HEAP_POISONING_DISABLED 1\n#define CONFIG_HEAP_POISONING_COMPREHENSIVE 1\n"):
config.write_text(flags)
compile_only("SSH memory requires heap poisoning disabled")
config.write_text("#define CONFIG_HEAP_POISONING_DISABLED 1\n"
"#define CONFIG_HEAP_POISONING_LIGHT 0\n"
"#define CONFIG_HEAP_POISONING_COMPREHENSIVE 0\n")
compile_only()
for unsupported in ("5,4,0", "5,5,1", "5,6,0", "6,0,0"):
version_header.write_text(version.replace("5,5,0", unsupported))
compile_only("Reaudit SSH memory usable extent contract for this IDF")
version_header.write_text(version)
print("PASS compile guards: 6 invalid poisoning profiles, explicit disabled profile, 4 unsupported IDF versions")
if args.idf_path:
sdk_contract(args.idf_path)
version_header.write_text((args.idf_path / "components/esp_common/include/esp_idf_version.h").read_text())
compile_only()
print("PASS actual module compile with installed IDF version header")
else:
print("SKIP installed SDK source contract (provide --idf-path)")
if __name__ == "__main__":
main()
+214
View File
@@ -0,0 +1,214 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_memory.h"
#include "esp_heap_caps.h"
#include <assert.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* All payloads have guarded rounded capacity and normal malloc alignment. */
#define ALIGNMENT _Alignof(max_align_t)
#define GUARD (2U * sizeof(max_align_t))
#define BLOCKS 8U
#define LIMIT 4096U
typedef struct {
unsigned char *raw;
unsigned char *base;
size_t capacity;
bool internal;
} block_t;
static block_t blocks[BLOCKS];
static bool fail_psram;
static bool fail_internal;
static unsigned allocations, releases, queries, psram_attempts, internal_attempts;
static unsigned live, peak_live;
static size_t last_request;
static block_t *lookup(void *pointer)
{
assert(pointer != NULL);
for (size_t i = 0; i < BLOCKS; ++i) {
if (blocks[i].base == pointer) return &blocks[i];
}
assert(!"not a live allocation base");
abort();
}
static void bytes_are(const unsigned char *p, size_t size, unsigned char value)
{
for (size_t i = 0; i < size; ++i) assert(p[i] == value);
}
static void guards(const block_t *block)
{
bytes_are(block->raw, GUARD, 0xD3);
bytes_are(block->base + block->capacity, GUARD, 0xD3);
}
void *heap_caps_malloc_prefer(size_t size, size_t count, ...)
{
va_list args;
va_start(args, count);
assert(count == 2U);
assert(va_arg(args, unsigned int) == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
assert(va_arg(args, unsigned int) == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
va_end(args);
++allocations;
last_request = size;
/* The module must pass size unchanged, including zero and SIZE_MAX. */
if (size == 0U) return NULL;
++psram_attempts;
bool internal = fail_psram || size > LIMIT;
if (internal) {
++internal_attempts;
if (fail_internal || size > LIMIT) return NULL;
}
size_t capacity = ((size + ALIGNMENT - 1U) / ALIGNMENT) * ALIGNMENT;
for (size_t i = 0; i < BLOCKS; ++i) {
block_t *block = &blocks[i];
if (block->base != NULL) continue;
block->raw = malloc(GUARD + capacity + GUARD);
assert(block->raw != NULL);
block->base = block->raw + GUARD;
block->capacity = capacity;
block->internal = internal;
memset(block->raw, 0xD3, GUARD + capacity + GUARD);
memset(block->base, 0xA5, capacity);
assert((uintptr_t)block->base % ALIGNMENT == 0U);
++live;
if (live > peak_live) peak_live = live;
return block->base;
}
assert(!"fake heap exhausted");
return NULL;
}
size_t heap_caps_get_allocated_size(void *pointer)
{
++queries;
block_t *block = lookup(pointer);
guards(block);
return block->capacity;
}
void heap_caps_free(void *pointer)
{
block_t *block = lookup(pointer);
guards(block);
/* Inspect BEFORE real free: no reads through dangling pointers. */
bytes_are(block->base, block->capacity, 0);
free(block->raw);
memset(block, 0, sizeof(*block));
++releases;
--live;
}
static void test_null_zero(void)
{
unsigned before = queries;
ssh_memory_free(NULL);
assert(queries == before && releases == 0U);
assert(ssh_memory_malloc(0) == NULL && last_request == 0U);
unsigned calls = allocations;
assert(ssh_memory_realloc(NULL, 0) == NULL);
assert(allocations == calls + 1U && queries == before);
void *p = ssh_memory_realloc(NULL, 7);
assert(p != NULL && last_request == 7U);
assert(ssh_memory_realloc(p, 0) == NULL && live == 0U);
}
static void test_retained_capacity(void)
{
unsigned char *p = ssh_memory_malloc(17);
block_t *block = lookup(p);
size_t capacity = block->capacity;
assert(capacity > 17U);
memset(p, 0x71, capacity);
unsigned calls = allocations;
assert(ssh_memory_realloc(p, capacity) == p);
bytes_are(p, capacity, 0x71);
assert(ssh_memory_realloc(p, 17) == p);
bytes_are(p, 17, 0x71);
bytes_are(p + 17, capacity - 17, 0);
assert(ssh_memory_realloc(p, 5) == p);
bytes_are(p, 5, 0x71);
bytes_are(p + 5, capacity - 5, 0);
/* Logical regrowth within retained capacity allocates nothing. */
assert(ssh_memory_realloc(p, capacity - 1U) == p);
bytes_are(p, 5, 0x71);
bytes_are(p + 5, capacity - 5, 0);
assert(block->capacity == capacity && allocations == calls);
guards(block);
ssh_memory_free(p);
}
static void test_growth_and_failure(void)
{
unsigned char *p = ssh_memory_malloc(17);
size_t capacity = lookup(p)->capacity;
for (size_t i = 0; i < capacity; ++i) p[i] = (unsigned char)(i + 1U);
fail_psram = fail_internal = true;
unsigned freed = releases;
assert(ssh_memory_realloc(p, capacity + 1U) == NULL);
assert(releases == freed && live == 1U);
for (size_t i = 0; i < capacity; ++i) assert(p[i] == (unsigned char)(i + 1U));
guards(lookup(p));
fail_internal = false;
unsigned char *q = ssh_memory_realloc(p, capacity + 1U);
assert(q != NULL && lookup(q)->internal);
assert(last_request == capacity + 1U && releases == freed + 1U);
assert(live == 1U && peak_live == 2U);
for (size_t i = 0; i < capacity; ++i) assert(q[i] == (unsigned char)(i + 1U));
/* The new suffix isn't promised zero; ensure no over-copy either. */
bytes_are(q + capacity, lookup(q)->capacity - capacity, 0xA5);
fail_psram = false;
size_t old_capacity = lookup(q)->capacity;
memset(q, 0x69, old_capacity);
unsigned char *r = ssh_memory_realloc(q, old_capacity + 19U);
assert(r != NULL && !lookup(r)->internal);
bytes_are(r, old_capacity, 0x69);
ssh_memory_free(r);
}
static void test_sizes_alignment_and_preference(void)
{
for (size_t size = 1; size <= 129; ++size) {
fail_psram = (size % 2U) != 0U;
unsigned external_before = psram_attempts;
unsigned internal_before = internal_attempts;
void *p = ssh_memory_malloc(size);
assert(last_request == size && lookup(p)->internal == fail_psram);
assert(psram_attempts == external_before + 1U);
assert(internal_attempts == internal_before + (fail_psram ? 1U : 0U));
assert((uintptr_t)p % ALIGNMENT == 0U);
ssh_memory_free(p);
}
fail_psram = fail_internal = true;
assert(ssh_memory_malloc(33) == NULL);
fail_psram = fail_internal = false;
assert(ssh_memory_malloc(SIZE_MAX) == NULL && last_request == SIZE_MAX);
unsigned char *p = ssh_memory_malloc(9);
size_t capacity = lookup(p)->capacity;
memset(p, 0x81, capacity);
assert(ssh_memory_realloc(p, SIZE_MAX) == NULL && last_request == SIZE_MAX);
bytes_are(p, capacity, 0x81);
guards(lookup(p));
ssh_memory_free(p);
}
int main(void)
{
test_null_zero();
test_retained_capacity();
test_growth_and_failure();
test_sizes_alignment_and_preference();
assert(live == 0U);
puts("PASS ssh_memory: null/zero, rounded extent, retained shrink/equal, growth, failure, caps, alignment, guards");
return 0;
}
+141
View File
@@ -0,0 +1,141 @@
# SSH protocol policy contracts
Run from the project root:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py
```
The runner requires the installed, exactly pinned wolfSSH 1.4.20 source, the
production compilation database, its target compiler, Python 3, and a host C99
compiler (`CC`, default `cc`). It never downloads dependencies or invokes a
firmware build. All host headers, extracted functions, and binaries are created
in a temporary directory and removed afterward. Every compiler/preprocessor
invocation is limited to 30 seconds; each host executable to 10 seconds.
`CCACHE_DISABLE=1` is also forced for subprocesses.
If multiple build environments exist, select the intended profile explicitly:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py \
--compile-commands .pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json
```
There is deliberately no host-only mode that could silently skip resolved
production-feature verification. The database must select exactly one generated
`security_overrides/wolfssh_internal/internal.c`, whose bytes must equal the
in-memory `tools/security_overrides.py` `render_entry()` output. Original vendor
compilation, missing/duplicate entries, and stale generated content fail closed
with a reconfiguration diagnostic. The runner does not regenerate anything.
The helper is syntax-checked against real target headers using that generated
translation unit's compile settings.
## Production contract and integration
`src/ssh_protocol_policy.{c,h}` exports:
```c
int ssh_protocol_policy_apply(WOLFSSH_CTX *context);
```
It applies permanent, borrowed strings for these five context settings:
| Setter suffix | Exact value |
|---|---|
| `Kex` | `curve25519-sha256,ecdh-sha2-nistp256` |
| `Key` | `ecdsa-sha2-nistp256` |
| `Cipher` | `aes128-gcm@openssh.com,aes256-gcm@openssh.com` |
| `Mac` | `hmac-sha2-256` |
| `KeyAccepted` | `ssh-ed25519,ecdsa-sha2-nistp256` |
The helper returns `WS_SSH_CTX_NULL_E` for NULL and otherwise returns the first
non-success setter result, without subsequent calls or fallback. It does not
allocate, free, or publish a context. A failure can leave earlier settings
applied: **the caller must discard the candidate, not use it**.
The parent integrated the helper in `create_context()` after host-key import and
full staging-buffer wipe, before callback registration and `s_context`
publication. Any policy failure frees the unpublished candidate and returns
`ESP_FAIL`. `context.c` now executes the actual extracted function with the real
policy helper to test this boundary. Service startup isolation, owner/task
lifecycle, session creation, and complete restart paths remain outside this
focused harness.
The exact-version guard rejects unreviewed wolfSSH versions. The source/config
checks below independently verify the actual feature profile; setter success
alone does not validate an algorithm list. `KeyAccepted` controls only the
`server-sig-algs` advertisement in this vendor version. User-key enrollment and
authorization remain enforced by the existing database/authentication path.
## Evidence provided
- Pins the SHA-256 of installed `src/internal.c` and `src/ssh.c`, the application
manifest's exact wolfSSH version, and the resolved compiler version macro.
Source changes require re-audit, not blind hash refresh. Independently checks
the registered override's original-source hash, renders it in memory, and
requires exact equality with the actual generated compiler input. Requires
all original algorithm tables/default strings and extracted protocol-function
bodies to remain unchanged by the override. Negative database cases reject
original/missing/duplicate entries and mismatched render output.
- Replays the actual generated vendor compile command without output/dependency-writing
flags to resolve feature macros, the name/ID/type map, and conditional enums.
All seven distinct policy algorithm names must have their expected compiled
IDs and categories. Required RNG/software-crypto and Ed25519 streaming settings
must remain present; policy-disabling macros are rejected. Negative map cases
demonstrate that missing algorithms fail the checker.
- Compiles the real helper against injected setter doubles. Tests NULL without
dispatch, all five exact lists in order, negative and positive non-success
return propagation at every step, no later calls/fallback, preservation of
unapplied fields, context sentinel survival, and retained string pointers.
These remain helper-boundary checks; the separate context harness below tests
actual caller cleanup and publication.
- Executes actual extracted `src/ssh_transport.c` `create_context()` together
with the real policy helper: 15 cases covering identity-copy failure,
context-allocation failure, both positive/negative key-import failures,
positive/negative setter failures at all five steps, and success. Checks error
mapping, no later policy calls/callbacks/publication on failure, exactly one
free for allocated failed candidates, no free on success, and publication only
after all eight callback registrations. Marks the entire synthetic identity
staging buffer, including the unused tail; asserts full-capacity wipe before
policy initialization, callbacks, and candidate destruction. Context creation
and key import necessarily precede that wipe. Buffer checks occur only while
the extracted function's stack frame is live, never after return.
- Executes the five actual vendor context setter bodies, confirming null-context
errors and their acceptance of invalid, empty, and NULL lists. The helper then
overwrites all five with the fixed policy.
- Executes actual vendor `NameToId`, `IdToName`, `AlgoListSz`, `CopyNameList`,
`CopyNameListPlus`, `BuildNameList`, `SendKexInit`, and `SendExtInfo` bodies.
The mapping and enum values come from production preprocessing. The five actual
`SshInit` list-pointer assignments are checked and reused in the reduced layout.
- Independently decodes initial and repeated/rekey KEXINIT plaintext payloads:
exact KEX/host-key lists, both cipher directions, both MAC directions,
compression/language lists, first-packet flag, reserved field, total bounds,
canaries, and saved exchange-hash input. Exact equality excludes CBC, CTR,
AES192, extra KEX/MAC entries, or an appended default fallback.
- Decodes the actual `server-sig-algs` extension with exactly Ed25519/P-256.
- Exercises missing-host-key, packet-preparation, saved-payload allocation, and
WANT_WRITE behavior with bounded doubles. Checks no send on early failures
and preservation of the exact payload on WANT_WRITE.
- Compiles negative older/newer version cases against the production guard.
## Limits and deferred validation
Host context/session layouts are reduced doubles, not vendor ABI replicas.
The context harness doubles identity copying, key import, context allocation/free,
callback registration, and the wipe primitive. It verifies production call order,
wipe extent, cleanup, and publication, not vendor destruction or secure-wipe
machine code. Synthetic identity bytes are not actual private-key material.
Handshake allocation, packet reservation/wrapping/purging, deterministic cookie
RNG, big-endian integer writing, and send are doubles; payload encoders and list
setters are extracted vendor code. The fixed packet/storage buffers are 1024
bytes. No private key, signature, KEX arithmetic, encryption, MAC, complete SSH
packet framing, peer negotiation, socket, device, or scheduling behavior is
executed. Repeated KEXINIT tests serialization on rekey, not an entire rekey
exchange. The extension test does not establish user-key enforcement.
Hardware/live-client acceptance remains deferred to combined Phase 9: both key
types, explicit rejection of excluded algorithms, real rekey, and mixed
transport responsiveness. No negotiated-handshake or target pass is implied.
This work does not change TLS policy, dependencies, vendor files, generated
assets, global crypto primitives/settings, NVS encryption, or eFuses.
+92
View File
@@ -0,0 +1,92 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ssh_protocol_policy.h"
static const char *const expected[] = {
"curve25519-sha256,ecdh-sha2-nistp256",
"ecdsa-sha2-nistp256",
"aes128-gcm@openssh.com,aes256-gcm@openssh.com",
"hmac-sha2-256",
"ssh-ed25519,ecdsa-sha2-nistp256",
};
static unsigned calls, fail_at;
static int failure;
static WOLFSSH_CTX *candidate;
static int set(WOLFSSH_CTX *ctx, const char *list, const char **field,
unsigned step)
{
assert(ctx == candidate);
assert(ctx->sentinel == 0x12345678U);
assert(++calls == step);
assert(strcmp(list, expected[step - 1]) == 0);
if (step == fail_at) return failure;
*field = list;
return WS_SUCCESS;
}
#define SETTER(name, field, step) \
int wolfSSH_CTX_SetAlgoList##name(WOLFSSH_CTX *c, const char *s) \
{ return set(c, s, &c->field, step); }
SETTER(Kex, algoListKex, 1)
SETTER(Key, algoListKey, 2)
SETTER(Cipher, algoListCipher, 3)
SETTER(Mac, algoListMac, 4)
SETTER(KeyAccepted, algoListKeyAccepted, 5)
static const char *get(const WOLFSSH_CTX *c, unsigned index)
{
switch (index) {
case 0: return c->algoListKex;
case 1: return c->algoListKey;
case 2: return c->algoListCipher;
case 3: return c->algoListMac;
default: return c->algoListKeyAccepted;
}
}
int main(void)
{
assert(ssh_protocol_policy_apply(NULL) == WS_SSH_CTX_NULL_E);
assert(calls == 0);
/* This is an unpublished stack-owned candidate. Any attempted vendor free
* has no test definition and fails linking; transport publication is outside
* this helper and is covered by the parent's integration tests. */
static const char original[] = "old-default";
for (unsigned step = 1; step <= 5; ++step) {
for (unsigned sign = 0; sign < 2; ++sign) {
WOLFSSH_CTX ctx = {
.algoListKex = original, .algoListKey = original,
.algoListCipher = original, .algoListMac = original,
.algoListKeyAccepted = original, .sentinel = 0x12345678U,
};
candidate = &ctx;
calls = 0;
fail_at = step;
failure = sign ? (int)(9000 + step) : -(int)(9000 + step);
assert(ssh_protocol_policy_apply(&ctx) == failure);
assert(calls == step);
assert(ctx.sentinel == 0x12345678U);
for (unsigned i = 0; i < 5; ++i) {
if (i + 1 < step) assert(strcmp(get(&ctx, i), expected[i]) == 0);
else assert(get(&ctx, i) == original);
}
}
}
const char *retained[5];
for (unsigned pass = 0; pass < 2; ++pass) {
WOLFSSH_CTX ctx = {.sentinel = 0x12345678U};
candidate = &ctx;
calls = fail_at = 0;
assert(ssh_protocol_policy_apply(&ctx) == WS_SUCCESS);
assert(calls == 5);
for (unsigned i = 0; i < 5; ++i) {
assert(strcmp(get(&ctx, i), expected[i]) == 0);
if (pass == 0) retained[i] = get(&ctx, i);
else assert(retained[i] == get(&ctx, i));
}
}
for (unsigned i = 0; i < 5; ++i) assert(strcmp(retained[i], expected[i]) == 0);
puts("PASS: apply NULL, five exact lists, every setter failure (+/-), stop/no fallback, retained strings");
return 0;
}
+182
View File
@@ -0,0 +1,182 @@
/* Actual create_context() + actual policy; identity/vendor/callback doubles. */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ssh_protocol_policy.h"
#include "context_constants.h"
typedef int esp_err_t;
enum { ESP_OK = 0, ESP_FAIL = -1, ESP_ERR_NO_MEM = 0x101 };
static WOLFSSH_CTX candidate, *s_context;
static unsigned copies, news, imports, wipes, setters, callbacks, frees;
static unsigned fail_setter;
static int copy_error, allocation_fail, import_error, setter_error;
static unsigned char *identity;
static size_t identity_capacity;
static void unpublished(void)
{
assert(s_context == NULL);
}
static void wiped(void)
{
assert(wipes == 1);
assert(identity_capacity == SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY);
/* Only called while the extracted create_context stack frame is alive. */
for (size_t i = 0; i < identity_capacity; ++i) assert(identity[i] == 0);
}
static esp_err_t ssh_security_copy_private_key(unsigned char *out, size_t capacity,
size_t *length)
{
unpublished();
assert(++copies == 1);
assert(capacity == SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY);
for (size_t i = 0; i < capacity; ++i) assert(out[i] == 0);
identity = out;
identity_capacity = capacity;
/* Mark the unused tail too, so a short wipe cannot pass this test. */
memset(out, 0x6d, capacity);
*length = 31;
return copy_error;
}
static void secure_wipe(void *ptr, size_t length)
{
unpublished();
assert(ptr == identity && length == identity_capacity);
assert(wipes++ == 0);
memset(ptr, 0, length);
}
static WOLFSSH_CTX *wolfSSH_CTX_new(int endpoint, void *heap)
{
unpublished();
assert(copies == 1 && wipes == 0 && imports == 0);
assert(endpoint == WOLFSSH_ENDPOINT_SERVER && heap == NULL);
assert(++news == 1);
return allocation_fail ? NULL : &candidate;
}
static int wolfSSH_CTX_UsePrivateKey_buffer(WOLFSSH_CTX *ctx,
const unsigned char *key, word32 length, int format)
{
unpublished();
assert(ctx == &candidate && news == 1 && wipes == 0);
assert(++imports == 1);
assert(key == identity && length == 31 && format == WOLFSSH_FORMAT_ASN1);
for (size_t i = 0; i < identity_capacity; ++i) assert(key[i] == 0x6d);
return import_error;
}
static void wolfSSH_CTX_free(WOLFSSH_CTX *ctx)
{
unpublished();
wiped();
assert(ctx == &candidate && news == 1 && callbacks == 0);
assert(++frees == 1);
}
static int set_list(WOLFSSH_CTX *ctx, const char *list, const char **field,
unsigned step)
{
unpublished();
wiped();
assert(imports == 1 && import_error == 0 && frees == 0 && callbacks == 0);
assert(ctx == &candidate && ++setters == step);
assert(list != NULL && list[0] != '\0');
if (step == fail_setter) return setter_error;
*field = list;
return WS_SUCCESS;
}
#define SETTER(name, field, step) \
int wolfSSH_CTX_SetAlgoList##name(WOLFSSH_CTX *ctx, const char *list) \
{ return set_list(ctx, list, &ctx->field, step); }
SETTER(Kex, algoListKex, 1)
SETTER(Key, algoListKey, 2)
SETTER(Cipher, algoListCipher, 3)
SETTER(Mac, algoListMac, 4)
SETTER(KeyAccepted, algoListKeyAccepted, 5)
static void bounded_ssh_receive(void) {}
static void authenticate_user(void) {}
static void allowed_auth_types(void) {}
static void authentication_result(void) {}
static void reject_keyboard_auth(void) {}
static void accept_shell(void) {}
static void reject_channel_request(void) {}
static int callback(WOLFSSH_CTX *ctx, void (*actual)(void),
void (*expected)(void), unsigned step)
{
unpublished();
wiped();
assert(ctx == &candidate && setters == 5 && fail_setter == 0 && frees == 0);
assert(++callbacks == step && actual == expected);
return WS_SUCCESS;
}
#define CALLBACK(name, expected, step) \
static int name(WOLFSSH_CTX *ctx, void (*cb)(void)) \
{ return callback(ctx, cb, expected, step); }
CALLBACK(wolfSSH_SetIORecv, bounded_ssh_receive, 1)
CALLBACK(wolfSSH_SetUserAuth, authenticate_user, 2)
CALLBACK(wolfSSH_SetUserAuthTypes, allowed_auth_types, 3)
CALLBACK(wolfSSH_SetUserAuthResult, authentication_result, 4)
CALLBACK(wolfSSH_SetKeyboardAuthPrompts, reject_keyboard_auth, 5)
CALLBACK(wolfSSH_CTX_SetChannelReqShellCb, accept_shell, 6)
CALLBACK(wolfSSH_CTX_SetChannelReqExecCb, reject_channel_request, 7)
CALLBACK(wolfSSH_CTX_SetChannelReqSubsysCb, reject_channel_request, 8)
#include "context_actual.c"
static void reset(void)
{
memset(&candidate, 0, sizeof(candidate));
s_context = NULL;
copies = news = imports = wipes = setters = callbacks = frees = 0;
fail_setter = 0;
copy_error = allocation_fail = import_error = setter_error = 0;
identity = NULL;
identity_capacity = 0;
}
static void failed(int expected)
{
assert(create_context() == expected);
assert(s_context == NULL && callbacks == 0 && wipes == 1 && copies == 1);
/* identity points at a retired stack frame now: never inspect it here. */
identity = NULL;
}
int main(void)
{
unsigned cases = 0;
reset();
copy_error = 0x4321;
failed(copy_error);
assert(news == 0 && imports == 0 && setters == 0 && frees == 0);
++cases;
reset();
allocation_fail = 1;
failed(ESP_ERR_NO_MEM);
assert(news == 1 && imports == 0 && setters == 0 && frees == 0);
++cases;
for (unsigned sign = 0; sign < 2; ++sign) {
reset();
import_error = sign ? 7001 : -7001;
failed(ESP_FAIL);
assert(news == 1 && imports == 1 && setters == 0 && frees == 1);
++cases;
}
for (unsigned step = 1; step <= 5; ++step) {
for (unsigned sign = 0; sign < 2; ++sign) {
reset();
fail_setter = step;
setter_error = sign ? (int)(8000 + step) : -(int)(8000 + step);
failed(ESP_FAIL);
assert(news == 1 && imports == 1 && setters == step && frees == 1);
++cases;
}
}
reset();
assert(create_context() == ESP_OK);
identity = NULL;
assert(s_context == &candidate);
assert(copies == 1 && news == 1 && imports == 1 && wipes == 1);
assert(setters == 5 && callbacks == 8 && frees == 0);
++cases;
assert(cases == 15);
puts("PASS: actual create_context + policy: 15 cases, full identity wipe before policy/callbacks/free, exact cleanup, publication only after success");
return 0;
}
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Bounded, offline policy/vendor contracts using the production compile profile."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
VENDOR = ROOT / "managed_components/wolfssl__wolfssh"
ENV = {**os.environ, "CCACHE_DISABLE": "1"}
sys.dont_write_bytecode = True
sys.path.insert(0, str(ROOT / "tools"))
from security_overrides import ENTRIES, render_entry
HASHES = {
"internal.c": "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9",
"ssh.c": "a4f479ff87eea0980ec1ebdf2c7dd090da473780181b695a56799cb9611f4366",
}
FIELDS = ("Kex", "Key", "Cipher", "Mac", "KeyAccepted")
REQUIRED = {
"curve25519-sha256": ("ID_CURVE25519_SHA256", "TYPE_KEX"),
"ecdh-sha2-nistp256": ("ID_ECDH_SHA2_NISTP256", "TYPE_KEX"),
"ecdsa-sha2-nistp256": ("ID_ECDSA_SHA2_NISTP256", "TYPE_KEY"),
"aes128-gcm@openssh.com": ("ID_AES128_GCM", "TYPE_CIPHER"),
"aes256-gcm@openssh.com": ("ID_AES256_GCM", "TYPE_CIPHER"),
"hmac-sha2-256": ("ID_HMAC_SHA2_256", "TYPE_MAC"),
"ssh-ed25519": ("ID_ED25519", "TYPE_KEY"),
}
def run(args, **kwargs):
return subprocess.run(args, env=ENV, timeout=30, check=True, **kwargs)
def extract(source, name):
# Mask comments/strings without changing offsets; match definitions only.
masked = re.sub(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'',
lambda m: " " * len(m[0]), source, flags=re.S)
pattern = (r"(?m)^(?:static )?(?:INLINE )?(?:const )?"
r"(?:int|void|byte|word32|char|esp_err_t)\s*\*?\s*" + re.escape(name) +
r"\s*\([^;{}]*\)\s*\{")
matches = list(re.finditer(pattern, masked))
if len(matches) != 1:
raise RuntimeError(f"Expected one definition of {name}, got {len(matches)}")
start = matches[0].start()
end = masked.index("{", start) + 1
depth = 1
while depth:
depth += (masked[end] == "{") - (masked[end] == "}")
end += 1
return source[start:end] + "\n"
def source_path(entry):
return (Path(entry["directory"]) / entry["file"]).resolve()
def compiler_command(database, override, expected):
entries = json.loads(database.read_text())
original = (VENDOR / "src/internal.c").resolve()
if any(source_path(e) == original for e in entries):
raise RuntimeError("Production still compiles original internal.c; reconfigure the build")
suffix = ("security_overrides", override.name, "internal.c")
matches = [e for e in entries if source_path(e).parts[-3:] == suffix]
if len(matches) != 1:
raise RuntimeError(f"Expected one generated wolfSSH compile entry, found {len(matches)}")
entry = matches[0]
actual = source_path(entry).read_bytes()
if actual != expected:
raise RuntimeError("Generated wolfSSH source differs from render_entry; reconfigure the build")
args = entry.get("arguments") or shlex.split(entry["command"])
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)
return entry, clean
def check_profile(macros, mapping):
if macros.get("LIBWOLFSSH_VERSION_HEX") != "0x01004020":
raise RuntimeError("Expected reviewed wolfSSH 1.4.20 compiler profile")
required = ("WC_RNG_SEED_CB", "NO_WOLFSSL_ESP32_CRYPT_AES",
"NO_WOLFSSL_ESP32_CRYPT_HASH", "WOLFSSL_ED25519_STREAMING_VERIFY",
"HAVE_CURVE25519", "HAVE_ECC", "HAVE_ED25519", "HAVE_AESGCM")
for name in required:
if name not in macros:
raise RuntimeError(f"Required resolved crypto/RNG feature missing: {name}")
disabled = ("WOLFSSH_NO_CURVE25519_SHA256", "WOLFSSH_NO_ECDH_SHA2_NISTP256",
"WOLFSSH_NO_ECDSA_SHA2_NISTP256", "WOLFSSH_NO_AES_GCM",
"WOLFSSH_NO_HMAC_SHA2_256", "WOLFSSH_NO_ED25519")
for name in disabled:
if name in macros:
raise RuntimeError(f"Policy algorithm disabled: {name}")
for name, (identifier, category) in REQUIRED.items():
row = r'\{\s*' + identifier + r'\s*,\s*' + category + r'\s*,\s*"' + re.escape(name) + r'"\s*\}'
if len(re.findall(row, mapping)) != 1:
raise RuntimeError(f"Missing/ambiguous resolved algorithm name/ID/type: {name}")
def enum_containing(source, token):
matches = [m[0] for m in re.finditer(r"(?m)^enum(?: \w+)?\s*\{[^{}]*\};", source)
if re.search(r"\b" + re.escape(token) + r"\b", m[0])]
if len(matches) != 1:
raise RuntimeError(f"Expected one resolved enum containing {token}")
return matches[0] + "\n"
def main():
parser = argparse.ArgumentParser(description=__doc__)
databases = sorted((ROOT / ".pio/build").glob("*/compile_commands.json"))
default = databases[0] if len(databases) == 1 else ROOT / "compile_commands.json"
parser.add_argument("--compile-commands", type=Path, default=default)
options = parser.parse_args()
sources = {}
for name, expected in HASHES.items():
raw = (VENDOR / "src" / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected:
raise RuntimeError(f"Vendor {name} changed; re-audit before updating pin")
sources[name] = raw.decode()
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("Expected exact application wolfSSH 1.4.20 pin")
overrides = [e for e in ENTRIES if e.component == "wolfssl__wolfssh" and
e.source == "managed_components/wolfssl__wolfssh/src/internal.c"]
if (len(overrides) != 1 or overrides[0].root != "project" or
overrides[0].sha256 != HASHES["internal.c"]):
raise RuntimeError("Expected one independently pinned project wolfSSH override")
override = overrides[0]
_, expected = render_entry(override, {"project": ROOT})
entry, command = compiler_command(options.compile_commands, override, expected)
internal = expected.decode()
# The generated memory-hardening changes must not silently change protocol
# defaults or the feature-filtered name/ID map independently of this policy.
for name, pattern in (
("NameIdMap", r"static const NameIdPair NameIdMap\[\].*?\n\};"),
*((name, r"static const char " + name + r"\[\].*?;") for name in
("cannedKexAlgoNames", "cannedKeyAlgoNames", "cannedEncAlgoNames",
"cannedMacAlgoNames", "cannedNoneNames"))):
original = re.search(pattern, sources["internal.c"], re.S)
transformed = re.search(pattern, internal, re.S)
if original is None or transformed is None or original[0] != transformed[0]:
raise RuntimeError(f"Override changed reviewed algorithm definitions: {name}")
print("PASS: generated compiler input equals render_entry; original pinned algorithm tables unchanged", flush=True)
resolved = run(command + ["-E", "-P"], cwd=entry["directory"],
capture_output=True, text=True).stdout
macro_text = run(command + ["-E", "-dM"], cwd=entry["directory"],
capture_output=True, text=True).stdout
macros = dict(re.findall(r'^#define (\w+)(?: (.*))?$', macro_text, re.M))
mapping = re.search(r'static const NameIdPair NameIdMap\[\]\s*=\s*\{.*?\n\};',
resolved, re.S)[0]
check_profile(macros, mapping)
# The feature checker must not turn into a support-only, always-green test.
for name in REQUIRED:
try:
check_profile(macros, mapping.replace('"' + name + '"', '"removed"'))
except RuntimeError:
pass
else:
raise AssertionError(f"Missing algorithm was not detected: {name}")
print("PASS: production compiler resolved all seven name/ID/type entries and required crypto/RNG features", flush=True)
# Compile the helper against real target headers/settings, even before the
# parent has registered its translation unit in CMake.
target = [str(ROOT / "src/ssh_protocol_policy.c") if
arg == entry["file"] else arg for arg in command]
if target == command:
raise RuntimeError("Could not replace vendor input in compiler command")
run(target + ["-fsyntax-only"], cwd=entry["directory"], capture_output=True, text=True)
print("PASS: policy syntax with real target compiler and headers", flush=True)
functions = ("NameToId", "IdToName", "AlgoListSz", "CopyNameList",
"CopyNameListPlus", "BuildNameList", "SendKexInit", "SendExtInfo")
actual = "\n".join(extract(sources["ssh.c"], "wolfSSH_CTX_SetAlgoList" + field)
for field in FIELDS)
for name in functions:
if extract(internal, name) != extract(sources["internal.c"], name):
raise RuntimeError(f"Override changed reviewed protocol function: {name}")
actual += "\n".join(extract(internal, name) for name in functions)
# Preserve actual conditional enum values and feature-filtered name table.
types = "\n".join(enum_containing(resolved, token) for token in
("ID_NONE", "TYPE_KEX", "MSGID_KEXINIT", "WOLFSSH_ENDPOINT_SERVER"))
types += "typedef struct { byte id; byte type; const char *name; } NameIdPair;\n" + mapping
assignments = []
for field in FIELDS:
line = f"ssh->algoList{field} = ctx->algoList{field};"
if resolved.count(line) != 1:
raise RuntimeError(f"Re-audit SshInit pointer inheritance: {field}")
assignments.append(line)
with tempfile.TemporaryDirectory(prefix="ssh-protocol-policy-") as directory:
temp = Path(directory)
# Fail closed on stale/ambiguous databases and a stale generated render.
entries = json.loads(options.compile_commands.read_text())
original_entry = {**entry, "file": str(VENDOR / "src/internal.c")}
database_cases = (
(entries + [original_entry], expected),
([e for e in entries if source_path(e) != source_path(entry)], expected),
(entries + [entry], expected),
(entries, expected + b"\n/* stale render */\n"),
)
for index, (bad_entries, bad_expected) in enumerate(database_cases):
database = temp / f"bad-database-{index}.json"
database.write_text(json.dumps(bad_entries))
try:
compiler_command(database, override, bad_expected)
except RuntimeError:
pass
else:
raise AssertionError(f"Unsafe generated compiler profile accepted: {index}")
print("PASS: original/missing/duplicate compile entries and stale render rejected", flush=True)
headers = temp / "wolfssh"
headers.mkdir()
(headers / "ssh.h").write_text('#include "support.h"\n')
(headers / "settings.h").write_text("/* Host layout double only. */\n")
for name in ("error.h", "version.h"):
shutil.copyfile(VENDOR / "wolfssh" / name, headers / name)
(temp / "resolved.h").write_text(types)
(temp / "vendor_actual.c").write_text(actual)
transport = (ROOT / "src/ssh_transport.c").read_text()
(temp / "context_actual.c").write_text(extract(transport, "create_context"))
security_header = (ROOT / "src/ssh_security.h").read_text()
capacity = re.search(r'^#define SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY\s+\d+U?$',
security_header, re.M)
if capacity is None:
raise RuntimeError("Re-audit private-key staging capacity definition")
(temp / "context_constants.h").write_text(
capacity[0] + "\n" + enum_containing(resolved, "WOLFSSH_ENDPOINT_SERVER") +
enum_containing(resolved, "WOLFSSH_FORMAT_ASN1"))
(temp / "session_lists.inc").write_text(
"{ WOLFSSH_CTX *ctx = context;\n" + "\n".join(assignments) + "\n}\n")
cc = shlex.split(os.environ.get("CC", "cc"))
flags = ["-std=c99", "-Wall", "-Wextra", "-Werror", "-I", str(temp),
"-I", str(HERE), "-I", str(ROOT / "src")]
policy = str(ROOT / "src/ssh_protocol_policy.c")
for name in ("apply", "context", "vendor"):
binary = temp / name
run(cc + flags + [policy, str(HERE / (name + ".c")), "-o", str(binary)])
subprocess.run([str(binary)], env=ENV, check=True, timeout=10)
version = (headers / "version.h").read_text()
if '"1.4.20"' not in version or "0x01004020" not in version:
raise RuntimeError("Unexpected vendor version header")
for replacement in ("0x01004019", "0x01004021"):
(headers / "version.h").write_text(version.replace("0x01004020", replacement))
result = subprocess.run(cc + flags + ["-fsyntax-only", policy], env=ENV,
capture_output=True, text=True, timeout=30)
if result.returncode == 0 or "Re-audit SSH protocol policy" not in result.stderr:
raise RuntimeError("Policy version guard did not reject unreviewed version")
print("PASS: older/newer wolfSSH versions rejected by production guard", flush=True)
print("PASS: source hashes, exact manifest pin; no downloads/build/device operations")
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
/* Reduced host layout only, not a wolfSSH ABI or crypto implementation. */
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <wolfssh/error.h>
typedef uint8_t byte;
typedef uint32_t word32;
typedef struct WOLFSSH_CTX {
const char *algoListKex, *algoListKey, *algoListCipher, *algoListMac;
const char *algoListKeyAccepted;
int side;
unsigned privateKeyCount;
void *heap;
byte publicKeyAlgo[8];
word32 publicKeyAlgoCount;
unsigned sentinel;
} WOLFSSH_CTX;
int wolfSSH_CTX_SetAlgoListKex(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListKey(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListCipher(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListMac(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListKeyAccepted(WOLFSSH_CTX *, const char *);
+239
View File
@@ -0,0 +1,239 @@
/* Actual vendor list/setter/serialization functions, with bounded host doubles.
* This tests plaintext SSH message payloads, not framing, crypto, or networking. */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ssh_protocol_policy.h"
#include "resolved.h"
#define WLOG(...) ((void)0)
#define INLINE inline
#define WMEMCPY memcpy
#define WSTRLEN strlen
#define XMEMCMP memcmp
#define WMALLOC(size, heap, type) bounded_alloc(size)
#define WFREE(ptr, heap, type) bounded_free(ptr)
#define MSG_ID_SZ 1U
#define UINT32_SZ 4U
#define LENGTH_SZ 4U
#define BOOLEAN_SZ 1U
#define COOKIE_SZ 16U
#define WS_EXTINFO_EXTENSION_COUNT 1
static const char cannedNoneNames[] = "none";
static const char serverSigAlgsName[] = "server-sig-algs";
typedef struct { byte *kexInit; word32 kexInitSz; } HandshakeInfo;
typedef struct {
WOLFSSH_CTX *ctx;
const char *algoListKex, *algoListKey, *algoListCipher, *algoListMac;
const char *algoListKeyAccepted;
int isKeying;
HandshakeInfo *handshake;
void *rng;
struct { byte *buffer; word32 length; } outputBuffer;
} WOLFSSH;
static byte packet[1024], saved_kex[1024];
static HandshakeInfo handshake;
static word32 planned;
static unsigned sends, allocations, frees, purges;
static int pool_in_use, allocation_fail, prepare_error, send_error;
static void *bounded_alloc(size_t size)
{
assert(size <= sizeof(saved_kex));
if (allocation_fail) return NULL;
assert(!pool_in_use);
pool_in_use = 1;
++allocations;
return saved_kex;
}
static void bounded_free(void *ptr)
{
assert(ptr == saved_kex && pool_in_use);
pool_in_use = 0;
++frees;
}
static HandshakeInfo *HandshakeInfoNew(void *heap)
{
(void)heap;
assert(handshake.kexInit == NULL);
return &handshake;
}
static void c32toa(word32 value, byte *out)
{
out[0] = (byte)(value >> 24);
out[1] = (byte)(value >> 16);
out[2] = (byte)(value >> 8);
out[3] = (byte)value;
}
static int wc_RNG_GenerateBlock(void *rng, byte *out, word32 size)
{
(void)rng;
assert(size == COOKIE_SZ);
memset(out, 0x5a, size);
return WS_SUCCESS;
}
static int PreparePacket(WOLFSSH *ssh, word32 payload_size)
{
if (prepare_error) return prepare_error;
assert(payload_size + 16U <= sizeof(packet));
memset(packet, 0xa5, sizeof(packet));
ssh->outputBuffer.buffer = packet;
ssh->outputBuffer.length = 8;
planned = payload_size;
return WS_SUCCESS;
}
static int BundlePacket(WOLFSSH *ssh)
{
assert(ssh->outputBuffer.length == planned + 8U);
for (unsigned i = 0; i < 8; ++i) assert(packet[i] == 0xa5);
for (size_t i = ssh->outputBuffer.length; i < sizeof(packet); ++i)
assert(packet[i] == 0xa5);
return WS_SUCCESS;
}
static int wolfSSH_SendPacket(WOLFSSH *ssh)
{
(void)ssh;
++sends;
return send_error;
}
static void PurgePacket(WOLFSSH *ssh)
{
if (ssh != NULL) ssh->outputBuffer.length = 0;
++purges;
}
#include "vendor_actual.c"
static word32 take_u32(const byte *data, size_t length, size_t *offset)
{
assert(*offset <= length && length - *offset >= 4);
const byte *p = data + *offset;
*offset += 4;
return ((word32)p[0] << 24) | ((word32)p[1] << 16) |
((word32)p[2] << 8) | p[3];
}
static void expect_name(const byte *data, size_t length, size_t *offset,
const char *expected)
{
word32 size = take_u32(data, length, offset);
assert(size == strlen(expected));
assert(*offset <= length && size <= length - *offset);
assert(memcmp(data + *offset, expected, size) == 0);
*offset += size;
}
static void check_kex(const WOLFSSH *ssh)
{
const byte *p = packet + 8;
size_t length = ssh->outputBuffer.length - 8U, offset = 1U + COOKIE_SZ;
assert(p[0] == MSGID_KEXINIT);
for (unsigned i = 1; i <= COOKIE_SZ; ++i) assert(p[i] == 0x5a);
expect_name(p, length, &offset, "curve25519-sha256,ecdh-sha2-nistp256");
expect_name(p, length, &offset, "ecdsa-sha2-nistp256");
/* Decode independently: both c2s and s2c must be exact, with no default
* CBC/CTR, AES192, extra KEX, or extra MAC fallback appended. */
for (unsigned direction = 0; direction < 2; ++direction)
expect_name(p, length, &offset,
"aes128-gcm@openssh.com,aes256-gcm@openssh.com");
for (unsigned direction = 0; direction < 2; ++direction)
expect_name(p, length, &offset, "hmac-sha2-256");
expect_name(p, length, &offset, "none");
expect_name(p, length, &offset, "none");
expect_name(p, length, &offset, "");
expect_name(p, length, &offset, "");
assert(offset < length && p[offset++] == 0);
assert(take_u32(p, length, &offset) == 0);
assert(offset == length);
assert(ssh->handshake->kexInitSz == length + 4);
offset = 0;
assert(take_u32(saved_kex, sizeof(saved_kex), &offset) == length);
assert(memcmp(saved_kex + 4, p, length) == 0);
}
static void check_names(void)
{
static const struct { const char *name; byte id, type; } required[] = {
{"curve25519-sha256", ID_CURVE25519_SHA256, TYPE_KEX},
{"ecdh-sha2-nistp256", ID_ECDH_SHA2_NISTP256, TYPE_KEX},
{"ecdsa-sha2-nistp256", ID_ECDSA_SHA2_NISTP256, TYPE_KEY},
{"aes128-gcm@openssh.com", ID_AES128_GCM, TYPE_CIPHER},
{"aes256-gcm@openssh.com", ID_AES256_GCM, TYPE_CIPHER},
{"hmac-sha2-256", ID_HMAC_SHA2_256, TYPE_MAC},
{"ssh-ed25519", ID_ED25519, TYPE_KEY},
};
for (size_t i = 0; i < sizeof(required) / sizeof(required[0]); ++i) {
assert(NameToId(required[i].name, (word32)strlen(required[i].name)) ==
required[i].id);
assert(strcmp(IdToName(required[i].id), required[i].name) == 0);
unsigned matches = 0;
for (size_t j = 0; j < sizeof(NameIdMap) / sizeof(NameIdMap[0]); ++j)
if (NameIdMap[j].id == required[i].id) {
assert(NameIdMap[j].type == required[i].type);
++matches;
}
assert(matches == 1);
}
assert(NameToId("not-an-algorithm", 16) == ID_UNKNOWN);
}
int main(void)
{
check_names();
WOLFSSH_CTX ctx = {.side = WOLFSSH_ENDPOINT_SERVER, .privateKeyCount = 1};
assert(ssh_protocol_policy_apply(NULL) == WS_SSH_CTX_NULL_E);
int (*const setters[])(WOLFSSH_CTX *, const char *) = {
wolfSSH_CTX_SetAlgoListKex, wolfSSH_CTX_SetAlgoListKey,
wolfSSH_CTX_SetAlgoListCipher, wolfSSH_CTX_SetAlgoListMac,
wolfSSH_CTX_SetAlgoListKeyAccepted,
};
for (size_t i = 0; i < sizeof(setters) / sizeof(setters[0]); ++i) {
assert(setters[i](NULL, "anything") == WS_SSH_CTX_NULL_E);
assert(setters[i](&ctx, "not-an-algorithm") == WS_SUCCESS);
assert(setters[i](&ctx, "") == WS_SUCCESS);
assert(setters[i](&ctx, NULL) == WS_SUCCESS);
}
assert(ssh_protocol_policy_apply(&ctx) == WS_SUCCESS);
/* Real SshInit's pointer assignments are extracted, but the rest of its
* allocation/crypto setup is deliberately not modeled. */
WOLFSSH session = {.ctx = &ctx};
WOLFSSH *ssh = &session;
WOLFSSH_CTX *context = &ctx;
(void)context;
#include "session_lists.inc"
assert(ssh->algoListKeyAccepted == ctx.algoListKeyAccepted);
assert(SendKexInit(ssh) == WS_SUCCESS);
check_kex(ssh);
assert(sends == 1 && allocations == 1 && frees == 0);
assert(SendKexInit(ssh) == WS_SUCCESS);
check_kex(ssh);
assert(sends == 2 && allocations == 2 && frees == 1);
assert(SendExtInfo(ssh) == WS_SUCCESS);
size_t offset = 1, length = ssh->outputBuffer.length - 8U;
const byte *p = packet + 8;
assert(p[0] == MSGID_EXT_INFO);
assert(take_u32(p, length, &offset) == 1);
expect_name(p, length, &offset, "server-sig-algs");
expect_name(p, length, &offset, "ssh-ed25519,ecdsa-sha2-nistp256");
assert(offset == length);
/* No key and injected packet/allocation failures must not send a fallback. */
unsigned before = sends;
ctx.privateKeyCount = 0;
assert(SendKexInit(ssh) == WS_BAD_ARGUMENT);
assert(sends == before);
ctx.privateKeyCount = 1;
prepare_error = WS_BUFFER_E;
assert(SendKexInit(ssh) == WS_BUFFER_E);
assert(sends == before);
prepare_error = 0;
allocation_fail = 1;
assert(SendKexInit(ssh) == WS_MEMORY_E);
assert(sends == before && !pool_in_use);
allocation_fail = 0;
send_error = WS_WANT_WRITE;
unsigned old_purges = purges;
assert(SendKexInit(ssh) == WS_WANT_WRITE);
assert(purges == old_purges);
check_kex(ssh);
bounded_free(handshake.kexInit);
puts("PASS: resolved vendor name/ID/type map, actual setters, initial/rekey KEXINIT both directions, server-sig-algs, bounded failure paths");
return 0;
}
+173
View File
@@ -0,0 +1,173 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Included by test.c: public API checks with live-buffer IO/parser/KDF probes. */
static const char auth_wrong_body[] = "{\"username\":\"alice\",\"password\":\"wrong\"}";
static const char auth_bad_json[] = "{\"username\":\"alice\",\"password\":\"secret-value\",\"extra\":1}";
static web_cookie_auth_snapshot_t auth_counts(void) {
web_cookie_auth_snapshot_t counts;
web_cookie_auth_get_snapshot(&counts);
return counts;
}
static esp_err_t auth_observe(const char *status) {
watch_login = true;
esp_err_t result = web_cookie_auth_handler(&req);
watch_login = false; /* captured stack pointers are no longer live */
assert(!strcmp(response_status, status));
return result;
}
static void auth_five_verifications(void) {
char token[65], csrf[65];
for (unsigned i = 0; i < 5; ++i) {
challenge(token, csrf); login_request(token, csrf, auth_wrong_body);
assert(auth_observe("401 Unauthorized") == ESP_OK);
}
}
static void auth_restart_hook(void) {
assert(!host_lock_depth);
web_cookie_auth_stop();
assert(web_cookie_auth_start() == ESP_OK);
}
static void auth_deadline_hook(void) { now += 3000001; }
static void auth_budget_tests(void) {
char token[65], csrf[65];
now = 180000000; auth_reset(); web_cookie_auth_clear_counters();
auth_five_verifications();
assert(password_calls == 5 && parser_calls == 5 && auth_counts().login_attempts == 5);
unsigned reads = receive_calls, parses = parser_calls;
const int64_t offsets[] = {0, 58999999, 59999999};
const char *retries[] = {"60", "2", "1"};
for (unsigned i = 0; i < 3; ++i) {
now = 180000000 + offsets[i];
challenge(token, csrf); login_request(token, csrf, good_body);
recv_fail = true; /* an exhausted budget must never reach this failure */
assert(auth_observe("429 Too Many Requests") == ESP_FAIL);
recv_fail = false;
assert(receive_calls == reads && parser_calls == parses && password_calls == 5);
assert(body_offset == 0 && aux.remaining_len == strlen(good_body));
assert(!strcmp(retry_value, retries[i]));
assert(cookie_count == 1 && strstr(cookie_values[0], "__Host-sak-prelogin="));
assert(strstr(cookie_values[0], "Max-Age=0"));
assert(auth_counts().active_challenges == 0 && auth_counts().login_attempts == 5);
zero(scratch, sizeof(scratch));
login_request(token, csrf, good_body);
assert(auth_observe("403 Forbidden") == ESP_FAIL); /* denied challenge stayed consumed */
assert(receive_calls == reads && parser_calls == parses && password_calls == 5);
}
now = 240000000;
login_request(token, csrf, good_body);
assert(auth_observe("403 Forbidden") == ESP_FAIL); /* refill cannot resurrect it */
challenge(token, csrf); login_request(token, csrf, good_body);
assert(auth_observe("200 OK") == ESP_OK);
assert(password_calls == 6 && parser_calls == parses + 1 && receive_calls > reads);
assert(auth_counts().login_attempts == 6 && !retry_value[0]);
auth_reset(); web_cookie_auth_clear_counters(); auth_five_verifications();
web_cookie_auth_clear_counters();
challenge(token, csrf); login_request(token, csrf, good_body);
reads = receive_calls; parses = parser_calls;
assert(auth_observe("429 Too Many Requests") == ESP_FAIL);
assert(!auth_counts().login_attempts && auth_counts().throttled == 1);
assert(receive_calls == reads && parser_calls == parses && password_calls == 5);
auth_restart_hook(); /* existing restart replenishment remains intentional */
challenge(token, csrf); login_request(token, csrf, good_body);
assert(auth_observe("200 OK") == ESP_OK);
assert(auth_counts().login_attempts == 1 && password_calls == 6);
puts("PASS: exhausted budget before receive/parser/KDF, close without drain, rounded Retry-After/refill boundary, consumed challenge and restart semantics");
}
static void auth_malformed_budget_tests(void) {
char token[65], csrf[65];
now = 360000000; auth_reset(); web_cookie_auth_clear_counters();
for (unsigned i = 0; i < 7; ++i) {
challenge(token, csrf); login_request(token, csrf, auth_bad_json);
assert(auth_observe("400 Bad Request") == ESP_OK);
assert(body_wipes && credential_wipes);
assert(!password_calls && !auth_counts().login_attempts);
}
assert(parser_calls == 7 && receive_calls > 7);
now = 365000000; auth_five_verifications();
assert(password_calls == 5 && auth_counts().login_attempts == 5);
now = 420000000; /* 60 s after malformed probe, only 55 s after first verification */
challenge(token, csrf); login_request(token, csrf, good_body);
unsigned reads = receive_calls, parses = parser_calls;
assert(auth_observe("429 Too Many Requests") == ESP_FAIL);
assert(!strcmp(retry_value, "5") && receive_calls == reads && parser_calls == parses);
now = 425000000;
challenge(token, csrf); login_request(token, csrf, good_body);
assert(auth_observe("200 OK") == ESP_OK && password_calls == 6);
puts("PASS: malformed requests do not charge verification attempts or advance the verification window");
}
static void auth_epoch_tests(void) {
char token[65], csrf[65];
for (unsigned phase = 0; phase < 2; ++phase) {
for (unsigned restart = 0; restart < 2; ++restart) {
auth_reset(); web_cookie_auth_clear_counters();
challenge(token, csrf); login_request(token, csrf, good_body);
void (*hook)(void) = restart ? auth_restart_hook : web_cookie_auth_stop;
if (phase == 0) header_hook = hook; /* consumed challenge, before first probe */
else parse_hook = hook; /* early probe passed, before final reservation */
assert(auth_observe("503 Service Unavailable") == (phase ? ESP_OK : ESP_FAIL));
assert(!password_calls && !auth_counts().login_attempts && !snapshot().active);
if (phase) {
assert(receive_calls && parser_calls == 1 && body_wipes && credential_wipes);
} else {
assert(!receive_calls && !parser_calls && !body_offset);
assert(aux.remaining_len == strlen(good_body));
}
}
}
puts("PASS: readiness and epoch fencing at early probe and authoritative post-parse reservation");
}
static void auth_plaintext_tests(void) {
char token[65], csrf[65];
for (unsigned mode = 0; mode < 4; ++mode) {
auth_reset(); web_cookie_auth_clear_counters();
challenge(token, csrf);
login_request(token, csrf, mode == 1 ? auth_wrong_body : mode == 2 ? auth_bad_json : good_body);
const char *status = mode == 1 ? "401 Unauthorized" : mode == 2 ? "400 Bad Request" :
mode == 3 ? "503 Service Unavailable" : "200 OK";
send_fail = true; db_fail = mode == 3;
assert(auth_observe(status) == ESP_FAIL);
send_fail = db_fail = false;
assert(body_wipes && credential_wipes && !snapshot().active);
assert(password_calls == (mode == 2 ? 0U : 1U));
assert(auth_counts().login_attempts == password_calls);
}
for (unsigned mode = 0; mode < 2; ++mode) {
auth_reset(); web_cookie_auth_clear_counters();
challenge(token, csrf); login_request(token, csrf, good_body);
receive_fragment = 7;
if (mode) recv_hook = auth_deadline_hook;
else recv_fail_after = 7;
send_fail = true;
assert(auth_observe("400 Bad Request") == ESP_FAIL);
send_fail = false; recv_fail_after = 0;
assert(body_offset == 7 && aux.remaining_len == strlen(good_body) - 7);
assert(receive_calls == (mode ? 1U : 2U) && body_wipes);
assert(!parser_calls && !password_calls && !auth_counts().login_attempts);
}
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
unsigned before = sends;
server.config.max_resp_headers = 1; /* final Set-Cookie fails after successful KDF */
watch_login = true;
assert(web_cookie_auth_handler(&req) != ESP_OK);
watch_login = false;
server.config.max_resp_headers = 8;
assert(sends == before && body_wipes && credential_wipes && !snapshot().active);
puts("PASS: raw JSON wiped before KDF, credentials wiped after KDF, pre-send error wiping and send/header/partial-receive failure cleanup");
}
static void auth_admission_tests(void) {
auth_budget_tests();
auth_malformed_budget_tests();
auth_epoch_tests();
auth_plaintext_tests();
auth_reset();
}
+3 -1
View File
@@ -253,7 +253,9 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
*(["-DHOST_BROKER"] if broker else []),
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
*(["-DHOST_LIFECYCLE"] if lifecycle else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources),
"-Wl,--wrap=web_auth_parse_login,--wrap=secure_wipe,--wrap=httpd_resp_set_hdr",
"-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
if lifecycle:
+67 -4
View File
@@ -9,6 +9,7 @@
#undef user_database_username_valid
#endif
#include "web_cookie_auth.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "esp_httpd_priv.h"
#ifdef HOST_ADMIN
@@ -32,11 +33,48 @@ static bool send_fail, recv_fail;
static size_t receive_fragment = 7;
static void (*password_hook)(void);
static void (*send_hook)(void);
static void (*header_hook)(void), (*parse_hook)(void), (*recv_hook)(void);
static unsigned receive_calls, parser_calls, body_wipes, credential_wipes;
static size_t recv_fail_after;
static bool watch_login;
static const char *watched_body;
static web_auth_credentials_t *watched_credentials;
static char retry_value[16];
static char response_status[48];
static struct httpd_req_aux aux;
static httpd_req_t req;
size_t host_read_pending(httpd_req_t *r, char *out, size_t n);
/* Link-time wrappers retain the production parser, wipe, and IDF header setter. */
bool __real_web_auth_parse_login(const char *, size_t, web_auth_credentials_t *);
void __real_secure_wipe(void *, size_t);
esp_err_t __real_httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
bool __wrap_web_auth_parse_login(const char *body, size_t length, web_auth_credentials_t *credentials) {
assert(!host_lock_depth); ++parser_calls;
if (watch_login) { watched_body = body; watched_credentials = credentials; }
bool valid = __real_web_auth_parse_login(body, length, credentials);
if (parse_hook) { void (*hook)(void) = parse_hook; parse_hook = NULL; hook(); }
return valid;
}
void __wrap_secure_wipe(void *data, size_t length) {
__real_secure_wipe(data, length);
if (watch_login && data == watched_body && length == WEB_AUTH_LOGIN_BODY_MAX + 1) {
zero(data, length); ++body_wipes;
}
if (watch_login && data == watched_credentials && length == sizeof(*watched_credentials)) {
zero(data, length); ++credential_wipes;
}
}
esp_err_t __wrap_httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value) {
assert(!host_lock_depth);
esp_err_t error = __real_httpd_resp_set_hdr(r, name, value);
if (header_hook && !strcmp(name, "Set-Cookie") &&
strstr(value, "__Host-sak-prelogin=") && strstr(value, "Max-Age=0")) {
void (*hook)(void) = header_hook; header_hook = NULL; hook();
}
return error;
}
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *status) {
(void)r; if (fail_header && ++setter_calls == fail_header) return ESP_FAIL;
snprintf(response_status, sizeof(response_status), "%s", status); return ESP_OK;
@@ -45,11 +83,18 @@ esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *type) {
(void)r; (void)type; return ESP_OK;
}
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
(void)r; ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body);
(void)r; assert(!host_lock_depth);
if (watch_login) {
if (watched_body) { assert(body_wipes); zero(watched_body, WEB_AUTH_LOGIN_BODY_MAX + 1); }
if (watched_credentials) { assert(credential_wipes); zero(watched_credentials, sizeof(*watched_credentials)); }
}
++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body);
cookie_count = 0;
for (unsigned i = 0; i < aux.resp_hdrs_count; ++i) {
assert(response_headers[i].value);
assert(strcmp(response_headers[i].field, "WWW-Authenticate"));
if (!strcmp(response_headers[i].field, "Retry-After"))
snprintf(retry_value, sizeof(retry_value), "%s", response_headers[i].value);
if (!strcmp(response_headers[i].field, "Set-Cookie")) {
assert(cookie_count < 2);
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
@@ -59,10 +104,14 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
return send_fail ? ESP_FAIL : ESP_OK;
}
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
(void)r; if (recv_fail) return -1;
(void)r; assert(!host_lock_depth); ++receive_calls;
if (watch_login && !body_offset) watched_body = out;
if (recv_fail || (recv_fail_after && body_offset >= recv_fail_after)) return -1;
if (size > receive_fragment) size = receive_fragment;
memcpy(out, request_body + body_offset, size); body_offset += size;
aux.remaining_len -= size; return (int)size;
aux.remaining_len -= size;
if (recv_hook) { void (*hook)(void) = recv_hook; recv_hook = NULL; hook(); }
return (int)size;
}
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); }
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
@@ -78,6 +127,12 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol
esp_err_t user_database_authenticate_password(const uint8_t *u, size_t un,
const uint8_t *p, size_t pn, user_principal_t *principal, bool *authenticated) {
assert(!host_lock_depth); ++password_calls;
if (watch_login) {
assert(watched_body && watched_credentials && body_wipes && !credential_wipes);
zero(watched_body, WEB_AUTH_LOGIN_BODY_MAX + 1);
assert(u == watched_credentials->username && p == watched_credentials->password);
assert(un == watched_credentials->username_length && pn == watched_credentials->password_length);
}
if (password_hook) { void (*hook)(void) = password_hook; password_hook = NULL; hook(); }
*authenticated = un == 5 && !memcmp(u, "alice", 5) && pn == 12 && !memcmp(p, "password1234", 12);
if (*authenticated) *principal = alice;
@@ -93,7 +148,9 @@ static void begin(const char *uri, int method, const char *body) {
.content_len = body ? strlen(body) : 0};
aux.remaining_len = req.content_len;
request_body = body; body_offset = 0;
response_status[0] = output[0] = 0; cookie_count = 0;
response_status[0] = output[0] = retry_value[0] = 0; cookie_count = 0;
watch_login = false; watched_body = NULL; watched_credentials = NULL;
body_wipes = credential_wipes = 0;
}
static void add(const char *key, const char *value) {
char *at = scratch;
@@ -126,10 +183,15 @@ static void login_request(const char *token, const char *csrf, const char *body)
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
}
static void auth_reset(void) {
watch_login = false; watched_body = NULL; watched_credentials = NULL;
header_hook = parse_hook = recv_hook = NULL; recv_fail_after = 0;
receive_calls = parser_calls = 0;
web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK);
password_calls = 0; password_hook = NULL;
}
#include "admission_test.c"
#ifdef HOST_ADMIN
#include "admin_test.c"
#endif
@@ -302,6 +364,7 @@ int main(void) {
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
server.config.max_resp_headers = 8;
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
auth_admission_tests();
#ifdef HOST_ADMIN
admin_tests();
#endif
+20 -9
View File
@@ -14,8 +14,9 @@ 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:
It requires the actual generated wolfSSH `internal.c` compilation input to equal
`tools/security_overrides.py`'s rendering of the pinned original, preprocesses
that compile command (`-E -dM`), and checks this reviewed profile:
- `LIBWOLFSSH_VERSION_HEX == 0x01004020` (1.4.20).
- RSA disabled; ECDSA and Ed25519 not disabled.
@@ -23,8 +24,8 @@ checks this reviewed profile:
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
source/version/pin and executes the rendered host contract with the reviewed
feature profile. A stale compilation database is not proof of the next firmware build's
configuration.
## What executes
@@ -39,8 +40,9 @@ reviewed SHA-256 of `managed_components/wolfssl__wolfssh/src/internal.c`:
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:
The runner extracts actual **overridden production** function definitions by
balancing braces after masking comments/string literals. Extraction does not
rewrite their bodies; the separately verified build overlay does:
- `GetBoolean`, `GetUint32`, `GetSize`, `GetStringRef`
- `DoUserAuthRequestPassword`, `DoUserAuthRequestPublicKey`, `DoUserAuthRequest`
@@ -53,7 +55,7 @@ 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:
The 35 baseline cases cover (with the stricter malformed-password contract):
- Ed25519 and ECDSA: signed authorization rejection (`INVALID_PUBLICKEY`,
`FAILURE`, `REJECTED`, `INVALID_USER`, `INVALID_AUTHTYPE`) never hashes,
@@ -62,8 +64,8 @@ The 35 cases cover:
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.
- Password success/failure, rejected password change, and rejection **before the
callback** for 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
@@ -76,6 +78,15 @@ The 35 cases cover:
prefix leaves the library copy intact. A blocked flush of earlier data returns
a negative code without consuming new data.
A further **100 generated-parser cases** test short/missing flags and lengths,
truncated/oversized/`UINT32_MAX` password and replacement-password lengths,
checked initial offsets and canaries, no callback on malformed fields, preserved
username/service/method prefixes, and suffix wiping before response emission.
They include success, invalid/backend/rejected outcomes, password changes, no
callback, callback-modified credential pointers/lengths, and pending retry.
`WS_AUTH_PENDING` deliberately preserves bytes; the project's synchronous
callbacks do not use it. This is not an unconditional async secret-wipe promise.
## Limits / ownership
This is a library parser/control-flow regression, **not application callback
+135 -9
View File
@@ -67,7 +67,8 @@ 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 int expect_new_password, poison_password_pointers;
static void inspect_password_wipe(void);
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];
@@ -89,8 +90,13 @@ static byte MatchIdLists(int side, const byte *id, word32 count, const byte *lis
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 wolfSSH_SetUsernameRaw(WOLFSSH *s, const byte *u, word32 n) {
/* Called again after the method parser: prefix must still be valid. */
assert(n == 4 && !memcmp(u, "test", 4)); return WS_SUCCESS;
}
static int SendUserAuthFailure(WOLFSSH *s, byte partial) {
inspect_password_wipe(); event('F'); return send_return;
}
static int SendUserAuthPkOk(WOLFSSH *s, const byte *a, word32 an, const byte *k, word32 kn) {
event('P'); return WS_SUCCESS;
}
@@ -129,6 +135,17 @@ static int authorize(byte method, WS_UserAuthData *a, void *ctx) {
assert(a->sf.password.hasNewPassword == expect_new_password);
assert(a->sf.password.passwordSz == 5);
assert(!memcmp(a->sf.password.password, "dummy", 5));
if (expect_new_password) {
assert(a->sf.password.newPasswordSz == 9);
assert(!memcmp(a->sf.password.newPassword, "new-dummy", 9));
}
if (poison_password_pointers) {
/* Cleanup must use checked packet bounds, not mutable authData. */
a->sf.password.password = (const byte *)(uintptr_t)1;
a->sf.password.passwordSz = UINT32_MAX;
a->sf.password.newPassword = (const byte *)(uintptr_t)1;
a->sf.password.newPasswordSz = UINT32_MAX;
}
}
return auth_return;
}
@@ -144,8 +161,23 @@ static int reject_keyboard(WS_UserAuthData_Keyboard *k, void *ctx) {
static int allowed(WOLFSSH *s, void *ctx) {
return WOLFSSH_USERAUTH_PASSWORD | WOLFSSH_USERAUTH_PUBLICKEY;
}
static byte output[1024], packet[1024];
static word32 length;
static byte output[1024];
static struct { byte before[16], bytes[1024], after[16]; } storage, saved;
#define packet storage.bytes
static word32 length, suffix_start, suffix_end;
static int watch_password;
static void inspect_password_wipe(void) {
if (!watch_password) return;
assert(!memcmp(storage.before, saved.before, sizeof(storage.before)));
assert(!memcmp(storage.after, saved.after, sizeof(storage.after)));
assert(!memcmp(packet, saved.bytes, suffix_start));
for (word32 i = suffix_start; i < suffix_end; ++i) assert(packet[i] == 0);
assert(!memcmp(packet + suffix_end, saved.bytes + suffix_end,
sizeof(packet) - suffix_end));
}
static void watch_suffix(word32 start) {
suffix_start = start; suffix_end = length; saved = storage; watch_password = 1;
}
static WOLFSSH_CTX context = { authorize, result, reject_keyboard, allowed };
static WOLFSSH ssh;
static void reset(void) {
@@ -154,7 +186,9 @@ static void reset(void) {
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;
length = 0; watch_password = 0; poison_password_pointers = 0;
memset(&storage, 0xa5, sizeof(storage));
context.userAuthCb = authorize;
}
static void blob(const void *s, word32 n) {
assert(length + 4 + n <= sizeof(packet));
@@ -178,6 +212,95 @@ static void check(const char *trace, int done) {
assert(!strcmp(events, trace));
assert((ssh.clientState == CLIENT_USERAUTH_DONE) == done); ++groups;
}
static void password_cleanup_tests(void) {
/* Every truncation of both encodings, including flag and length fields.
* No malformed input may reach the auth/database double, even if absent. */
for (int change = 0; change < 2; ++change) {
word32 payload_size = change ? 23 : 10;
for (word32 cut = 0; cut < payload_size; ++cut) {
for (int no_callback = 0; no_callback < 2; ++no_callback) {
reset(); request("password"); word32 start = length;
packet[length++] = change; string("dummy");
if (change) string("new-dummy");
length = start + cut; watch_suffix(start);
if (no_callback) context.userAuthCb = NULL;
assert(dispatch() == WS_BUFFER_E); check("", 0);
inspect_password_wipe();
}
}
}
const word32 oversized[] = { 6, 1024, UINT32_MAX };
for (unsigned i = 0; i < sizeof(oversized)/sizeof(oversized[0]); ++i) {
for (int change = 0; change < 2; ++change) {
reset(); request("password"); word32 start = length;
packet[length++] = change; string("dummy");
word32 field = start + 1;
if (change) { field = length; string("new-dummy"); }
c32toa(change && oversized[i] == 6 ? 10 : oversized[i], packet + field);
watch_suffix(start);
assert(dispatch() == WS_BUFFER_E); check("", 0); inspect_password_wipe();
}
}
/* Application bad-password/backend failure/admission denial all retain the
* library's ordinary result mapping. Include partial success and no callback. */
const int outcomes[] = { WOLFSSH_USERAUTH_SUCCESS, WOLFSSH_USERAUTH_INVALID_PASSWORD,
WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_REJECTED,
WOLFSSH_USERAUTH_INVALID_USER, WOLFSSH_USERAUTH_INVALID_AUTHTYPE,
WOLFSSH_USERAUTH_PARTIAL_SUCCESS };
for (unsigned i = 0; i < sizeof(outcomes)/sizeof(outcomes[0]); ++i) {
for (int change = 0; change < 2; ++change) {
reset(); request("password"); word32 start = length;
packet[length++] = change; string("dummy");
if (change) string("new-dummy");
expect_new_password = change; auth_return = outcomes[i];
poison_password_pointers = 1;
/* Trailing payload is also wiped but not included in parsed idx. */
word32 parsed_end = length; packet[length++] = 0x71;
watch_suffix(start);
WS_UserAuthData data = {0}; data.username = packet + 4; data.usernameSz = 4;
word32 idx = start;
assert(DoUserAuthRequestPassword(&ssh, &data, packet, length, &idx) == WS_SUCCESS);
assert(idx == (outcomes[i] == WOLFSSH_USERAUTH_REJECTED ? start : parsed_end));
check(i == 0 ? "A" : "AF", i == 0); inspect_password_wipe();
}
}
reset(); request("password"); word32 start = length;
packet[length++] = 0; string("dummy"); watch_suffix(start);
context.userAuthCb = NULL;
assert(dispatch() == WS_SUCCESS); check("F", 0); inspect_password_wipe();
reset(); request("password"); start = length;
packet[length++] = 0; string("dummy"); watch_suffix(start);
auth_return = WOLFSSH_USERAUTH_FAILURE; send_return = WS_WANT_WRITE;
assert(dispatch() == WS_WANT_WRITE); check("AF", 0); inspect_password_wipe();
for (int change = 0; change < 2; ++change) {
reset(); request("password"); start = length;
packet[length++] = change; string("dummy");
if (change) string("new-dummy");
expect_new_password = change; watch_suffix(start);
auth_return = WOLFSSH_USERAUTH_WOULD_BLOCK;
assert(dispatch() == WS_AUTH_PENDING); check("A", 0);
assert(!memcmp(&storage, &saved, sizeof(storage)));
auth_return = WOLFSSH_USERAUTH_SUCCESS;
assert(dispatch() == WS_SUCCESS); check("AA", 1); inspect_password_wipe();
}
/* Invalid argument paths must neither dereference idx nor guess wipe bounds. */
for (int bad = 0; bad < 8; ++bad) {
reset(); request("password"); start = length;
packet[length++] = 0; string("dummy"); saved = storage;
WS_UserAuthData data = {0}; word32 idx = start;
if (bad == 5) idx = length + 1;
if (bad == 6) idx = UINT32_MAX;
if (bad == 7) ssh.ctx = NULL;
int ret = DoUserAuthRequestPassword(bad == 0 ? NULL : &ssh,
bad == 1 ? NULL : &data, bad == 2 ? NULL : packet,
bad == 3 ? 0 : length, bad == 4 ? NULL : &idx);
assert(ret == (bad == 5 || bad == 6 ? WS_BUFFER_E : WS_BAD_ARGUMENT));
assert(!memcmp(&storage, &saved, sizeof(storage))); check("", 0);
}
}
int main(void) {
const int rejected[] = { WOLFSSH_USERAUTH_INVALID_PUBLICKEY,
WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_REJECTED,
@@ -209,10 +332,10 @@ int main(void) {
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. */
/* The generated parser rejects a truncated new-password length before auth. */
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);
assert(dispatch() == WS_BUFFER_E); check("", 0);
const char *unsupported[] = { "none", "unrecognized" };
for (unsigned i = 0; i < 2; ++i) {
reset(); request(unsupported[i]); assert(dispatch() == WS_SUCCESS); check("F", 0);
@@ -241,6 +364,9 @@ int main(void) {
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);
assert(groups == 35);
printf("PASS: %u original wolfSSH parser/control-flow cases (stricter malformed password contract)\n", groups);
password_cleanup_tests();
printf("PASS: %u additional generated password parser/cleanup cases\n", groups - 35);
return 0;
}
+39 -10
View File
@@ -8,12 +8,16 @@ from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
VENDOR = ROOT / "managed_components/wolfssl__wolfssh"
REVIEWED_SHA256 = "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9"
sys.dont_write_bytecode = True
sys.path.insert(0, str(ROOT / "tools"))
from security_overrides import ENTRIES, render_entry
def extract(source, name):
@@ -34,10 +38,24 @@ def extract(source, name):
return source[start:end] + "\n"
def check_build_profile(database):
def check_build_profile(database, override, expected):
entries = json.loads(database.read_text())
entry = next(e for e in entries if Path(e["file"]).resolve() ==
(VENDOR / "src/internal.c").resolve())
def source_path(entry):
path = Path(entry["file"])
return (Path(entry["directory"]) / path).resolve()
original = (VENDOR / "src/internal.c").resolve()
if any(source_path(e) == original for e in entries):
raise RuntimeError("Production still compiles original internal.c; reconfigure the build")
suffix = ("security_overrides", override.name, "internal.c")
matches = [e for e in entries if source_path(e).parts[-3:] == suffix]
if len(matches) != 1:
raise RuntimeError(f"Expected one generated wolfSSH compile entry, found {len(matches)}")
entry = matches[0]
actual = source_path(entry).read_bytes()
if actual != expected:
raise RuntimeError("Generated wolfSSH source differs from render_entry; reconfigure the build")
args = entry.get("arguments") or shlex.split(entry["command"])
# Strip output/dependency-writing flags: this must only preprocess to stdout.
clean = []
@@ -59,7 +77,8 @@ def check_build_profile(database):
"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)
print("PASS: generated source equals render_entry; actual compiler preprocessing matches reviewed auth feature profile", flush=True)
return actual.decode()
def main():
@@ -83,12 +102,17 @@ def main():
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")
overrides = [e for e in ENTRIES if e.component == "wolfssl__wolfssh" and
e.source == "managed_components/wolfssl__wolfssh/src/internal.c"]
if len(overrides) != 1 or overrides[0].root != "project" or overrides[0].sha256 != REVIEWED_SHA256:
raise RuntimeError("Expected one independently pinned project wolfSSH override")
override = overrides[0]
_, expected = render_entry(override, {"project": ROOT})
if options.host_only:
print("SKIP: production feature verification (--host-only)", flush=True)
print("SKIP: production generated-source/feature verification (--host-only); testing render_entry output", flush=True)
source = expected.decode()
else:
check_build_profile(options.compile_commands)
source = raw.decode()
source = check_build_profile(options.compile_commands, override, expected)
# 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 {"):
@@ -101,14 +125,19 @@ def main():
"DoUserAuthRequestPassword", "DoUserAuthRequestPublicKey",
"SendUserAuthKeyboardRequest", "DoUserAuthRequest", "GetAllowedAuth",
"SendChannelData"]
extracted = "\n".join(extract(source, name) for name in names)
# Exercise the installed nonoptimizable wipe, not a memset replacement.
misc = (VENDOR / "src/misc.c").read_text()
wipe = extract(misc.replace("STATIC INLINE void ForceZero", "static void ForceZero"), "ForceZero")
if "volatile byte*" not in wipe:
raise RuntimeError("ForceZero implementation changed; re-audit")
extracted = wipe + "\n" + "\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",
subprocess.run(cc + ["-std=c99", "-O2", "-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"})