Files
ESP32_Serial_Swiss_Army_Knife/tests/ssh_protocol_policy/run.py
T
Commander1024 bea33e1c95 Add restricted wolfSSH ordering fix
Apply hash-pinned generated edits for CVE-2025-14942 while keeping
wolfSSH 1.4.20 managed sources unchanged. Add the ABI header overlay,
provenance records, and real state-machine interoperability contracts.
2026-09-16 14:04:34 +02:00

296 lines
15 KiB
Python

#!/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 reviewed_order_function(name, original):
"""Independent, exact allowlist; do not accept an arbitrary generator delta."""
if name == 'SendExtInfo':
return ('int SendExtInfo(WOLFSSH* ssh)\n{\n'
' WOLFSSH_UNUSED(ssh);\n return WS_NOT_COMPILED;\n}\n')
if name == 'SendKexInit':
edits = (
(' ssh->isKeying = 1;',
' /* Set self is keying flag since we started sending the KEX init msg */\n'
' ssh->isKeying |= WOLFSSH_SELF_IS_KEYING;'),
(' if (ssh->ctx->side == WOLFSSH_ENDPOINT_CLIENT) {\n'
' kexAlgoNamesPlus = ",ext-info-c";\n'
' kexAlgoNamesPlusSz = (word32)WSTRLEN(kexAlgoNamesPlus);\n }\n\n', ''),
(' if (ret == WS_SUCCESS)\n ret = wolfSSH_SendPacket(ssh);',
' if (ret == WS_SUCCESS) {\n ret = wolfSSH_SendPacket(ssh);\n }'),
)
for old, new in edits:
assert original.count(old) == 1, name
original = original.replace(old, new)
return original
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")
ssh_entry = next(e for e in ENTRIES if e.name == 'wolfssh_ssh')
_, generated_ssh = render_entry(ssh_entry, {'project': ROOT})
actual = ''
for field in FIELDS:
name = 'wolfSSH_CTX_SetAlgoList' + field
body = extract(generated_ssh.decode(), name)
assert body == extract(sources['ssh.c'], name), name
actual += body
for name in functions:
if extract(internal, name) != reviewed_order_function(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"))
assert macros['WOLFSSH_SELF_IS_KEYING'] == '0x02'
types += '#define WOLFSSH_SELF_IS_KEYING 0x02\n'
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()