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,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()
|
||||
Reference in New Issue
Block a user