Files
ESP32_Serial_Swiss_Army_Knife/tools/security_overrides.py
T
Commander1024 cdc9c7335a 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.
2026-09-15 22:12:57 +02:00

375 lines
15 KiB
Python

#!/usr/bin/env python3
"""Generate audited SDK sources in the build tree, never modify dependencies.
Extend ENTRIES with an independently pinned Entry (root='idf' or 'project').
Every Edit must match exactly once. CMake consumes the generated manifest and
replaces only the matching component source, retaining its compile properties.
"""
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import re
import sys
import tempfile
class OverrideError(ValueError):
pass
@dataclass(frozen=True)
class Edit:
old: str
new: str
@dataclass(frozen=True)
class Entry:
name: str
component: str
root: str
source: str
sha256: str
edits: tuple[Edit, ...]
WIPE = """/* Retired secret-bearing storage must not survive allocator reuse. */
static void security_override_wipe(void *buffer, size_t length)
{
volatile unsigned char *p = buffer;
while (length--) {
*p++ = 0;
}
}
"""
SCRATCH_RESIZE = """/* Keep shrinking as well as growing: no permanent maximum-size allocation.
* Allocation failure preserves the old buffer for normal request cleanup.
* A resize briefly owns both bounded allocations so the retired copy can wipe. */
static bool security_override_resize_scratch(struct httpd_req_aux *ra, size_t size)
{
if (size == ra->scratch_cur_size) {
return true;
}
char *replacement = malloc(size);
if (replacement == NULL) {
return false;
}
if (ra->scratch != NULL) {
memcpy(replacement, ra->scratch, MIN(size, ra->scratch_cur_size));
security_override_wipe(ra->scratch, ra->scratch_cur_size);
free(ra->scratch);
}
ra->scratch = replacement;
ra->scratch_cur_size = size;
return true;
}
"""
TLS_GUARDS = """/* The server profile and record-buffer wipe audit require these features.
* Client cipher configuration is deliberately unchanged. */
#if !defined(MBEDTLS_SSL_PROTO_TLS1_2) || !defined(MBEDTLS_SSL_SRV_C) || \\
!defined(MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED) || \\
!defined(MBEDTLS_ECDH_C) || !defined(MBEDTLS_ECDSA_C) || \\
!defined(MBEDTLS_AES_C) || !defined(MBEDTLS_GCM_C) || \\
!defined(MBEDTLS_SHA256_C) || !defined(MBEDTLS_SHA384_C)
#error "Security override requires TLS 1.2 ECDHE-ECDSA AES-GCM SHA256/SHA384"
#endif
#if defined(CONFIG_MBEDTLS_DYNAMIC_BUFFER)
#error "Reaudit dynamic TLS buffer destruction before enabling it"
#endif
"""
TLS_POLICY = """ /* mbedTLS retains this pointer: it must outlive every server session. */
static const int security_server_ciphersuites[] = {
MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
0
};
mbedtls_ssl_conf_ciphersuites(&tls->conf, security_server_ciphersuites);
mbedtls_ssl_conf_min_tls_version(&tls->conf, MBEDTLS_SSL_VERSION_TLS1_2);
mbedtls_ssl_conf_max_tls_version(&tls->conf, MBEDTLS_SSL_VERSION_TLS1_2);
#if defined(MBEDTLS_SSL_RENEGOTIATION)
mbedtls_ssl_conf_renegotiation(&tls->conf, MBEDTLS_SSL_RENEGOTIATION_DISABLED);
#endif
"""
ENTRIES = (
Entry("wolfssh_internal", "wolfssl__wolfssh", "project",
"managed_components/wolfssl__wolfssh/src/internal.c",
"81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9", (
Edit(""" WS_UserAuthData_Password* pw = NULL;
int ret = WS_SUCCESS;
""", """ WS_UserAuthData_Password* pw = NULL;
word32 passwordStart = 0;
int wipePassword = 0;
int ret = WS_SUCCESS;
"""),
Edit(""" WLOG(WS_LOG_DEBUG, "Entering DoUserAuthRequestPassword()");
if (ssh == NULL || authData == NULL ||
buf == NULL || len == 0 || idx == NULL) {
ret = WS_BAD_ARGUMENT;
}
if (ret == WS_SUCCESS) {
begin = *idx;
""", """ WLOG(WS_LOG_DEBUG, "Entering DoUserAuthRequestPassword()");
if (ssh == NULL || authData == NULL ||
buf == NULL || len == 0 || idx == NULL || ssh->ctx == NULL) {
ret = WS_BAD_ARGUMENT;
}
if (ret == WS_SUCCESS && *idx > len)
ret = WS_BUFFER_E;
if (ret == WS_SUCCESS) {
passwordStart = *idx;
wipePassword = 1;
begin = *idx;
"""),
Edit("ret = GetUint32(&pw->passwordSz, buf, len, &begin);",
"ret = GetSize(&pw->passwordSz, buf, len, &begin);"),
Edit("ret = GetUint32(&pw->newPasswordSz, buf, len, &begin);",
"ret = GetSize(&pw->newPasswordSz, buf, len, &begin);"),
Edit(""" if (ssh->ctx->userAuthCb != NULL) {
WLOG(WS_LOG_DEBUG, "DUARPW: Calling the userauth callback");
""", """ if (ret == WS_SUCCESS && ssh->ctx->userAuthCb != NULL) {
WLOG(WS_LOG_DEBUG, "DUARPW: Calling the userauth callback");
"""),
Edit(""" else {
WLOG(WS_LOG_DEBUG, "DUARPW: No user auth callback");
""", """ else if (ret == WS_SUCCESS) {
WLOG(WS_LOG_DEBUG, "DUARPW: No user auth callback");
"""),
Edit(""" if (authFailure || partialSuccess) {
ret = SendUserAuthFailure(ssh, partialSuccess);
}
else if (ret == WS_SUCCESS) {
ssh->clientState = CLIENT_USERAUTH_DONE;
}
WLOG(WS_LOG_DEBUG, "Leaving DoUserAuthRequestPassword(), ret = %d", ret);
""", """ /* Preserve the username/service/method prefix used by the caller.
* Wipe only the checked packet suffix, never callback pointers or declared
* credential sizes. ForceZero is the library's nonoptimizable primitive.
* Async callbacks need this payload for retry: the project never returns
* pending, but this is not a full secret-lifetime guarantee for async users. */
if (wipePassword && ret != WS_AUTH_PENDING)
ForceZero(buf + passwordStart, len - passwordStart);
if (authFailure || partialSuccess) {
ret = SendUserAuthFailure(ssh, partialSuccess);
}
else if (ret == WS_SUCCESS) {
ssh->clientState = CLIENT_USERAUTH_DONE;
}
WLOG(WS_LOG_DEBUG, "Leaving DoUserAuthRequestPassword(), ret = %d", ret);
"""),
)),
Entry("https_server", "esp_https_server", "idf",
"components/esp_https_server/src/https_server.c",
"6481942b62e51125e2a43441fa0900cbda74bd2ea05c82f0c29eb4933c31946e", (
Edit('const static char *TAG = "esp_https_server";\n',
WIPE + 'const static char *TAG = "esp_https_server";\n'),
Edit(""" if (!transport_ctx) {
esp_https_server_last_error_t last_error = {0};
last_error.last_error = ESP_ERR_NO_MEM;
http_dispatch_event_to_event_loop(HTTPS_SERVER_EVENT_ERROR, &last_error, sizeof(last_error));
return ESP_ERR_NO_MEM;
}
""", """ if (!transport_ctx) {
esp_https_server_last_error_t last_error = {0};
last_error.last_error = ESP_ERR_NO_MEM;
http_dispatch_event_to_event_loop(HTTPS_SERVER_EVENT_ERROR, &last_error, sizeof(last_error));
esp_tls_server_session_delete(tls);
return ESP_ERR_NO_MEM;
}
"""),
Edit(""" if (cfg->serverkey_buf) {
free((void *)cfg->serverkey_buf);
}
""", """ if (cfg->serverkey_buf) {
security_override_wipe((void *)cfg->serverkey_buf, cfg->serverkey_bytes);
free((void *)cfg->serverkey_buf);
}
"""),
Edit(""" ret = httpd_start(&handle, &config->httpd);
if (ret != ESP_OK) {
free(ssl_ctx);
ssl_ctx = NULL;
return ret;
}
""", """ ret = httpd_start(&handle, &config->httpd);
if (ret != ESP_OK) {
if (ssl_ctx != NULL) {
config->httpd.open_fn = ssl_ctx->open_fn;
free_secure_context(ssl_ctx);
config->httpd.global_transport_ctx = NULL;
config->httpd.global_transport_ctx_free_fn = NULL;
}
return ret;
}
"""),
)),
Entry("httpd_parse", "esp_http_server", "idf",
"components/esp_http_server/src/httpd_parse.c",
"6bba77064aaa68a06f8d4c01432064a1b050c91ed22741c547785b0d8a6c07d8", (
Edit('static const char *TAG = "httpd_parse";\n',
WIPE + SCRATCH_RESIZE + 'static const char *TAG = "httpd_parse";\n'),
Edit(" size_t at_offset = parser_data->last.at - raux->scratch;\n",
""" /* No parser position exists until a callback sets it. In particular,
* parse_init/init_req_aux start with both pointers NULL. */
bool has_at = parser_data->last.at != NULL && raux->scratch != NULL;
size_t at_offset = has_at ? parser_data->last.at - raux->scratch : 0;
"""),
Edit(" parser_data->last.at = raux->scratch + at_offset;\n",
" parser_data->last.at = has_at ? raux->scratch + at_offset : NULL;\n"),
Edit(""" raux->scratch = (char*) realloc(raux->scratch, offset + buf_len);
if (raux->scratch == NULL) {
""", """ if (!security_override_resize_scratch(raux, offset + buf_len)) {
"""),
Edit(" raux->scratch_cur_size = offset + buf_len;\n", ""),
Edit(""" free(ra->scratch);
ra->scratch = NULL;
""", """ security_override_wipe(ra->scratch, ra->scratch_cur_size);
free(ra->scratch);
ra->scratch = NULL;
"""),
)),
Entry("esp_tls_mbedtls", "esp-tls", "idf",
"components/esp-tls/esp_tls_mbedtls.c",
"09210c5a601647ca5775d127a2951bab2f3e509192b53487bbea8a93d8731b78", (
Edit('static const char *TAG = "esp-tls-mbedtls";\n',
TLS_GUARDS + 'static const char *TAG = "esp-tls-mbedtls";\n'),
Edit(" mbedtls_ssl_conf_set_user_data_p(&tls->conf, cfg->userdata);\n",
TLS_POLICY + " mbedtls_ssl_conf_set_user_data_p(&tls->conf, cfg->userdata);\n"),
)),
)
def apply_edits(text: str, edits: tuple[Edit, ...]) -> str:
for index, edit in enumerate(edits, 1):
count = text.count(edit.old) if edit.old else 0
if count != 1:
raise OverrideError(f"edit {index}: expected exactly one match, got {count}")
text = text.replace(edit.old, edit.new, 1)
return text
def verify_version(idf: Path) -> Path:
version = idf / "components/esp_common/include/esp_idf_version.h"
text = version.read_text(encoding="utf-8")
for part, expected in (("MAJOR", "5"), ("MINOR", "5"), ("PATCH", "0")):
found = re.findall(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$", text, re.M)
if found != [expected]:
raise OverrideError(f"requires ESP-IDF 5.5.0: {version} ({part}={found})")
return version
def render_entry(entry: Entry, roots: dict[str, Path]) -> tuple[Path, bytes]:
if not all(re.fullmatch(r"[a-zA-Z0-9_-]+", value) for value in (entry.name, entry.component)):
raise OverrideError("invalid entry name/component")
root = roots[entry.root].resolve()
source = (root / entry.source).resolve()
if not source.is_relative_to(root):
raise OverrideError(f"source escapes {entry.root}: {entry.source}")
raw = source.read_bytes()
actual = hashlib.sha256(raw).hexdigest()
if actual != entry.sha256:
raise OverrideError(f"{entry.name}: SHA256 mismatch for {source}: expected {entry.sha256}, got {actual}; reaudit, do not repin blindly")
return source, apply_edits(raw.decode("utf-8"), entry.edits).encode("utf-8")
def write_if_changed(path: Path, data: bytes) -> bool:
if path.exists() and path.read_bytes() == data:
return False
path.parent.mkdir(parents=True, exist_ok=True)
name = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as output:
name = output.name
output.write(data)
os.replace(name, path)
finally:
if name and os.path.exists(name):
os.unlink(name)
return True
def cmake_quote(value: str) -> str:
# Semicolons/newlines would turn one source into several CMake list entries.
if any(c in value for c in ";\n\r"):
raise OverrideError("unsupported character in CMake path")
return '"' + value.replace("\\", "/").replace('"', '\\"').replace("$", "\\$") + '"'
def generate(idf: Path, project: Path, binary: Path, entries: tuple[Entry, ...] = ENTRIES) -> Path:
idf, project, binary = idf.resolve(), project.resolve(), binary.resolve()
if binary.is_relative_to(idf) or binary == project or idf.is_relative_to(binary):
raise OverrideError("binary directory must be separate from SDK and project source root")
version = verify_version(idf)
roots = {"idf": idf, "project": project}
names = [entry.name for entry in entries]
if not names or len(set(names)) != len(names):
raise OverrideError("absent or duplicate override entries")
output = binary / "security_overrides"
rendered = []
seen = set()
# Validate the entire plan before writing any generated source or manifest.
for entry in entries:
source, data = render_entry(entry, roots)
if source in seen:
raise OverrideError(f"ambiguous duplicate source: {source}")
seen.add(source)
target = output / entry.name / source.name
rendered.append((entry, source, target, data))
lines = ["# Generated by tools/security_overrides.py; do not edit.",
"set(SAK_SECURITY_OVERRIDE_IDS " + " ".join(names) + ")",
"set(SAK_SECURITY_VERSION_HEADER " + cmake_quote(str(version)) + ")"]
for entry, source, target, _ in rendered:
for key, value in (("COMPONENT", entry.component), ("ORIGINAL", str(source)),
("GENERATED", str(target)), ("SHA256", entry.sha256)):
lines.append(f"set(SAK_SECURITY_{entry.name}_{key} {cmake_quote(value)})")
manifest = output / "manifest.cmake"
manifest_data = ("\n".join(lines) + "\n").encode("utf-8")
for path in [manifest] + [item[2] for item in rendered]:
resolved = path.resolve()
if not resolved.is_relative_to(binary) or resolved.is_relative_to(idf) or resolved in seen:
raise OverrideError(f"output escapes binary directory or aliases a source: {path}")
for _, _, target, data in rendered:
write_if_changed(target, data)
write_if_changed(manifest, manifest_data)
return manifest
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, required=True)
parser.add_argument("--project-dir", type=Path, required=True)
parser.add_argument("--binary-dir", type=Path, required=True)
args = parser.parse_args()
try:
generate(args.idf_path, args.project_dir, args.binary_dir)
except (OverrideError, OSError, UnicodeError, KeyError) as error:
print(f"security overrides: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())