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.
296 lines
15 KiB
Python
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()
|