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.
This commit is contained in:
ScottW514
2026-09-27 20:01:31 -04:00
parent cd84786fc5
commit 11031ad87f
6 changed files with 281 additions and 0 deletions
+1
View File
@@ -34,3 +34,4 @@ from . import homeoff # noqa: F401,E402
from . import bedsize # noqa: F401,E402 from . import bedsize # noqa: F401,E402
from . import extsender # noqa: F401,E402 from . import extsender # noqa: F401,E402
from . import tray # noqa: F401,E402 from . import tray # noqa: F401,E402
from . import tlsrec # noqa: F401,E402
+196
View File
@@ -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)
@@ -38,6 +38,8 @@ hostname:pn-base-files = "forgefirm"
# code without poky's CVE patches # code without poky's CVE patches
# no tpm, fips, dane, and no certificate compression (brotli, zlib, # no tpm, fips, dane, and no certificate compression (brotli, zlib,
# zstd), none of which the panel uses # 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" PACKAGECONFIG:pn-gnutls = "libtasn1"
# opengl stays: forgectrl's camera demosaic runs as GLES2 fragment # opengl stays: forgectrl's camera demosaic runs as GLES2 fragment
@@ -0,0 +1,42 @@
From dc016daf1af41d0285c61d3c65332b43d0ca577a Mon Sep 17 00:00:00 2001
From: Alexander Sosedkin <asosedkin@redhat.com>
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 <asosedkin@redhat.com>
---
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
@@ -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
@@ -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"