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.
266 lines
13 KiB
Python
266 lines
13 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 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()
|