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,374 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user