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