Add Phase 9C security hardening

Generate exact-hash SDK source overrides without modifying dependencies.
Harden
SSH allocation and algorithm policy, tighten web authentication cleanup,
and add
focused host contract tests and documentation.
This commit is contained in:
2026-09-15 22:12:57 +02:00
parent 751dfb9ddb
commit cdc9c7335a
41 changed files with 3597 additions and 89 deletions
+141
View File
@@ -0,0 +1,141 @@
# SSH protocol policy contracts
Run from the project root:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py
```
The runner requires the installed, exactly pinned wolfSSH 1.4.20 source, the
production compilation database, its target compiler, Python 3, and a host C99
compiler (`CC`, default `cc`). It never downloads dependencies or invokes a
firmware build. All host headers, extracted functions, and binaries are created
in a temporary directory and removed afterward. Every compiler/preprocessor
invocation is limited to 30 seconds; each host executable to 10 seconds.
`CCACHE_DISABLE=1` is also forced for subprocesses.
If multiple build environments exist, select the intended profile explicitly:
```sh
CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py \
--compile-commands .pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json
```
There is deliberately no host-only mode that could silently skip resolved
production-feature verification. The database must select exactly one generated
`security_overrides/wolfssh_internal/internal.c`, whose bytes must equal the
in-memory `tools/security_overrides.py` `render_entry()` output. Original vendor
compilation, missing/duplicate entries, and stale generated content fail closed
with a reconfiguration diagnostic. The runner does not regenerate anything.
The helper is syntax-checked against real target headers using that generated
translation unit's compile settings.
## Production contract and integration
`src/ssh_protocol_policy.{c,h}` exports:
```c
int ssh_protocol_policy_apply(WOLFSSH_CTX *context);
```
It applies permanent, borrowed strings for these five context settings:
| Setter suffix | Exact value |
|---|---|
| `Kex` | `curve25519-sha256,ecdh-sha2-nistp256` |
| `Key` | `ecdsa-sha2-nistp256` |
| `Cipher` | `aes128-gcm@openssh.com,aes256-gcm@openssh.com` |
| `Mac` | `hmac-sha2-256` |
| `KeyAccepted` | `ssh-ed25519,ecdsa-sha2-nistp256` |
The helper returns `WS_SSH_CTX_NULL_E` for NULL and otherwise returns the first
non-success setter result, without subsequent calls or fallback. It does not
allocate, free, or publish a context. A failure can leave earlier settings
applied: **the caller must discard the candidate, not use it**.
The parent integrated the helper in `create_context()` after host-key import and
full staging-buffer wipe, before callback registration and `s_context`
publication. Any policy failure frees the unpublished candidate and returns
`ESP_FAIL`. `context.c` now executes the actual extracted function with the real
policy helper to test this boundary. Service startup isolation, owner/task
lifecycle, session creation, and complete restart paths remain outside this
focused harness.
The exact-version guard rejects unreviewed wolfSSH versions. The source/config
checks below independently verify the actual feature profile; setter success
alone does not validate an algorithm list. `KeyAccepted` controls only the
`server-sig-algs` advertisement in this vendor version. User-key enrollment and
authorization remain enforced by the existing database/authentication path.
## Evidence provided
- Pins the SHA-256 of installed `src/internal.c` and `src/ssh.c`, the application
manifest's exact wolfSSH version, and the resolved compiler version macro.
Source changes require re-audit, not blind hash refresh. Independently checks
the registered override's original-source hash, renders it in memory, and
requires exact equality with the actual generated compiler input. Requires
all original algorithm tables/default strings and extracted protocol-function
bodies to remain unchanged by the override. Negative database cases reject
original/missing/duplicate entries and mismatched render output.
- Replays the actual generated vendor compile command without output/dependency-writing
flags to resolve feature macros, the name/ID/type map, and conditional enums.
All seven distinct policy algorithm names must have their expected compiled
IDs and categories. Required RNG/software-crypto and Ed25519 streaming settings
must remain present; policy-disabling macros are rejected. Negative map cases
demonstrate that missing algorithms fail the checker.
- Compiles the real helper against injected setter doubles. Tests NULL without
dispatch, all five exact lists in order, negative and positive non-success
return propagation at every step, no later calls/fallback, preservation of
unapplied fields, context sentinel survival, and retained string pointers.
These remain helper-boundary checks; the separate context harness below tests
actual caller cleanup and publication.
- Executes actual extracted `src/ssh_transport.c` `create_context()` together
with the real policy helper: 15 cases covering identity-copy failure,
context-allocation failure, both positive/negative key-import failures,
positive/negative setter failures at all five steps, and success. Checks error
mapping, no later policy calls/callbacks/publication on failure, exactly one
free for allocated failed candidates, no free on success, and publication only
after all eight callback registrations. Marks the entire synthetic identity
staging buffer, including the unused tail; asserts full-capacity wipe before
policy initialization, callbacks, and candidate destruction. Context creation
and key import necessarily precede that wipe. Buffer checks occur only while
the extracted function's stack frame is live, never after return.
- Executes the five actual vendor context setter bodies, confirming null-context
errors and their acceptance of invalid, empty, and NULL lists. The helper then
overwrites all five with the fixed policy.
- Executes actual vendor `NameToId`, `IdToName`, `AlgoListSz`, `CopyNameList`,
`CopyNameListPlus`, `BuildNameList`, `SendKexInit`, and `SendExtInfo` bodies.
The mapping and enum values come from production preprocessing. The five actual
`SshInit` list-pointer assignments are checked and reused in the reduced layout.
- Independently decodes initial and repeated/rekey KEXINIT plaintext payloads:
exact KEX/host-key lists, both cipher directions, both MAC directions,
compression/language lists, first-packet flag, reserved field, total bounds,
canaries, and saved exchange-hash input. Exact equality excludes CBC, CTR,
AES192, extra KEX/MAC entries, or an appended default fallback.
- Decodes the actual `server-sig-algs` extension with exactly Ed25519/P-256.
- Exercises missing-host-key, packet-preparation, saved-payload allocation, and
WANT_WRITE behavior with bounded doubles. Checks no send on early failures
and preservation of the exact payload on WANT_WRITE.
- Compiles negative older/newer version cases against the production guard.
## Limits and deferred validation
Host context/session layouts are reduced doubles, not vendor ABI replicas.
The context harness doubles identity copying, key import, context allocation/free,
callback registration, and the wipe primitive. It verifies production call order,
wipe extent, cleanup, and publication, not vendor destruction or secure-wipe
machine code. Synthetic identity bytes are not actual private-key material.
Handshake allocation, packet reservation/wrapping/purging, deterministic cookie
RNG, big-endian integer writing, and send are doubles; payload encoders and list
setters are extracted vendor code. The fixed packet/storage buffers are 1024
bytes. No private key, signature, KEX arithmetic, encryption, MAC, complete SSH
packet framing, peer negotiation, socket, device, or scheduling behavior is
executed. Repeated KEXINIT tests serialization on rekey, not an entire rekey
exchange. The extension test does not establish user-key enforcement.
Hardware/live-client acceptance remains deferred to combined Phase 9: both key
types, explicit rejection of excluded algorithms, real rekey, and mixed
transport responsiveness. No negotiated-handshake or target pass is implied.
This work does not change TLS policy, dependencies, vendor files, generated
assets, global crypto primitives/settings, NVS encryption, or eFuses.
+92
View File
@@ -0,0 +1,92 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ssh_protocol_policy.h"
static const char *const expected[] = {
"curve25519-sha256,ecdh-sha2-nistp256",
"ecdsa-sha2-nistp256",
"aes128-gcm@openssh.com,aes256-gcm@openssh.com",
"hmac-sha2-256",
"ssh-ed25519,ecdsa-sha2-nistp256",
};
static unsigned calls, fail_at;
static int failure;
static WOLFSSH_CTX *candidate;
static int set(WOLFSSH_CTX *ctx, const char *list, const char **field,
unsigned step)
{
assert(ctx == candidate);
assert(ctx->sentinel == 0x12345678U);
assert(++calls == step);
assert(strcmp(list, expected[step - 1]) == 0);
if (step == fail_at) return failure;
*field = list;
return WS_SUCCESS;
}
#define SETTER(name, field, step) \
int wolfSSH_CTX_SetAlgoList##name(WOLFSSH_CTX *c, const char *s) \
{ return set(c, s, &c->field, step); }
SETTER(Kex, algoListKex, 1)
SETTER(Key, algoListKey, 2)
SETTER(Cipher, algoListCipher, 3)
SETTER(Mac, algoListMac, 4)
SETTER(KeyAccepted, algoListKeyAccepted, 5)
static const char *get(const WOLFSSH_CTX *c, unsigned index)
{
switch (index) {
case 0: return c->algoListKex;
case 1: return c->algoListKey;
case 2: return c->algoListCipher;
case 3: return c->algoListMac;
default: return c->algoListKeyAccepted;
}
}
int main(void)
{
assert(ssh_protocol_policy_apply(NULL) == WS_SSH_CTX_NULL_E);
assert(calls == 0);
/* This is an unpublished stack-owned candidate. Any attempted vendor free
* has no test definition and fails linking; transport publication is outside
* this helper and is covered by the parent's integration tests. */
static const char original[] = "old-default";
for (unsigned step = 1; step <= 5; ++step) {
for (unsigned sign = 0; sign < 2; ++sign) {
WOLFSSH_CTX ctx = {
.algoListKex = original, .algoListKey = original,
.algoListCipher = original, .algoListMac = original,
.algoListKeyAccepted = original, .sentinel = 0x12345678U,
};
candidate = &ctx;
calls = 0;
fail_at = step;
failure = sign ? (int)(9000 + step) : -(int)(9000 + step);
assert(ssh_protocol_policy_apply(&ctx) == failure);
assert(calls == step);
assert(ctx.sentinel == 0x12345678U);
for (unsigned i = 0; i < 5; ++i) {
if (i + 1 < step) assert(strcmp(get(&ctx, i), expected[i]) == 0);
else assert(get(&ctx, i) == original);
}
}
}
const char *retained[5];
for (unsigned pass = 0; pass < 2; ++pass) {
WOLFSSH_CTX ctx = {.sentinel = 0x12345678U};
candidate = &ctx;
calls = fail_at = 0;
assert(ssh_protocol_policy_apply(&ctx) == WS_SUCCESS);
assert(calls == 5);
for (unsigned i = 0; i < 5; ++i) {
assert(strcmp(get(&ctx, i), expected[i]) == 0);
if (pass == 0) retained[i] = get(&ctx, i);
else assert(retained[i] == get(&ctx, i));
}
}
for (unsigned i = 0; i < 5; ++i) assert(strcmp(retained[i], expected[i]) == 0);
puts("PASS: apply NULL, five exact lists, every setter failure (+/-), stop/no fallback, retained strings");
return 0;
}
+182
View File
@@ -0,0 +1,182 @@
/* Actual create_context() + actual policy; identity/vendor/callback doubles. */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ssh_protocol_policy.h"
#include "context_constants.h"
typedef int esp_err_t;
enum { ESP_OK = 0, ESP_FAIL = -1, ESP_ERR_NO_MEM = 0x101 };
static WOLFSSH_CTX candidate, *s_context;
static unsigned copies, news, imports, wipes, setters, callbacks, frees;
static unsigned fail_setter;
static int copy_error, allocation_fail, import_error, setter_error;
static unsigned char *identity;
static size_t identity_capacity;
static void unpublished(void)
{
assert(s_context == NULL);
}
static void wiped(void)
{
assert(wipes == 1);
assert(identity_capacity == SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY);
/* Only called while the extracted create_context stack frame is alive. */
for (size_t i = 0; i < identity_capacity; ++i) assert(identity[i] == 0);
}
static esp_err_t ssh_security_copy_private_key(unsigned char *out, size_t capacity,
size_t *length)
{
unpublished();
assert(++copies == 1);
assert(capacity == SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY);
for (size_t i = 0; i < capacity; ++i) assert(out[i] == 0);
identity = out;
identity_capacity = capacity;
/* Mark the unused tail too, so a short wipe cannot pass this test. */
memset(out, 0x6d, capacity);
*length = 31;
return copy_error;
}
static void secure_wipe(void *ptr, size_t length)
{
unpublished();
assert(ptr == identity && length == identity_capacity);
assert(wipes++ == 0);
memset(ptr, 0, length);
}
static WOLFSSH_CTX *wolfSSH_CTX_new(int endpoint, void *heap)
{
unpublished();
assert(copies == 1 && wipes == 0 && imports == 0);
assert(endpoint == WOLFSSH_ENDPOINT_SERVER && heap == NULL);
assert(++news == 1);
return allocation_fail ? NULL : &candidate;
}
static int wolfSSH_CTX_UsePrivateKey_buffer(WOLFSSH_CTX *ctx,
const unsigned char *key, word32 length, int format)
{
unpublished();
assert(ctx == &candidate && news == 1 && wipes == 0);
assert(++imports == 1);
assert(key == identity && length == 31 && format == WOLFSSH_FORMAT_ASN1);
for (size_t i = 0; i < identity_capacity; ++i) assert(key[i] == 0x6d);
return import_error;
}
static void wolfSSH_CTX_free(WOLFSSH_CTX *ctx)
{
unpublished();
wiped();
assert(ctx == &candidate && news == 1 && callbacks == 0);
assert(++frees == 1);
}
static int set_list(WOLFSSH_CTX *ctx, const char *list, const char **field,
unsigned step)
{
unpublished();
wiped();
assert(imports == 1 && import_error == 0 && frees == 0 && callbacks == 0);
assert(ctx == &candidate && ++setters == step);
assert(list != NULL && list[0] != '\0');
if (step == fail_setter) return setter_error;
*field = list;
return WS_SUCCESS;
}
#define SETTER(name, field, step) \
int wolfSSH_CTX_SetAlgoList##name(WOLFSSH_CTX *ctx, const char *list) \
{ return set_list(ctx, list, &ctx->field, step); }
SETTER(Kex, algoListKex, 1)
SETTER(Key, algoListKey, 2)
SETTER(Cipher, algoListCipher, 3)
SETTER(Mac, algoListMac, 4)
SETTER(KeyAccepted, algoListKeyAccepted, 5)
static void bounded_ssh_receive(void) {}
static void authenticate_user(void) {}
static void allowed_auth_types(void) {}
static void authentication_result(void) {}
static void reject_keyboard_auth(void) {}
static void accept_shell(void) {}
static void reject_channel_request(void) {}
static int callback(WOLFSSH_CTX *ctx, void (*actual)(void),
void (*expected)(void), unsigned step)
{
unpublished();
wiped();
assert(ctx == &candidate && setters == 5 && fail_setter == 0 && frees == 0);
assert(++callbacks == step && actual == expected);
return WS_SUCCESS;
}
#define CALLBACK(name, expected, step) \
static int name(WOLFSSH_CTX *ctx, void (*cb)(void)) \
{ return callback(ctx, cb, expected, step); }
CALLBACK(wolfSSH_SetIORecv, bounded_ssh_receive, 1)
CALLBACK(wolfSSH_SetUserAuth, authenticate_user, 2)
CALLBACK(wolfSSH_SetUserAuthTypes, allowed_auth_types, 3)
CALLBACK(wolfSSH_SetUserAuthResult, authentication_result, 4)
CALLBACK(wolfSSH_SetKeyboardAuthPrompts, reject_keyboard_auth, 5)
CALLBACK(wolfSSH_CTX_SetChannelReqShellCb, accept_shell, 6)
CALLBACK(wolfSSH_CTX_SetChannelReqExecCb, reject_channel_request, 7)
CALLBACK(wolfSSH_CTX_SetChannelReqSubsysCb, reject_channel_request, 8)
#include "context_actual.c"
static void reset(void)
{
memset(&candidate, 0, sizeof(candidate));
s_context = NULL;
copies = news = imports = wipes = setters = callbacks = frees = 0;
fail_setter = 0;
copy_error = allocation_fail = import_error = setter_error = 0;
identity = NULL;
identity_capacity = 0;
}
static void failed(int expected)
{
assert(create_context() == expected);
assert(s_context == NULL && callbacks == 0 && wipes == 1 && copies == 1);
/* identity points at a retired stack frame now: never inspect it here. */
identity = NULL;
}
int main(void)
{
unsigned cases = 0;
reset();
copy_error = 0x4321;
failed(copy_error);
assert(news == 0 && imports == 0 && setters == 0 && frees == 0);
++cases;
reset();
allocation_fail = 1;
failed(ESP_ERR_NO_MEM);
assert(news == 1 && imports == 0 && setters == 0 && frees == 0);
++cases;
for (unsigned sign = 0; sign < 2; ++sign) {
reset();
import_error = sign ? 7001 : -7001;
failed(ESP_FAIL);
assert(news == 1 && imports == 1 && setters == 0 && frees == 1);
++cases;
}
for (unsigned step = 1; step <= 5; ++step) {
for (unsigned sign = 0; sign < 2; ++sign) {
reset();
fail_setter = step;
setter_error = sign ? (int)(8000 + step) : -(int)(8000 + step);
failed(ESP_FAIL);
assert(news == 1 && imports == 1 && setters == step && frees == 1);
++cases;
}
}
reset();
assert(create_context() == ESP_OK);
identity = NULL;
assert(s_context == &candidate);
assert(copies == 1 && news == 1 && imports == 1 && wipes == 1);
assert(setters == 5 && callbacks == 8 && frees == 0);
++cases;
assert(cases == 15);
puts("PASS: actual create_context + policy: 15 cases, full identity wipe before policy/callbacks/free, exact cleanup, publication only after success");
return 0;
}
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Bounded, offline policy/vendor contracts using the production compile profile."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
VENDOR = ROOT / "managed_components/wolfssl__wolfssh"
ENV = {**os.environ, "CCACHE_DISABLE": "1"}
sys.dont_write_bytecode = True
sys.path.insert(0, str(ROOT / "tools"))
from security_overrides import ENTRIES, render_entry
HASHES = {
"internal.c": "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9",
"ssh.c": "a4f479ff87eea0980ec1ebdf2c7dd090da473780181b695a56799cb9611f4366",
}
FIELDS = ("Kex", "Key", "Cipher", "Mac", "KeyAccepted")
REQUIRED = {
"curve25519-sha256": ("ID_CURVE25519_SHA256", "TYPE_KEX"),
"ecdh-sha2-nistp256": ("ID_ECDH_SHA2_NISTP256", "TYPE_KEX"),
"ecdsa-sha2-nistp256": ("ID_ECDSA_SHA2_NISTP256", "TYPE_KEY"),
"aes128-gcm@openssh.com": ("ID_AES128_GCM", "TYPE_CIPHER"),
"aes256-gcm@openssh.com": ("ID_AES256_GCM", "TYPE_CIPHER"),
"hmac-sha2-256": ("ID_HMAC_SHA2_256", "TYPE_MAC"),
"ssh-ed25519": ("ID_ED25519", "TYPE_KEY"),
}
def run(args, **kwargs):
return subprocess.run(args, env=ENV, timeout=30, check=True, **kwargs)
def extract(source, name):
# Mask comments/strings without changing offsets; match definitions only.
masked = re.sub(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'',
lambda m: " " * len(m[0]), source, flags=re.S)
pattern = (r"(?m)^(?:static )?(?:INLINE )?(?:const )?"
r"(?:int|void|byte|word32|char|esp_err_t)\s*\*?\s*" + re.escape(name) +
r"\s*\([^;{}]*\)\s*\{")
matches = list(re.finditer(pattern, masked))
if len(matches) != 1:
raise RuntimeError(f"Expected one definition of {name}, got {len(matches)}")
start = matches[0].start()
end = masked.index("{", start) + 1
depth = 1
while depth:
depth += (masked[end] == "{") - (masked[end] == "}")
end += 1
return source[start:end] + "\n"
def source_path(entry):
return (Path(entry["directory"]) / entry["file"]).resolve()
def compiler_command(database, override, expected):
entries = json.loads(database.read_text())
original = (VENDOR / "src/internal.c").resolve()
if any(source_path(e) == original for e in entries):
raise RuntimeError("Production still compiles original internal.c; reconfigure the build")
suffix = ("security_overrides", override.name, "internal.c")
matches = [e for e in entries if source_path(e).parts[-3:] == suffix]
if len(matches) != 1:
raise RuntimeError(f"Expected one generated wolfSSH compile entry, found {len(matches)}")
entry = matches[0]
actual = source_path(entry).read_bytes()
if actual != expected:
raise RuntimeError("Generated wolfSSH source differs from render_entry; reconfigure the build")
args = entry.get("arguments") or shlex.split(entry["command"])
clean = []
skip = False
for arg in args:
if skip:
skip = False
elif arg in ("-o", "-MF", "-MT", "-MQ"):
skip = True
elif arg not in ("-c", "-MD", "-MMD", "-MP"):
clean.append(arg)
return entry, clean
def check_profile(macros, mapping):
if macros.get("LIBWOLFSSH_VERSION_HEX") != "0x01004020":
raise RuntimeError("Expected reviewed wolfSSH 1.4.20 compiler profile")
required = ("WC_RNG_SEED_CB", "NO_WOLFSSL_ESP32_CRYPT_AES",
"NO_WOLFSSL_ESP32_CRYPT_HASH", "WOLFSSL_ED25519_STREAMING_VERIFY",
"HAVE_CURVE25519", "HAVE_ECC", "HAVE_ED25519", "HAVE_AESGCM")
for name in required:
if name not in macros:
raise RuntimeError(f"Required resolved crypto/RNG feature missing: {name}")
disabled = ("WOLFSSH_NO_CURVE25519_SHA256", "WOLFSSH_NO_ECDH_SHA2_NISTP256",
"WOLFSSH_NO_ECDSA_SHA2_NISTP256", "WOLFSSH_NO_AES_GCM",
"WOLFSSH_NO_HMAC_SHA2_256", "WOLFSSH_NO_ED25519")
for name in disabled:
if name in macros:
raise RuntimeError(f"Policy algorithm disabled: {name}")
for name, (identifier, category) in REQUIRED.items():
row = r'\{\s*' + identifier + r'\s*,\s*' + category + r'\s*,\s*"' + re.escape(name) + r'"\s*\}'
if len(re.findall(row, mapping)) != 1:
raise RuntimeError(f"Missing/ambiguous resolved algorithm name/ID/type: {name}")
def enum_containing(source, token):
matches = [m[0] for m in re.finditer(r"(?m)^enum(?: \w+)?\s*\{[^{}]*\};", source)
if re.search(r"\b" + re.escape(token) + r"\b", m[0])]
if len(matches) != 1:
raise RuntimeError(f"Expected one resolved enum containing {token}")
return matches[0] + "\n"
def main():
parser = argparse.ArgumentParser(description=__doc__)
databases = sorted((ROOT / ".pio/build").glob("*/compile_commands.json"))
default = databases[0] if len(databases) == 1 else ROOT / "compile_commands.json"
parser.add_argument("--compile-commands", type=Path, default=default)
options = parser.parse_args()
sources = {}
for name, expected in HASHES.items():
raw = (VENDOR / "src" / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected:
raise RuntimeError(f"Vendor {name} changed; re-audit before updating pin")
sources[name] = raw.decode()
manifest = (ROOT / "src/idf_component.yml").read_text()
if not re.search(r'^\s*wolfssl/wolfssh:\s*"1\.4\.20"\s*$', manifest, re.M):
raise RuntimeError("Expected exact application wolfSSH 1.4.20 pin")
overrides = [e for e in ENTRIES if e.component == "wolfssl__wolfssh" and
e.source == "managed_components/wolfssl__wolfssh/src/internal.c"]
if (len(overrides) != 1 or overrides[0].root != "project" or
overrides[0].sha256 != HASHES["internal.c"]):
raise RuntimeError("Expected one independently pinned project wolfSSH override")
override = overrides[0]
_, expected = render_entry(override, {"project": ROOT})
entry, command = compiler_command(options.compile_commands, override, expected)
internal = expected.decode()
# The generated memory-hardening changes must not silently change protocol
# defaults or the feature-filtered name/ID map independently of this policy.
for name, pattern in (
("NameIdMap", r"static const NameIdPair NameIdMap\[\].*?\n\};"),
*((name, r"static const char " + name + r"\[\].*?;") for name in
("cannedKexAlgoNames", "cannedKeyAlgoNames", "cannedEncAlgoNames",
"cannedMacAlgoNames", "cannedNoneNames"))):
original = re.search(pattern, sources["internal.c"], re.S)
transformed = re.search(pattern, internal, re.S)
if original is None or transformed is None or original[0] != transformed[0]:
raise RuntimeError(f"Override changed reviewed algorithm definitions: {name}")
print("PASS: generated compiler input equals render_entry; original pinned algorithm tables unchanged", flush=True)
resolved = run(command + ["-E", "-P"], cwd=entry["directory"],
capture_output=True, text=True).stdout
macro_text = run(command + ["-E", "-dM"], cwd=entry["directory"],
capture_output=True, text=True).stdout
macros = dict(re.findall(r'^#define (\w+)(?: (.*))?$', macro_text, re.M))
mapping = re.search(r'static const NameIdPair NameIdMap\[\]\s*=\s*\{.*?\n\};',
resolved, re.S)[0]
check_profile(macros, mapping)
# The feature checker must not turn into a support-only, always-green test.
for name in REQUIRED:
try:
check_profile(macros, mapping.replace('"' + name + '"', '"removed"'))
except RuntimeError:
pass
else:
raise AssertionError(f"Missing algorithm was not detected: {name}")
print("PASS: production compiler resolved all seven name/ID/type entries and required crypto/RNG features", flush=True)
# Compile the helper against real target headers/settings, even before the
# parent has registered its translation unit in CMake.
target = [str(ROOT / "src/ssh_protocol_policy.c") if
arg == entry["file"] else arg for arg in command]
if target == command:
raise RuntimeError("Could not replace vendor input in compiler command")
run(target + ["-fsyntax-only"], cwd=entry["directory"], capture_output=True, text=True)
print("PASS: policy syntax with real target compiler and headers", flush=True)
functions = ("NameToId", "IdToName", "AlgoListSz", "CopyNameList",
"CopyNameListPlus", "BuildNameList", "SendKexInit", "SendExtInfo")
actual = "\n".join(extract(sources["ssh.c"], "wolfSSH_CTX_SetAlgoList" + field)
for field in FIELDS)
for name in functions:
if extract(internal, name) != extract(sources["internal.c"], name):
raise RuntimeError(f"Override changed reviewed protocol function: {name}")
actual += "\n".join(extract(internal, name) for name in functions)
# Preserve actual conditional enum values and feature-filtered name table.
types = "\n".join(enum_containing(resolved, token) for token in
("ID_NONE", "TYPE_KEX", "MSGID_KEXINIT", "WOLFSSH_ENDPOINT_SERVER"))
types += "typedef struct { byte id; byte type; const char *name; } NameIdPair;\n" + mapping
assignments = []
for field in FIELDS:
line = f"ssh->algoList{field} = ctx->algoList{field};"
if resolved.count(line) != 1:
raise RuntimeError(f"Re-audit SshInit pointer inheritance: {field}")
assignments.append(line)
with tempfile.TemporaryDirectory(prefix="ssh-protocol-policy-") as directory:
temp = Path(directory)
# Fail closed on stale/ambiguous databases and a stale generated render.
entries = json.loads(options.compile_commands.read_text())
original_entry = {**entry, "file": str(VENDOR / "src/internal.c")}
database_cases = (
(entries + [original_entry], expected),
([e for e in entries if source_path(e) != source_path(entry)], expected),
(entries + [entry], expected),
(entries, expected + b"\n/* stale render */\n"),
)
for index, (bad_entries, bad_expected) in enumerate(database_cases):
database = temp / f"bad-database-{index}.json"
database.write_text(json.dumps(bad_entries))
try:
compiler_command(database, override, bad_expected)
except RuntimeError:
pass
else:
raise AssertionError(f"Unsafe generated compiler profile accepted: {index}")
print("PASS: original/missing/duplicate compile entries and stale render rejected", flush=True)
headers = temp / "wolfssh"
headers.mkdir()
(headers / "ssh.h").write_text('#include "support.h"\n')
(headers / "settings.h").write_text("/* Host layout double only. */\n")
for name in ("error.h", "version.h"):
shutil.copyfile(VENDOR / "wolfssh" / name, headers / name)
(temp / "resolved.h").write_text(types)
(temp / "vendor_actual.c").write_text(actual)
transport = (ROOT / "src/ssh_transport.c").read_text()
(temp / "context_actual.c").write_text(extract(transport, "create_context"))
security_header = (ROOT / "src/ssh_security.h").read_text()
capacity = re.search(r'^#define SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY\s+\d+U?$',
security_header, re.M)
if capacity is None:
raise RuntimeError("Re-audit private-key staging capacity definition")
(temp / "context_constants.h").write_text(
capacity[0] + "\n" + enum_containing(resolved, "WOLFSSH_ENDPOINT_SERVER") +
enum_containing(resolved, "WOLFSSH_FORMAT_ASN1"))
(temp / "session_lists.inc").write_text(
"{ WOLFSSH_CTX *ctx = context;\n" + "\n".join(assignments) + "\n}\n")
cc = shlex.split(os.environ.get("CC", "cc"))
flags = ["-std=c99", "-Wall", "-Wextra", "-Werror", "-I", str(temp),
"-I", str(HERE), "-I", str(ROOT / "src")]
policy = str(ROOT / "src/ssh_protocol_policy.c")
for name in ("apply", "context", "vendor"):
binary = temp / name
run(cc + flags + [policy, str(HERE / (name + ".c")), "-o", str(binary)])
subprocess.run([str(binary)], env=ENV, check=True, timeout=10)
version = (headers / "version.h").read_text()
if '"1.4.20"' not in version or "0x01004020" not in version:
raise RuntimeError("Unexpected vendor version header")
for replacement in ("0x01004019", "0x01004021"):
(headers / "version.h").write_text(version.replace("0x01004020", replacement))
result = subprocess.run(cc + flags + ["-fsyntax-only", policy], env=ENV,
capture_output=True, text=True, timeout=30)
if result.returncode == 0 or "Re-audit SSH protocol policy" not in result.stderr:
raise RuntimeError("Policy version guard did not reject unreviewed version")
print("PASS: older/newer wolfSSH versions rejected by production guard", flush=True)
print("PASS: source hashes, exact manifest pin; no downloads/build/device operations")
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
/* Reduced host layout only, not a wolfSSH ABI or crypto implementation. */
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <wolfssh/error.h>
typedef uint8_t byte;
typedef uint32_t word32;
typedef struct WOLFSSH_CTX {
const char *algoListKex, *algoListKey, *algoListCipher, *algoListMac;
const char *algoListKeyAccepted;
int side;
unsigned privateKeyCount;
void *heap;
byte publicKeyAlgo[8];
word32 publicKeyAlgoCount;
unsigned sentinel;
} WOLFSSH_CTX;
int wolfSSH_CTX_SetAlgoListKex(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListKey(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListCipher(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListMac(WOLFSSH_CTX *, const char *);
int wolfSSH_CTX_SetAlgoListKeyAccepted(WOLFSSH_CTX *, const char *);
+239
View File
@@ -0,0 +1,239 @@
/* Actual vendor list/setter/serialization functions, with bounded host doubles.
* This tests plaintext SSH message payloads, not framing, crypto, or networking. */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ssh_protocol_policy.h"
#include "resolved.h"
#define WLOG(...) ((void)0)
#define INLINE inline
#define WMEMCPY memcpy
#define WSTRLEN strlen
#define XMEMCMP memcmp
#define WMALLOC(size, heap, type) bounded_alloc(size)
#define WFREE(ptr, heap, type) bounded_free(ptr)
#define MSG_ID_SZ 1U
#define UINT32_SZ 4U
#define LENGTH_SZ 4U
#define BOOLEAN_SZ 1U
#define COOKIE_SZ 16U
#define WS_EXTINFO_EXTENSION_COUNT 1
static const char cannedNoneNames[] = "none";
static const char serverSigAlgsName[] = "server-sig-algs";
typedef struct { byte *kexInit; word32 kexInitSz; } HandshakeInfo;
typedef struct {
WOLFSSH_CTX *ctx;
const char *algoListKex, *algoListKey, *algoListCipher, *algoListMac;
const char *algoListKeyAccepted;
int isKeying;
HandshakeInfo *handshake;
void *rng;
struct { byte *buffer; word32 length; } outputBuffer;
} WOLFSSH;
static byte packet[1024], saved_kex[1024];
static HandshakeInfo handshake;
static word32 planned;
static unsigned sends, allocations, frees, purges;
static int pool_in_use, allocation_fail, prepare_error, send_error;
static void *bounded_alloc(size_t size)
{
assert(size <= sizeof(saved_kex));
if (allocation_fail) return NULL;
assert(!pool_in_use);
pool_in_use = 1;
++allocations;
return saved_kex;
}
static void bounded_free(void *ptr)
{
assert(ptr == saved_kex && pool_in_use);
pool_in_use = 0;
++frees;
}
static HandshakeInfo *HandshakeInfoNew(void *heap)
{
(void)heap;
assert(handshake.kexInit == NULL);
return &handshake;
}
static void c32toa(word32 value, byte *out)
{
out[0] = (byte)(value >> 24);
out[1] = (byte)(value >> 16);
out[2] = (byte)(value >> 8);
out[3] = (byte)value;
}
static int wc_RNG_GenerateBlock(void *rng, byte *out, word32 size)
{
(void)rng;
assert(size == COOKIE_SZ);
memset(out, 0x5a, size);
return WS_SUCCESS;
}
static int PreparePacket(WOLFSSH *ssh, word32 payload_size)
{
if (prepare_error) return prepare_error;
assert(payload_size + 16U <= sizeof(packet));
memset(packet, 0xa5, sizeof(packet));
ssh->outputBuffer.buffer = packet;
ssh->outputBuffer.length = 8;
planned = payload_size;
return WS_SUCCESS;
}
static int BundlePacket(WOLFSSH *ssh)
{
assert(ssh->outputBuffer.length == planned + 8U);
for (unsigned i = 0; i < 8; ++i) assert(packet[i] == 0xa5);
for (size_t i = ssh->outputBuffer.length; i < sizeof(packet); ++i)
assert(packet[i] == 0xa5);
return WS_SUCCESS;
}
static int wolfSSH_SendPacket(WOLFSSH *ssh)
{
(void)ssh;
++sends;
return send_error;
}
static void PurgePacket(WOLFSSH *ssh)
{
if (ssh != NULL) ssh->outputBuffer.length = 0;
++purges;
}
#include "vendor_actual.c"
static word32 take_u32(const byte *data, size_t length, size_t *offset)
{
assert(*offset <= length && length - *offset >= 4);
const byte *p = data + *offset;
*offset += 4;
return ((word32)p[0] << 24) | ((word32)p[1] << 16) |
((word32)p[2] << 8) | p[3];
}
static void expect_name(const byte *data, size_t length, size_t *offset,
const char *expected)
{
word32 size = take_u32(data, length, offset);
assert(size == strlen(expected));
assert(*offset <= length && size <= length - *offset);
assert(memcmp(data + *offset, expected, size) == 0);
*offset += size;
}
static void check_kex(const WOLFSSH *ssh)
{
const byte *p = packet + 8;
size_t length = ssh->outputBuffer.length - 8U, offset = 1U + COOKIE_SZ;
assert(p[0] == MSGID_KEXINIT);
for (unsigned i = 1; i <= COOKIE_SZ; ++i) assert(p[i] == 0x5a);
expect_name(p, length, &offset, "curve25519-sha256,ecdh-sha2-nistp256");
expect_name(p, length, &offset, "ecdsa-sha2-nistp256");
/* Decode independently: both c2s and s2c must be exact, with no default
* CBC/CTR, AES192, extra KEX, or extra MAC fallback appended. */
for (unsigned direction = 0; direction < 2; ++direction)
expect_name(p, length, &offset,
"aes128-gcm@openssh.com,aes256-gcm@openssh.com");
for (unsigned direction = 0; direction < 2; ++direction)
expect_name(p, length, &offset, "hmac-sha2-256");
expect_name(p, length, &offset, "none");
expect_name(p, length, &offset, "none");
expect_name(p, length, &offset, "");
expect_name(p, length, &offset, "");
assert(offset < length && p[offset++] == 0);
assert(take_u32(p, length, &offset) == 0);
assert(offset == length);
assert(ssh->handshake->kexInitSz == length + 4);
offset = 0;
assert(take_u32(saved_kex, sizeof(saved_kex), &offset) == length);
assert(memcmp(saved_kex + 4, p, length) == 0);
}
static void check_names(void)
{
static const struct { const char *name; byte id, type; } required[] = {
{"curve25519-sha256", ID_CURVE25519_SHA256, TYPE_KEX},
{"ecdh-sha2-nistp256", ID_ECDH_SHA2_NISTP256, TYPE_KEX},
{"ecdsa-sha2-nistp256", ID_ECDSA_SHA2_NISTP256, TYPE_KEY},
{"aes128-gcm@openssh.com", ID_AES128_GCM, TYPE_CIPHER},
{"aes256-gcm@openssh.com", ID_AES256_GCM, TYPE_CIPHER},
{"hmac-sha2-256", ID_HMAC_SHA2_256, TYPE_MAC},
{"ssh-ed25519", ID_ED25519, TYPE_KEY},
};
for (size_t i = 0; i < sizeof(required) / sizeof(required[0]); ++i) {
assert(NameToId(required[i].name, (word32)strlen(required[i].name)) ==
required[i].id);
assert(strcmp(IdToName(required[i].id), required[i].name) == 0);
unsigned matches = 0;
for (size_t j = 0; j < sizeof(NameIdMap) / sizeof(NameIdMap[0]); ++j)
if (NameIdMap[j].id == required[i].id) {
assert(NameIdMap[j].type == required[i].type);
++matches;
}
assert(matches == 1);
}
assert(NameToId("not-an-algorithm", 16) == ID_UNKNOWN);
}
int main(void)
{
check_names();
WOLFSSH_CTX ctx = {.side = WOLFSSH_ENDPOINT_SERVER, .privateKeyCount = 1};
assert(ssh_protocol_policy_apply(NULL) == WS_SSH_CTX_NULL_E);
int (*const setters[])(WOLFSSH_CTX *, const char *) = {
wolfSSH_CTX_SetAlgoListKex, wolfSSH_CTX_SetAlgoListKey,
wolfSSH_CTX_SetAlgoListCipher, wolfSSH_CTX_SetAlgoListMac,
wolfSSH_CTX_SetAlgoListKeyAccepted,
};
for (size_t i = 0; i < sizeof(setters) / sizeof(setters[0]); ++i) {
assert(setters[i](NULL, "anything") == WS_SSH_CTX_NULL_E);
assert(setters[i](&ctx, "not-an-algorithm") == WS_SUCCESS);
assert(setters[i](&ctx, "") == WS_SUCCESS);
assert(setters[i](&ctx, NULL) == WS_SUCCESS);
}
assert(ssh_protocol_policy_apply(&ctx) == WS_SUCCESS);
/* Real SshInit's pointer assignments are extracted, but the rest of its
* allocation/crypto setup is deliberately not modeled. */
WOLFSSH session = {.ctx = &ctx};
WOLFSSH *ssh = &session;
WOLFSSH_CTX *context = &ctx;
(void)context;
#include "session_lists.inc"
assert(ssh->algoListKeyAccepted == ctx.algoListKeyAccepted);
assert(SendKexInit(ssh) == WS_SUCCESS);
check_kex(ssh);
assert(sends == 1 && allocations == 1 && frees == 0);
assert(SendKexInit(ssh) == WS_SUCCESS);
check_kex(ssh);
assert(sends == 2 && allocations == 2 && frees == 1);
assert(SendExtInfo(ssh) == WS_SUCCESS);
size_t offset = 1, length = ssh->outputBuffer.length - 8U;
const byte *p = packet + 8;
assert(p[0] == MSGID_EXT_INFO);
assert(take_u32(p, length, &offset) == 1);
expect_name(p, length, &offset, "server-sig-algs");
expect_name(p, length, &offset, "ssh-ed25519,ecdsa-sha2-nistp256");
assert(offset == length);
/* No key and injected packet/allocation failures must not send a fallback. */
unsigned before = sends;
ctx.privateKeyCount = 0;
assert(SendKexInit(ssh) == WS_BAD_ARGUMENT);
assert(sends == before);
ctx.privateKeyCount = 1;
prepare_error = WS_BUFFER_E;
assert(SendKexInit(ssh) == WS_BUFFER_E);
assert(sends == before);
prepare_error = 0;
allocation_fail = 1;
assert(SendKexInit(ssh) == WS_MEMORY_E);
assert(sends == before && !pool_in_use);
allocation_fail = 0;
send_error = WS_WANT_WRITE;
unsigned old_purges = purges;
assert(SendKexInit(ssh) == WS_WANT_WRITE);
assert(purges == old_purges);
check_kex(ssh);
bounded_free(handshake.kexInit);
puts("PASS: resolved vendor name/ID/type map, actual setters, initial/rekey KEXINIT both directions, server-sig-algs, bounded failure paths");
return 0;
}