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:
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 *);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user