From 11031ad87f479a4e1f43a84dc8bd6d6b2d911b0b Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 27 Sep 2026 20:01:31 -0400 Subject: [PATCH] GnuTLS with kernel TLS, and forgectrl.tls-records The gnutls bbappend builds GnuTLS with --enable-ktls and installs /etc/gnutls/config with ktls = true, so after the handshake the kernel seals and opens forgectrl's HTTPS records (the kernel side is meta-openglow's CONFIG_TLS and patch 0016). It also backports GnuTLS dc016daf: 3.8.4 hands the kernel the record sequence number where a TLS 1.2 ChaCha20-Poly1305 connection's IV belongs, so every such connection failed the kernel's first decryption. forgectrl is the only program on the image that links GnuTLS. forgectrl.tls-records, in its own module (suite/tlsrec.py), reads the result from the outside with openssl s_client on loopback: a desktop offer (AES-GCM first) gets ChaCha20-Poly1305 over TLS 1.3 and 1.2 and the kernel takes both directions' keys; an AES-128-GCM-only offer, three times over each protocol, gets it, the kernel takes its keys, and the CAAM's job-ring interrupt counts the records; a TLS 1.2 CBC-only offer connects and stays in GnuTLS (the control for the kernel's counters); every copy of the panel page equals the plain-HTTP copy byte for byte, with no decrypt error; the kernel's drivers are rfc7539(chacha20-neon,poly1305-neon) and ctr-aes-caam with ghash-ce. Proof: on the image before these fixes the test failed on both bugs it names (the TLS 1.2 ChaCha20 page arrived empty with a decrypt error; the AES-GCM pages arrived damaged); on image 20260927224418 it passes. The unit suite (500 tests) and the coverage lint (0 uncovered paths) pass on the host. --- forgetest/forgetest/suite/__init__.py | 1 + forgetest/forgetest/suite/tlsrec.py | 196 ++++++++++++++++++ meta-forgefirm/conf/distro/forgefirm.conf | 2 + ...ha20-Poly1305-IV-passing-for-TLS-1.2.patch | 42 ++++ .../recipes-support/gnutls/files/config | 9 + .../recipes-support/gnutls/gnutls_%.bbappend | 31 +++ 6 files changed, 281 insertions(+) create mode 100644 forgetest/forgetest/suite/tlsrec.py create mode 100644 meta-forgefirm/recipes-support/gnutls/files/0001-ktls-fix-ChaCha20-Poly1305-IV-passing-for-TLS-1.2.patch create mode 100644 meta-forgefirm/recipes-support/gnutls/files/config create mode 100644 meta-forgefirm/recipes-support/gnutls/gnutls_%.bbappend diff --git a/forgetest/forgetest/suite/__init__.py b/forgetest/forgetest/suite/__init__.py index 23cd70d..3e64a2c 100644 --- a/forgetest/forgetest/suite/__init__.py +++ b/forgetest/forgetest/suite/__init__.py @@ -34,3 +34,4 @@ from . import homeoff # noqa: F401,E402 from . import bedsize # noqa: F401,E402 from . import extsender # noqa: F401,E402 from . import tray # noqa: F401,E402 +from . import tlsrec # noqa: F401,E402 diff --git a/forgetest/forgetest/suite/tlsrec.py b/forgetest/forgetest/suite/tlsrec.py new file mode 100644 index 0000000..8c46bc7 --- /dev/null +++ b/forgetest/forgetest/suite/tlsrec.py @@ -0,0 +1,196 @@ +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT + +"""The cipher forgectrl's HTTPS listener chooses, and where its records +are sealed. + +The listener puts ChaCha20-Poly1305 first, whatever the client prefers, +and the image seals the records in the kernel: GnuTLS hands each +connection's keys to the socket after the handshake. ChaCha20-Poly1305 +runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20, +runs its AES on the CAAM crypto engine and its GHASH in NEON. This test +reads all of it from the outside: the cipher the listener picks for a +client that prefers AES-GCM, the kernel's TLS counters, the crypto +engine's interrupt, and the drivers the kernel resolved, with each +connection carrying the same page, which must arrive identical to the +plain-HTTP copy. + +Its own module: a test's fingerprint holds the shared text of the module +it lives in, so a test added to forgectrl.py would move the fingerprint +of every test there. +""" + +import os +import shutil +import subprocess +import tempfile +import urllib.request + +from ..catalog import test + +OPENSSL = "/usr/bin/openssl" +ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring +RECORD = 16384 # the largest TLS record's plaintext +PAGE = "/" # the panel page: large, and the same bytes every time +CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)" +# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions. +DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256" +DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305" + + +def _tls_stat(): + """The kernel's TLS counters, by name.""" + out = {} + with open("/proc/net/tls_stat") as f: + for line in f: + p = line.split() + if len(p) == 2 and p[1].isdigit(): + out[p[0]] = int(p[1]) + return out + + +def _engine_irqs(): + with open("/proc/interrupts") as f: + for line in f: + p = line.split() + if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit(): + return int(p[1]) + return None + + +def _driver(name): + """The driver answering an algorithm name: the entry of that name with + the highest priority in /proc/crypto.""" + best, drv, entry = -1, None, {} + with open("/proc/crypto") as f: + lines = f.read().splitlines() + [""] + for line in lines: + if not line.strip(): + if entry.get("name") == name and int(entry.get("priority", -1)) > best: + best, drv = int(entry["priority"]), entry.get("driver") + entry = {} + continue + k, _, v = line.partition(":") + entry[k.strip()] = v.strip() + return drv + + +def _fetch(version, offer, work): + """GET the page over HTTPS on loopback with the given offer. Returns + the suite, the status line, the body, the kernel counters' change, + and the crypto engine's interrupts during the connection.""" + req = os.path.join(work, "req") + with open(req, "wb") as f: + f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n") + opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer] + s0, i0 = _tls_stat(), _engine_irqs() + with open(req, "rb") as stdin: + r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt, + stdin=stdin, capture_output=True, timeout=60) + s1, i1 = _tls_stat(), _engine_irqs() + info = {} + for line in r.stderr.decode("utf-8", "replace").splitlines(): + k, _, v = line.partition(":") + info[k.strip()] = v.strip() + head, _, body = r.stdout.partition(b"\r\n\r\n") + delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")} + return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta, + (i1 or 0) - (i0 or 0)) + + +@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel", + subsystem="forgectrl", kind="auto", est_min=1, + covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")], + description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the " + "image seals the records in the kernel: GnuTLS hands each connection's keys to the " + "socket after the handshake (/etc/gnutls/config turns it on). A client that lists " + "AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and " + "TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the " + "NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel " + "takes its keys, and the crypto engine's interrupt counts the records: the AES runs " + "on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each " + "record by DMA while the CPU fills the next, so those connections run three times " + "over each protocol and every copy must be exact. A TLS 1.2 client " + "offering a CBC suite alone still connects and stays in GnuTLS, which is the control " + "for the kernel's counters; the ChaCha20 runs are the control for the engine's " + "interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, " + "and the kernel must count no decrypt error. Nothing on the machine changes.") +def tls_records(ctx): + ev = ctx.evidence + + conf = "" + if os.path.exists("/etc/gnutls/config"): + with open("/etc/gnutls/config") as f: + conf = f.read() + ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()), + "/etc/gnutls/config does not turn kernel TLS on: %r", conf) + with open("/proc/sys/net/ipv4/tcp_available_ulp") as f: + ulp = f.read().split() + ev["tcp_available_ulp"] = ulp + ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp) + ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ) + + with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r: + plain = r.read() + ev["page_bytes"] = len(plain) + ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain)) + records = len(plain) // RECORD + + work = tempfile.mkdtemp(prefix="forgetest-tls-") + try: + runs = {} + # The AES-GCM connections run three times each: their records are written by the crypto + # engine's DMA while the CPU fills the next one, which is where a record's last bytes were + # once lost. + cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True), + ("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)] + for n in (1, 2, 3): + cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True), + ("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256", + True)] + cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False)) + for label, version, offer, want, kernel in cases: + suite, status, body, delta, irqs = _fetch(version, offer, work) + run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body), + "same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs} + runs[label] = run + ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d", + label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs) + ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want) + ctx.check(status.endswith("200 OK") and body == plain, + "%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body), + len(plain)) + ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label, + delta["TlsDecryptError"]) + if kernel: + ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1, + "%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta) + else: + ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0, + "%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta) + ev["runs"] = runs + + # The AES of AES-GCM runs on the crypto engine: its interrupt counts + # the records. ChaCha20 never touches the engine, so those runs are + # the control. + for label in ("aes-1.3#1", "aes-1.2#1"): + ctx.check(runs[label]["engine_irqs"] >= records // 4, + "%s: %d records crossed and the crypto engine interrupted %d times", label, records, + runs[label]["engine_irqs"]) + for label in ("desktop-1.3", "desktop-1.2"): + ctx.check(runs[label]["engine_irqs"] < max(2, records // 8), + "%s: the crypto engine interrupted %d times for ChaCha20 records", label, + runs[label]["engine_irqs"]) + finally: + shutil.rmtree(work, ignore_errors=True) + + # The drivers the kernel resolved for the two record ciphers (both + # exist now: the connections above instantiated them). + chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)") + ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm} + ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm) + ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER) + ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm, + "the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm) diff --git a/meta-forgefirm/conf/distro/forgefirm.conf b/meta-forgefirm/conf/distro/forgefirm.conf index 62848bf..0240a7c 100644 --- a/meta-forgefirm/conf/distro/forgefirm.conf +++ b/meta-forgefirm/conf/distro/forgefirm.conf @@ -38,6 +38,8 @@ hostname:pn-base-files = "forgefirm" # code without poky's CVE patches # no tpm, fips, dane, and no certificate compression (brotli, zlib, # zstd), none of which the panel uses +# Kernel TLS is built in and turned on by the gnutls bbappend +# (recipes-support/gnutls), which also installs /etc/gnutls/config. PACKAGECONFIG:pn-gnutls = "libtasn1" # opengl stays: forgectrl's camera demosaic runs as GLES2 fragment diff --git a/meta-forgefirm/recipes-support/gnutls/files/0001-ktls-fix-ChaCha20-Poly1305-IV-passing-for-TLS-1.2.patch b/meta-forgefirm/recipes-support/gnutls/files/0001-ktls-fix-ChaCha20-Poly1305-IV-passing-for-TLS-1.2.patch new file mode 100644 index 0000000..fbf50fb --- /dev/null +++ b/meta-forgefirm/recipes-support/gnutls/files/0001-ktls-fix-ChaCha20-Poly1305-IV-passing-for-TLS-1.2.patch @@ -0,0 +1,42 @@ +From dc016daf1af41d0285c61d3c65332b43d0ca577a Mon Sep 17 00:00:00 2001 +From: Alexander Sosedkin +Date: Tue, 3 Mar 2026 19:47:25 +0100 +Subject: [PATCH] ktls: fix ChaCha20-Poly1305 IV passing for TLS 1.2 + +Upstream-Status: Backport [https://gitlab.com/gnutls/gnutls/-/commit/dc016daf1af41d0285c61d3c65332b43d0ca577a] + +Signed-off-by: Alexander Sosedkin +--- + lib/system/ktls.c | 6 ++---- + 1 file changed, 2 insertions(+), 4 deletions(-) + +diff --git a/lib/system/ktls.c b/lib/system/ktls.c +index a4c2a60df0..d531cfb85a 100644 +--- a/lib/system/ktls.c ++++ b/lib/system/ktls.c +@@ -679,10 +679,9 @@ int _gnutls_ktls_set_keys(gnutls_session_t session, + assert(cipher_key.size == + TLS_CIPHER_CHACHA20_POLY1305_KEY_SIZE); + +- /* for TLS 1.2 IV is generated in kernel */ + if (version == GNUTLS_TLS1_2) { + crypto_info.info.version = TLS_1_2_VERSION; +- memcpy(crypto_info.iv, seq_number, ++ memcpy(crypto_info.iv, iv.data, + TLS_CIPHER_CHACHA20_POLY1305_IV_SIZE); + } else { + crypto_info.info.version = TLS_1_3_VERSION; +@@ -854,10 +853,9 @@ int _gnutls_ktls_set_keys(gnutls_session_t session, + assert(cipher_key.size == + TLS_CIPHER_CHACHA20_POLY1305_KEY_SIZE); + +- /* for TLS 1.2 IV is generated in kernel */ + if (version == GNUTLS_TLS1_2) { + crypto_info.info.version = TLS_1_2_VERSION; +- memcpy(crypto_info.iv, seq_number, ++ memcpy(crypto_info.iv, iv.data, + TLS_CIPHER_CHACHA20_POLY1305_IV_SIZE); + } else { + crypto_info.info.version = TLS_1_3_VERSION; +-- +GitLab diff --git a/meta-forgefirm/recipes-support/gnutls/files/config b/meta-forgefirm/recipes-support/gnutls/files/config new file mode 100644 index 0000000..9418512 --- /dev/null +++ b/meta-forgefirm/recipes-support/gnutls/files/config @@ -0,0 +1,9 @@ +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# SPDX-License-Identifier: MIT +# +# GnuTLS's system-wide configuration. Kernel TLS on: after the handshake +# the kernel seals and opens the records. A connection on a cipher that +# kernel TLS does not take stays in user space. +[global] +ktls = true diff --git a/meta-forgefirm/recipes-support/gnutls/gnutls_%.bbappend b/meta-forgefirm/recipes-support/gnutls/gnutls_%.bbappend new file mode 100644 index 0000000..57faa52 --- /dev/null +++ b/meta-forgefirm/recipes-support/gnutls/gnutls_%.bbappend @@ -0,0 +1,31 @@ +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# SPDX-License-Identifier: MIT + +# Kernel TLS. GnuTLS runs the handshake, then gives the connection's keys +# to the socket, and the kernel seals and opens every record after that: +# ChaCha20-Poly1305 in NEON, AES-GCM with its AES on the CAAM crypto +# engine and its GHASH in NEON (the kernel options are in the BSP's +# kernel fragment). --enable-ktls builds the support in; /etc/gnutls/config +# turns it on, because the library leaves it off without that line. A +# connection on a cipher that kernel TLS does not take (AES-256-CCM, the +# CBC suites) stays in GnuTLS. forgectrl is the only program on the image +# that links GnuTLS (curl and Python use OpenSSL). The other build options +# are in conf/distro/forgefirm.conf. +# +# The backport: 3.8.4 hands the kernel the record sequence number where the +# connection's IV belongs for ChaCha20-Poly1305 over TLS 1.2, so every such +# connection fails the kernel's first decryption. Fixed upstream after 3.8.4. +FILESEXTRAPATHS:prepend := "${THISDIR}/files:" + +SRC_URI:append:class-target = " file://config \ + file://0001-ktls-fix-ChaCha20-Poly1305-IV-passing-for-TLS-1.2.patch" + +EXTRA_OECONF:append:class-target = " --enable-ktls" + +do_install:append:class-target() { + install -d ${D}${sysconfdir}/gnutls + install -m 0644 ${WORKDIR}/config ${D}${sysconfdir}/gnutls/config +} + +FILES:${PN}:append:class-target = " ${sysconfdir}/gnutls/config"