Files
MeshCore-mqtt-observer/scripts/mbedtls_4k.py
agessaman 74a3df3206 build(tls): bind the reduced-TLS archives to the framework they were built against
The manifest said "rebuild these for every espressif32 platform bump" and nothing
enforced it. mbedtls_4k.py verified the staged archives against the manifest's own
hashes, which proves the pair agrees with itself and nothing more: bump the
platform without rebuilding and every check still passes while the link takes
mbedTLS built against a different IDF. That fails at runtime on struct-layout
drift, not at the link, which is the failure the mechanism claimed to prevent.

Fingerprint the framework's own mbedTLS archives — the ones ours displace — as
stock: lines in the manifest and check them before the build. If the framework
moves, the staged pair is stale by construction and the build stops with the
replacement hashes printed ready to paste. Stronger than comparing a version
string: framework-arduinoespressif32 versions independently of the platform, and
its archives are what actually has to match.

The lib directory is resolved by trying the layouts espressif32 has used rather
than hardcoding one, and failing closed if none holds all four archives. The fetch
script ignores the new lines; its known-arches hint skips them so they cannot be
reported as architectures.
2026-08-14 18:41:29 -07:00

245 lines
10 KiB
Python

"""Link the reduced-TLS mbedTLS archives, and prove they were actually linked.
Opt in per build with MESHCORE_REDUCED_TLS=1. Off by default, so an ordinary build
needs no 6 MB artifact and behaves exactly as before.
MESHCORE_REDUCED_TLS=1 pio run -e Heltec_v3_repeater_observer_mqtt
The archives lower the mbedTLS outbound record buffer from 16 KiB to 4 KiB, saving
~12 KiB of internal DRAM per TLS connection on non-PSRAM observers. The inbound
buffer stays at 16 KiB, so the contiguous allocation a handshake needs is unchanged
— this buys headroom, it does not move that floor. See docs/mbedtls-tls-footprint.md.
Three failure modes this guards against, each of which produces a firmware that looks
fine and is silently wrong:
- a -L pointing at a missing or partial directory. The linker ignores an
unusable search path and quietly resolves mbedTLS from the framework instead.
- archives that do not match the manifest, e.g. left over from an earlier
platform version.
- a manifest and archives that agree with each other but not with the installed
framework, which is what a platform bump without a rebuild leaves behind. That
one links cleanly and drifts on struct layout at runtime.
So the opt-in path verifies every archive by sha256 before the build, checks the
framework's own mbedTLS archives still fingerprint as the ones these were built
against, and after the link re-reads firmware.map to confirm every libmbed*.a came
from our directory.
"""
Import("env")
import hashlib
import os
import sys
REQUIRED = ("libmbedcrypto.a", "libmbedtls_2.a", "libmbedtls.a", "libmbedx509.a")
def _fail(msg):
print("\n*** reduced-TLS build failed ***", file=sys.stderr)
print(msg, file=sys.stderr)
print(
"\nFetch the archives with: scripts/fetch_mbedtls_4k.sh <arch>"
"\nOr build without them by unsetting MESHCORE_REDUCED_TLS.",
file=sys.stderr,
)
env.Exit(1)
def _sha256(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _manifest(project_dir, arch):
path = os.path.join(project_dir, "scripts", "mbedtls_4k_manifest.txt")
if not os.path.isfile(path):
_fail("missing scripts/mbedtls_4k_manifest.txt")
wanted = {}
stock = {}
platform_id = ""
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) == 2 and parts[0] == "platform":
platform_id = parts[1]
elif len(parts) == 3 and parts[0] == "stock:" + arch:
stock[parts[2]] = parts[1]
elif len(parts) == 3 and parts[0] == arch:
wanted[parts[2]] = parts[1]
return wanted, stock, platform_id
# Where each espressif32 generation keeps the archives we displace. First directory
# holding all four wins, so this resolves without knowing which platform is in play.
FRAMEWORK_LIB_DIRS = (
("framework-arduinoespressif32", "tools/sdk/%s/lib"),
("framework-arduinoespressif32-libs", "%s/lib"),
("framework-arduinoespressif32", "tools/esp32-arduino-libs/%s/lib"),
)
def _framework_lib_dir(platform, arch):
for package, layout in FRAMEWORK_LIB_DIRS:
try:
base = platform.get_package_dir(package)
except Exception:
base = None
if not base:
continue
path = os.path.join(base, *(layout % arch).split("/"))
if all(os.path.isfile(os.path.join(path, name)) for name in REQUIRED):
return path
return None
if os.environ.get("MESHCORE_REDUCED_TLS", "") not in ("1", "true", "yes"):
Return()
project_dir = env.subst("$PROJECT_DIR")
arch = env.BoardConfig().get("build.mcu", "")
if not arch:
_fail("could not determine board MCU, so cannot pick an archive set")
staged = os.path.join(project_dir, ".mbedtls-4k", arch)
if not os.path.isdir(staged):
_fail("no archives for %s at %s" % (arch, staged))
wanted, stock, manifest_platform = _manifest(project_dir, arch)
if not wanted:
_fail("manifest has no entries for arch '%s'" % arch)
for name in REQUIRED:
archive = os.path.join(staged, name)
if not os.path.isfile(archive):
_fail("missing %s" % archive)
if name not in wanted:
_fail("%s is not in the manifest for %s" % (name, arch))
actual = _sha256(archive)
if actual != wanted[name]:
_fail(
"%s does not match the manifest\n expected %s\n actual %s\n"
"Rebuild it for this platform version, or re-run the fetch script."
% (archive, wanted[name], actual)
)
# Bind the staged archives to the framework they were built against. The hashes above
# only prove the staged files are the ones the manifest names; they say nothing about
# whether the manifest is still current. Bump the platform without rebuilding, and
# every check above still passes while the link takes mbedTLS built against a
# different IDF — a struct-layout drift that corrupts silently at runtime. So
# fingerprint the framework's own archives, the ones being displaced: if those moved,
# the staged pair is stale by construction.
platform = env.PioPlatform()
platform_id = "%s@%s" % (platform.name, platform.version)
stock_dir = _framework_lib_dir(platform, arch)
if stock_dir is None:
_fail("cannot locate the framework's own mbedTLS archives for %s, so the staged "
"ones cannot be tied to a framework version" % arch)
if not stock:
_fail("manifest has no stock:%s fingerprints — it predates the framework binding.\n"
"Add these lines for the framework now installed (%s):\n%s"
% (arch, platform_id,
"\n".join("stock:%s %s %s" % (arch, _sha256(os.path.join(stock_dir, n)), n)
for n in REQUIRED)))
for name in REQUIRED:
actual = _sha256(os.path.join(stock_dir, name))
if name not in stock:
_fail("manifest has no stock:%s entry for %s" % (arch, name))
if actual != stock[name]:
_fail(
"the framework's mbedTLS archives are not the ones these were built against.\n"
" %s\n manifest %s\n installed %s\n"
"Manifest records %s; installed is %s.\n"
"Rebuild the reduced-TLS archives against this framework "
"(docs/mbedtls-tls-footprint.md), then update every hash in the manifest."
% (os.path.join(stock_dir, name), stock[name], actual,
manifest_platform or "no platform", platform_id)
)
if manifest_platform and manifest_platform != platform_id:
# Hashes are the check; the version string is orientation. Identical archives
# under a renamed platform are not a compatibility problem.
print("reduced-TLS: manifest says %s, installed is %s — archives match, so this is "
"only a stale label" % (manifest_platform, platform_id))
# Prepend so these satisfy mbedTLS symbols ahead of the framework's own copies:
# the linker takes each archive member from the first archive that resolves it.
env.Prepend(LIBPATH=[staged])
print("reduced-TLS: linking mbedTLS from %s (verified)" % staged)
def _verify_map(source, target, env):
"""Confirm every mbedTLS archive in the link came from our directory.
Fails closed. Anything that stops this from *proving* the link — no map, an
unparsable map, a short archive list — is a failure, not a warning. A warning
here would leave exactly the hole the check exists to close: an opt-in build
that succeeds while silently linking the framework's 16 KiB buffers.
"""
# Derive the map name from PROGNAME rather than hardcoding firmware.map, so a
# renamed program cannot leave us inspecting a stale or absent file.
map_path = os.path.join(env.subst("$BUILD_DIR"),
env.subst("${PROGNAME}") + ".map")
if not os.path.isfile(map_path):
legacy = os.path.join(env.subst("$BUILD_DIR"), "firmware.map")
map_path = legacy if os.path.isfile(legacy) else map_path
if not os.path.isfile(map_path):
print("\n*** reduced-TLS: no linker map at %s ***" % map_path,
file=sys.stderr)
print("Cannot prove the reduced-TLS archives were linked. Ensure the env "
"emits a map (-Wl,-Map), or unset MESHCORE_REDUCED_TLS.",
file=sys.stderr)
env.Exit(1)
return
# The map records whatever the linker was given, which for a -L hit is a path
# relative to the linker's cwd (the project dir). Resolve before comparing, or
# every one of our own archives reads as stray.
staged_real = os.path.realpath(staged)
stray = set()
seen = set()
with open(map_path, errors="replace") as fh:
for line in fh:
for token in line.split():
if "libmbed" not in token or ".a" not in token:
continue
path = token.split("(")[0]
base = os.path.basename(path)
if not base.startswith("libmbed") or not base.endswith(".a"):
continue
seen.add(base)
resolved = os.path.realpath(os.path.join(project_dir, path))
if os.path.dirname(resolved) != staged_real:
stray.add(path)
if stray:
print("\n*** reduced-TLS: archives linked from the WRONG place ***",
file=sys.stderr)
for path in sorted(stray):
print(" " + path, file=sys.stderr)
env.Exit(1)
return
# Every required archive must appear. Seeing only some of them means the rest
# resolved somewhere this parse did not recognise, which is not proof of anything.
missing = [name for name in REQUIRED if name not in seen]
if missing:
print("\n*** reduced-TLS: %s names only %d of %d archives ***"
% (os.path.basename(map_path), len(seen), len(REQUIRED)),
file=sys.stderr)
print(" missing: " + ", ".join(missing), file=sys.stderr)
print("Either the map format changed or mbedTLS was resolved elsewhere; "
"the reduced buffers cannot be assumed.", file=sys.stderr)
env.Exit(1)
return
print("reduced-TLS: confirmed all %d archives linked from %s"
% (len(REQUIRED), staged))
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _verify_map)