mirror of
https://github.com/wolfSSL/wolfssl.git
synced 2026-10-24 04:09:07 +03:00
* Stop multi-msg-record depending on the peer's session ticket support scripts/multi-msg-record.test fails all eleven of its TLS 1.2 cases on any machine running tlslite-ng 0.8.0-beta1, whenever wolfSSL is built with session tickets. The wolfSSL client aborts the handshake with SESSION_TICKET_EXPECT_E (-390). That beta echoes an empty session_ticket extension in its ServerHello and sets send_session_ticket unconditionally, then sends no NewSessionTicket because its default HandshakeSettings carry no ticket keys. Rejecting that is correct behaviour on our side, per RFC 5077. Upstream fixed the server in 0.8.0-beta2 by gating the echo on ticket_count and ticketKeys, so only that one release is affected. HAVE_SESSION_TICKET alone decides it. A PRB-dtls run on the affected node makes that plain: three configurations, one commit, one box, and only the third, --enable-dtls --enable-dtls13 --enable-dtls-mtu --enable-session-ticket, failed. It is not specific to --enable-all, which merely happens to turn the feature on. Hide the extension from the tlslite server for the duration of the TLS 1.2 case, using the monkey patch the test already installs. Session tickets are not what this test covers, and releases from 0.8.0-beta2 on never echo the extension here anyway, so this changes nothing on a current tlslite-ng. Also report the wolfSSL client's return code and stderr on failure. Both runners preferred the server side traceback and dropped the client's output entirely, so a failing CI log showed a bare TLSAbruptCloseError from the socket layer and no sign of the -390 that caused it. Verified on master and on the failing branch, x86_64 Linux and macOS arm64, against tlslite-ng 0.8.0-beta1 and 0.8.2: 15 passed, 0 failed, 1 skipped in every combination. Before the change, 0.8.0-beta1 gives 4 passed, 11 failed. * Test that a promised but absent session ticket is rejected RFC 5077 requires a server that puts the session_ticket extension in its ServerHello to follow with a NewSessionTicket. wolfSSL answers a server that skips it with SESSION_TICKET_EXPECT_E, but nothing tested that. The behaviour only came to light because a tlslite-ng beta got it wrong, and a refactor could relax it into accepting the ticketless handshake without any test noticing. Drive the case deliberately: configure the tlslite server with ticket keys so it promises a ticket on every release, replace _serverSendTickets with an empty generator so it sends none, and require the client to fail with -390. The case skips itself when the client offers no session_ticket extension, which is how a build without HAVE_SESSION_TICKET reports. Checked both ways round. Leaving _serverSendTickets intact makes the handshake succeed and the case fail, so the assertion is specific to the missing ticket and not to any handshake error. Hiding the extension from the server, which is how a build without HAVE_SESSION_TICKET looks, makes the case skip rather than fail. * Pin tlslite-ng in the os-check workflow scripts/multi-msg-record.test speaks TLS to a tlslite-ng server, so that package decides what the 24 --enable-all entries in os-check-linux.json and the macOS entry are tested against, and whether the test runs at all rather than reporting SKIP. Installing it unpinned means any upstream release lands across the whole matrix at once, on whichever unrelated PR happens to run next. Pin it to 0.8.2, matching how advisory.yml and sbom.yml pin their Python dependencies. No behaviour change today: 0.8.2 is what an unpinned install already resolves to.
751 lines
27 KiB
Python
Executable File
751 lines
27 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
#
|
||
# multi-msg-record.py
|
||
#
|
||
# Python half of scripts/multi-msg-record.test (the bash wrapper handles
|
||
# NETWORK_UNSHARE_HELPER / AM_BWRAPPED and the python3 availability
|
||
# check, then execs this script).
|
||
#
|
||
# Tests that wolfSSL correctly processes TLS records containing multiple
|
||
# handshake messages packed into a single record.
|
||
#
|
||
# Uses tlslite-ng as the TLS peer to craft multi-message records:
|
||
#
|
||
# TLS 1.2 – Each connection tests TWO code paths back-to-back:
|
||
# 1. Initial handshake: RecordMergingSocket rewrites separate
|
||
# plaintext ServerHello + Certificate + ServerKeyExchange +
|
||
# ServerHelloDone records into one multi-message TLS
|
||
# record before forwarding to the wolfSSL client.
|
||
# 2. Renegotiation on the same connection: tlslite-ng is
|
||
# monkey-patched to coalesce SH+Cert+SKE+SHD into ONE
|
||
# encrypted handshake record (exercises the
|
||
# curSize -= padSz CBC-padding path and the AEAD path).
|
||
#
|
||
# TLS 1.3 – tlslite-ng's _queue_message / _queue_flush mechanism already
|
||
# coalesces EncryptedExtensions + Certificate + CertificateVerify
|
||
# + Finished into a single encrypted record. The test verifies
|
||
# that wolfSSL parses this correctly.
|
||
#
|
||
# Multiple cipher suites are tested for both protocol versions.
|
||
#
|
||
# Requirements: python3, tlslite-ng (pip install tlslite-ng)
|
||
|
||
import socket
|
||
import struct
|
||
import subprocess
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
import types
|
||
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
|
||
def _find_wolfssl_dir():
|
||
# Under `make check` the working directory is the build tree, which is
|
||
# where the client binary lives and which differs from the tree
|
||
# containing this script in an out-of-tree (VPATH) build. Fall back to
|
||
# the script's parent for direct invocation from the source tree.
|
||
for root in (os.getcwd(), os.path.dirname(SCRIPT_DIR)):
|
||
if os.path.isfile(os.path.join(root, "examples", "client", "client")):
|
||
return root
|
||
return os.path.dirname(SCRIPT_DIR)
|
||
|
||
|
||
WOLFSSL_DIR = _find_wolfssl_dir()
|
||
WOLF_CLIENT = os.path.join(WOLFSSL_DIR, "examples", "client", "client")
|
||
CERT_DIR = os.path.join(WOLFSSL_DIR, "certs")
|
||
|
||
# CA cert path passed to the wolfSSL client via -A. Set in main() after
|
||
# detect_wolf_features() determines whether the build accepts PEM or DER.
|
||
WOLF_CA_CERT = os.path.join(CERT_DIR, "ca-cert.pem")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Bypass a strict tlslite-ng validation that rejects wolfSSL's ClientHello
|
||
# when the client advertises FFDHE groups in a TLS-1.3-only hello.
|
||
# This must happen before importing TLSConnection.
|
||
#
|
||
# If tlslite-ng isn't installed we exit 77 so automake marks the test
|
||
# SKIPped instead of FAILed.
|
||
# ---------------------------------------------------------------------------
|
||
try:
|
||
import tlslite.tlsconnection # noqa: E402
|
||
import tlslite.recordlayer # noqa: E402
|
||
tlslite.tlsconnection.TLS_1_3_FORBIDDEN_GROUPS = frozenset()
|
||
|
||
from tlslite import ( # noqa: E402
|
||
TLSConnection, HandshakeSettings, X509CertChain, parsePEMKey,
|
||
)
|
||
from tlslite.constants import ContentType # noqa: E402
|
||
from tlslite.extensions import RenegotiationInfoExtension # noqa: E402
|
||
from tlslite.constants import ExtensionType # noqa: E402
|
||
from tlslite.messages import HelloMessage, Message as TLSMessage # noqa: E402
|
||
except ImportError as e:
|
||
sys.stdout.write(
|
||
"tlslite-ng not installed ({}); skipping multi-msg-record test\n"
|
||
" (install with: pip install tlslite-ng)\n".format(e))
|
||
sys.exit(77)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
HS_NAMES = {
|
||
2: "SH", 4: "NST", 8: "EE", 11: "Cert", 12: "SKE",
|
||
13: "CR", 14: "SHD", 15: "CV", 16: "CKE", 20: "Fin",
|
||
}
|
||
|
||
PASS_COUNT = 0
|
||
FAIL_COUNT = 0
|
||
SKIP_COUNT = 0
|
||
|
||
|
||
def passed(label):
|
||
global PASS_COUNT
|
||
PASS_COUNT += 1
|
||
print(f" PASS: {label}")
|
||
|
||
|
||
def failed(label):
|
||
global FAIL_COUNT
|
||
FAIL_COUNT += 1
|
||
print(f" FAIL: {label}")
|
||
|
||
|
||
def skipped(label):
|
||
global SKIP_COUNT
|
||
SKIP_COUNT += 1
|
||
print(f" SKIP: {label}")
|
||
|
||
|
||
def detect_wolf_features():
|
||
"""Probe the wolfSSL client binary to find which features are
|
||
compiled in. Used to decide which test phases to run.
|
||
|
||
Returns dict with keys: tls12 (bool), tls13 (bool),
|
||
secure_reneg (bool), rsa (bool), ciphers (set[str]), ca_cert (str).
|
||
"""
|
||
feats = {"tls12": False, "tls13": False, "secure_reneg": False,
|
||
"rsa": True,
|
||
"ciphers": set(),
|
||
"ca_cert": os.path.join(CERT_DIR, "ca-cert.pem")}
|
||
|
||
# ./client -V -> e.g. "3:4:d(downgrade):e(either):"
|
||
try:
|
||
r = subprocess.run([WOLF_CLIENT, "-V"],
|
||
capture_output=True, timeout=5)
|
||
parts = r.stdout.decode("utf-8", errors="replace").strip().split(":")
|
||
feats["tls12"] = "3" in parts
|
||
feats["tls13"] = "4" in parts
|
||
except Exception:
|
||
pass
|
||
|
||
# ./client -? -> help text includes "-R" only when
|
||
# HAVE_SECURE_RENEGOTIATION is defined. The default -A path
|
||
# ("ca-cert.pem" vs "ca-cert.der") also tells us which CA file
|
||
# format the build can load. The RSA key-size line reports
|
||
# "RSA not supported" when NO_RSA is defined.
|
||
try:
|
||
r = subprocess.run([WOLF_CLIENT, "-?"],
|
||
capture_output=True, timeout=5)
|
||
htxt = r.stdout.decode("utf-8", errors="replace")
|
||
feats["secure_reneg"] = ("Allow Secure Renegotiation" in htxt)
|
||
if "ca-cert.der" in htxt and "ca-cert.pem" not in htxt:
|
||
feats["ca_cert"] = os.path.join(CERT_DIR, "ca-cert.der")
|
||
if "RSA not supported" in htxt:
|
||
feats["rsa"] = False
|
||
except Exception:
|
||
pass
|
||
|
||
# ./client -e -> colon-separated list of supported cipher suites.
|
||
try:
|
||
r = subprocess.run([WOLF_CLIENT, "-e"],
|
||
capture_output=True, timeout=5)
|
||
ctxt = r.stdout.decode("utf-8", errors="replace").strip()
|
||
feats["ciphers"] = {c for c in ctxt.split(":") if c}
|
||
except Exception:
|
||
pass
|
||
|
||
return feats
|
||
|
||
|
||
def _load_chain(cert_file):
|
||
with open(cert_file) as f:
|
||
chain = X509CertChain()
|
||
chain.parsePemList(f.read())
|
||
return chain
|
||
|
||
|
||
def _load_key(key_file):
|
||
with open(key_file) as f:
|
||
return parsePEMKey(f.read(), private=True)
|
||
|
||
|
||
def _parse_hs_types(data):
|
||
"""Parse handshake message types from raw handshake content."""
|
||
msgs = []
|
||
off = 0
|
||
while off + 4 <= len(data):
|
||
ht = data[off]
|
||
hl = struct.unpack("!I", b"\x00" + bytes(data[off + 1 : off + 4]))[0]
|
||
msgs.append(HS_NAMES.get(ht, f"T{ht}"))
|
||
off += 4 + hl
|
||
return msgs
|
||
|
||
|
||
def _listen_socket():
|
||
"""Bind a listening TCP socket on localhost with the standard test timeout.
|
||
|
||
The port is read back from the socket that keeps it, rather than from a
|
||
throwaway one that is closed first: closing it leaves a window in which
|
||
anything else on the machine can take the port before the real bind.
|
||
"""
|
||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
srv.bind(("127.0.0.1", 0))
|
||
srv.listen(1)
|
||
srv.settimeout(15)
|
||
return srv, srv.getsockname()[1]
|
||
|
||
|
||
def _run_wolf_client(port, version, cipher, extra=()):
|
||
"""Invoke the wolfSSL example client against 127.0.0.1:port.
|
||
|
||
WOLF_CA_CERT is PEM or DER depending on the build (NO_CODING /
|
||
OPENSSL_EXTRA builds don't both support PEM).
|
||
"""
|
||
cmd = [WOLF_CLIENT, "-h", "127.0.0.1", "-p", str(port),
|
||
"-v", version, "-A", WOLF_CA_CERT,
|
||
"-g", *extra]
|
||
if cipher:
|
||
cmd.extend(["-l", cipher])
|
||
return subprocess.run(cmd, capture_output=True, timeout=15)
|
||
|
||
|
||
def _describe_failure(proc, result):
|
||
"""Render the client's and the server's side of a failed connection."""
|
||
client = proc.stderr.decode("utf-8", errors="replace").strip()
|
||
server = result["error"].strip()
|
||
parts = ["client rc={}".format(proc.returncode)]
|
||
if client:
|
||
parts.append("client: " + client[-400:])
|
||
if server:
|
||
parts.append("server: " + server[-400:])
|
||
return "; ".join(parts)
|
||
|
||
|
||
class _SendRecordTrace:
|
||
"""Context manager that wraps RecordLayer.sendRecord to log every record."""
|
||
|
||
def __init__(self):
|
||
self.log = []
|
||
self._orig = None
|
||
|
||
def __enter__(self):
|
||
self._orig = tlslite.recordlayer.RecordLayer.sendRecord
|
||
log = self.log
|
||
orig = self._orig
|
||
|
||
def wrapper(self_rl, msg):
|
||
data = msg.write()
|
||
ct = msg.contentType
|
||
encrypted = bool(self_rl._writeState
|
||
and self_rl._writeState.encContext)
|
||
hs_msgs = []
|
||
if ct == ContentType.handshake:
|
||
hs_msgs = _parse_hs_types(data)
|
||
log.append((ct, encrypted, len(data), hs_msgs))
|
||
yield from orig(self_rl, msg)
|
||
|
||
tlslite.recordlayer.RecordLayer.sendRecord = wrapper
|
||
return self.log
|
||
|
||
def __exit__(self, *exc):
|
||
tlslite.recordlayer.RecordLayer.sendRecord = self._orig
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RecordMergingSocket (TLS 1.2 plaintext record merging)
|
||
# ---------------------------------------------------------------------------
|
||
class RecordMergingSocket:
|
||
"""Socket wrapper that rewrites consecutive TLS handshake records into
|
||
a single multi-message record. Only merges plaintext records that
|
||
precede ChangeCipherSpec."""
|
||
|
||
def __init__(self, sock):
|
||
self._sock = sock
|
||
self._pending = bytearray()
|
||
self._ver = 0x0303
|
||
self._after_ccs = False
|
||
self.merged_msgs = [] # [(n_msgs, [names], size)]
|
||
|
||
def _flush(self):
|
||
if not self._pending:
|
||
return
|
||
msgs = _parse_hs_types(self._pending)
|
||
n = len(msgs)
|
||
hdr = struct.pack("!BHH", 22, self._ver, len(self._pending))
|
||
self._sock.sendall(hdr + bytes(self._pending))
|
||
self.merged_msgs.append((n, msgs, len(self._pending)))
|
||
self._pending = bytearray()
|
||
|
||
# Called by BufferedSocket (one record per call, or multiple from flush)
|
||
def _process(self, data):
|
||
data = bytearray(data)
|
||
off = 0
|
||
while off + 5 <= len(data):
|
||
ct = data[off]
|
||
ver = struct.unpack("!H", data[off + 1 : off + 3])[0]
|
||
rlen = struct.unpack("!H", data[off + 3 : off + 5])[0]
|
||
if off + 5 + rlen > len(data):
|
||
break
|
||
payload = data[off + 5 : off + 5 + rlen]
|
||
if not self._after_ccs and ct == 22:
|
||
self._pending.extend(payload)
|
||
self._ver = ver
|
||
else:
|
||
if ct == 20:
|
||
self._after_ccs = True
|
||
self._flush()
|
||
self._sock.sendall(bytes(data[off : off + 5 + rlen]))
|
||
off += 5 + rlen
|
||
|
||
def send(self, data):
|
||
self._process(data)
|
||
return len(data)
|
||
|
||
def sendall(self, data):
|
||
self._process(data)
|
||
|
||
def recv(self, bufsize):
|
||
self._flush()
|
||
return self._sock.recv(bufsize)
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(self._sock, name)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test runners
|
||
# ---------------------------------------------------------------------------
|
||
def run_tls12_test(cipher_wolf, cert_chain, priv_key, label,
|
||
do_reneg=True):
|
||
"""TLS 1.2 test – one connection optionally exercises two code paths:
|
||
|
||
Phase 1 (plaintext grouping, initial handshake):
|
||
RecordMergingSocket rewrites separate plaintext ServerHello,
|
||
Certificate, ServerKeyExchange and ServerHelloDone records into
|
||
one multi-message TLS record before delivery to wolfSSL.
|
||
|
||
Phase 2 (encrypted grouping, renegotiation on same connection):
|
||
tlslite-ng server is monkey-patched to coalesce SH+Cert+SKE+SHD
|
||
into a single encrypted handshake record inside the renegotiation
|
||
(exercises wolfSSL's encrypted multi-message parsing including
|
||
curSize -= padSz for CBC padding).
|
||
|
||
Phase 2 is skipped when do_reneg=False (e.g. the wolfSSL client was
|
||
built without HAVE_SECURE_RENEGOTIATION).
|
||
"""
|
||
srv, port = _listen_socket()
|
||
|
||
result = {"ok": False, "error": ""}
|
||
msock_ref = [None]
|
||
trace_log = []
|
||
reneg_active = [False]
|
||
verify_data = {'client': None, 'server': None}
|
||
|
||
# --- monkey-patches (used only during this connection) ----------------
|
||
orig_calc_key = tlslite.tlsconnection.calc_key
|
||
|
||
def capturing_calc_key(*args, **kwargs):
|
||
res = orig_calc_key(*args, **kwargs)
|
||
lbl = args[3] if len(args) > 3 else kwargs.get('label', b'')
|
||
if lbl == b"client finished" and verify_data['client'] is None:
|
||
verify_data['client'] = bytearray(res)
|
||
elif lbl == b"server finished" and verify_data['server'] is None:
|
||
verify_data['server'] = bytearray(res)
|
||
return res
|
||
|
||
orig_getExt = HelloMessage.getExtension
|
||
|
||
def patched_getExt(self, ext_type):
|
||
# tlslite-ng 0.8.0b1 promises a session ticket it never sends (-390).
|
||
if ext_type == ExtensionType.session_ticket:
|
||
return None
|
||
ext = orig_getExt(self, ext_type)
|
||
if (ext_type == ExtensionType.renegotiation_info
|
||
and ext is not None and reneg_active[0]):
|
||
ext._internal_value = bytearray(0)
|
||
return ext
|
||
|
||
orig_rie_create = RenegotiationInfoExtension.create
|
||
|
||
def patched_rie_create(self, data):
|
||
if reneg_active[0] and data == bytearray(0):
|
||
combined = (bytearray(verify_data['client'])
|
||
+ bytearray(verify_data['server']))
|
||
return orig_rie_create(self, combined)
|
||
return orig_rie_create(self, data)
|
||
|
||
# ----------------------------------------------------------------------
|
||
def server():
|
||
try:
|
||
tlslite.tlsconnection.calc_key = capturing_calc_key
|
||
HelloMessage.getExtension = patched_getExt
|
||
RenegotiationInfoExtension.create = patched_rie_create
|
||
|
||
conn, _ = srv.accept()
|
||
conn.settimeout(15)
|
||
msock = RecordMergingSocket(conn)
|
||
msock_ref[0] = msock
|
||
tls = TLSConnection(msock)
|
||
settings = HandshakeSettings()
|
||
settings.minVersion = (3, 3)
|
||
settings.maxVersion = (3, 3)
|
||
|
||
# ---------- Phase 1: initial handshake (plaintext grouping) ----
|
||
tls.handshakeServer(certChain=cert_chain, privateKey=priv_key,
|
||
settings=settings)
|
||
tlslite.tlsconnection.calc_key = orig_calc_key
|
||
|
||
data = tls.recv(4096)
|
||
|
||
if do_reneg:
|
||
# ---------- Phase 2: trigger + run renegotiation ----------
|
||
hr = TLSMessage(ContentType.handshake,
|
||
bytearray([0, 0, 0, 0]))
|
||
for _ in tls._sendMsg(hr, randomizeFirstBlock=False,
|
||
update_hashes=False):
|
||
pass
|
||
|
||
# Bypass tlslite-ng renegotiation guards
|
||
tls.closed = True
|
||
tls.session = None
|
||
reneg_active[0] = True
|
||
|
||
# Coalesce handshake messages into ONE encrypted TLS record
|
||
def coalescing_sendMsgs(self, msgs):
|
||
for msg in msgs:
|
||
self._queue_message(msg)
|
||
yield from self._queue_flush()
|
||
tls._sendMsgs = types.MethodType(coalescing_sendMsgs, tls)
|
||
|
||
with _SendRecordTrace() as log:
|
||
tls.handshakeServer(certChain=cert_chain,
|
||
privateKey=priv_key,
|
||
settings=settings)
|
||
reneg_active[0] = False
|
||
trace_log.extend(log)
|
||
|
||
if data:
|
||
tls.send(data)
|
||
tls.close()
|
||
result["ok"] = True
|
||
except Exception as e:
|
||
import traceback
|
||
result["error"] = traceback.format_exc()
|
||
finally:
|
||
tlslite.tlsconnection.calc_key = orig_calc_key
|
||
HelloMessage.getExtension = orig_getExt
|
||
RenegotiationInfoExtension.create = orig_rie_create
|
||
reneg_active[0] = False
|
||
srv.close()
|
||
|
||
st = threading.Thread(target=server, daemon=True)
|
||
st.start()
|
||
time.sleep(0.1)
|
||
|
||
proc = _run_wolf_client(port, "3", cipher_wolf,
|
||
extra=("-R",) if do_reneg else ())
|
||
st.join(timeout=5)
|
||
|
||
if proc.returncode != 0 or not result["ok"]:
|
||
err = _describe_failure(proc, result)
|
||
failed(f"{label}: connection failed ({err})")
|
||
return False
|
||
|
||
ok = True
|
||
|
||
# Phase 1 verification: plaintext multi-message record
|
||
msock = msock_ref[0]
|
||
has_pt_grouped = False
|
||
for n, msgs, sz in (msock.merged_msgs if msock else []):
|
||
if n > 1:
|
||
has_pt_grouped = True
|
||
passed(f"{label} [plaintext]: {n} msgs "
|
||
f"[{'+'.join(msgs)}] in one record ({sz} bytes)")
|
||
if not has_pt_grouped:
|
||
failed(f"{label} [plaintext]: no multi-message record detected")
|
||
ok = False
|
||
|
||
# Phase 2 verification: encrypted multi-message record (renego)
|
||
if do_reneg:
|
||
has_enc_grouped = False
|
||
for ct, enc, sz, msgs in trace_log:
|
||
if ct == ContentType.handshake and enc and len(msgs) > 1:
|
||
has_enc_grouped = True
|
||
passed(f"{label} [encrypted]: {len(msgs)} msgs "
|
||
f"[{'+'.join(msgs)}] in one record ({sz} bytes)")
|
||
if not has_enc_grouped:
|
||
failed(f"{label} [encrypted]: no multi-message "
|
||
f"encrypted record")
|
||
ok = False
|
||
|
||
return ok
|
||
|
||
|
||
def run_tls13_test(cipher_wolf, cert_chain, priv_key, label):
|
||
"""TLS 1.3: verify tlslite-ng sends multi-msg encrypted record and
|
||
wolfSSL client processes it."""
|
||
srv, port = _listen_socket()
|
||
|
||
result = {"ok": False, "error": ""}
|
||
|
||
def server():
|
||
try:
|
||
conn, _ = srv.accept()
|
||
conn.settimeout(15)
|
||
tls = TLSConnection(conn)
|
||
settings = HandshakeSettings()
|
||
settings.minVersion = (3, 4)
|
||
settings.maxVersion = (3, 4)
|
||
tls.handshakeServer(certChain=cert_chain, privateKey=priv_key,
|
||
settings=settings)
|
||
data = tls.recv(4096)
|
||
if data:
|
||
tls.send(data)
|
||
tls.close()
|
||
result["ok"] = True
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
finally:
|
||
srv.close()
|
||
|
||
with _SendRecordTrace() as log:
|
||
st = threading.Thread(target=server, daemon=True)
|
||
st.start()
|
||
time.sleep(0.1)
|
||
|
||
proc = _run_wolf_client(port, "4", cipher_wolf)
|
||
st.join(timeout=5)
|
||
|
||
if proc.returncode != 0 or not result["ok"]:
|
||
err = _describe_failure(proc, result)
|
||
failed(f"{label}: handshake failed ({err})")
|
||
return False
|
||
|
||
# Check that at least one encrypted handshake record has multiple messages
|
||
has_multi = False
|
||
for ct, enc, sz, msgs in log:
|
||
if ct == ContentType.handshake and enc and len(msgs) > 1:
|
||
has_multi = True
|
||
passed(f"{label}: {len(msgs)} encrypted msgs "
|
||
f"[{'+'.join(msgs)}] in one record ({sz} bytes)")
|
||
if not has_multi:
|
||
failed(f"{label}: no multi-message encrypted records")
|
||
return False
|
||
return True
|
||
|
||
|
||
def run_tls12_ticket_expect_test(cert_chain, priv_key, label):
|
||
"""Expect SESSION_TICKET_EXPECT_E when a promised ticket never arrives."""
|
||
srv, port = _listen_socket()
|
||
|
||
result = {"ok": False, "error": ""}
|
||
offered = [None]
|
||
|
||
orig_getExt = HelloMessage.getExtension
|
||
orig_send_tickets = TLSConnection._serverSendTickets
|
||
|
||
def patched_getExt(self, ext_type):
|
||
ext = orig_getExt(self, ext_type)
|
||
if ext_type == ExtensionType.session_ticket and offered[0] is None:
|
||
offered[0] = ext is not None
|
||
return ext
|
||
|
||
def no_tickets(self, settings):
|
||
# The ServerHello still promises a ticket; this sends none.
|
||
return iter(())
|
||
|
||
def server():
|
||
try:
|
||
HelloMessage.getExtension = patched_getExt
|
||
TLSConnection._serverSendTickets = no_tickets
|
||
|
||
conn, _ = srv.accept()
|
||
conn.settimeout(15)
|
||
tls = TLSConnection(conn)
|
||
settings = HandshakeSettings()
|
||
settings.minVersion = (3, 3)
|
||
settings.maxVersion = (3, 3)
|
||
# Releases from 0.8.0-beta2 on only echo the extension when
|
||
# ticket keys are configured.
|
||
settings.ticketKeys = [bytearray(32)]
|
||
settings.ticket_count = 1
|
||
|
||
tls.handshakeServer(certChain=cert_chain, privateKey=priv_key,
|
||
settings=settings)
|
||
tls.close()
|
||
result["ok"] = True
|
||
except Exception:
|
||
import traceback
|
||
result["error"] = traceback.format_exc()
|
||
finally:
|
||
HelloMessage.getExtension = orig_getExt
|
||
TLSConnection._serverSendTickets = orig_send_tickets
|
||
srv.close()
|
||
|
||
st = threading.Thread(target=server, daemon=True)
|
||
st.start()
|
||
time.sleep(0.1)
|
||
|
||
proc = _run_wolf_client(port, "3", None)
|
||
st.join(timeout=5)
|
||
|
||
if offered[0] is False:
|
||
skipped(f"{label} (build has no HAVE_SESSION_TICKET)")
|
||
return True
|
||
|
||
err = proc.stderr.decode("utf-8", errors="replace")
|
||
if offered[0] and proc.returncode != 0 and "-390" in err:
|
||
passed(f"{label}: client rejected the missing NewSessionTicket")
|
||
return True
|
||
|
||
failed(f"{label}: expected -390, got {_describe_failure(proc, result)}")
|
||
return False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
def main():
|
||
if not os.path.isfile(WOLF_CLIENT):
|
||
print(f"ERROR: wolfSSL client not found: {WOLF_CLIENT}")
|
||
print(" Build wolfSSL first (./configure && make)")
|
||
sys.exit(1)
|
||
|
||
# Probe the client to see which features are compiled in so each
|
||
# phase of the test is only run when it can succeed.
|
||
feats = detect_wolf_features()
|
||
global WOLF_CA_CERT
|
||
WOLF_CA_CERT = feats["ca_cert"]
|
||
|
||
print("=" * 60)
|
||
print(" Multi-Message TLS Record Test")
|
||
print("=" * 60)
|
||
print(f" wolfSSL features: TLS1.2={feats['tls12']} "
|
||
f"TLS1.3={feats['tls13']} "
|
||
f"secure_reneg={feats['secure_reneg']} "
|
||
f"rsa={feats['rsa']}")
|
||
|
||
# The test certs are RSA; skip the whole test when the wolfSSL build
|
||
# has no RSA support (the client can't load or verify them).
|
||
if not feats["rsa"]:
|
||
print("\n wolfSSL built without RSA; skipping multi-msg-record "
|
||
"test (RSA test certs cannot be verified).")
|
||
sys.exit(77)
|
||
|
||
# Load certificate / key pairs
|
||
rsa_chain = _load_chain(os.path.join(CERT_DIR, "server-cert.pem"))
|
||
rsa_key = _load_key(os.path.join(CERT_DIR, "server-key.pem"))
|
||
|
||
# ------------------------------------------------------------------
|
||
# TLS 1.2 – plaintext (initial HS) + optional encrypted (renegotiation)
|
||
# multi-message records, same connection per cipher suite.
|
||
# ------------------------------------------------------------------
|
||
tls12_suites = [
|
||
# (wolfSSL cipher name, description)
|
||
(None, "default negotiated"),
|
||
# AEAD (GCM)
|
||
("ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-RSA AES128-GCM"),
|
||
("ECDHE-RSA-AES256-GCM-SHA384", "ECDHE-RSA AES256-GCM"),
|
||
("DHE-RSA-AES128-GCM-SHA256", "DHE-RSA AES128-GCM"),
|
||
("DHE-RSA-AES256-GCM-SHA384", "DHE-RSA AES256-GCM"),
|
||
# CBC + HMAC (exercises padding path)
|
||
("ECDHE-RSA-AES128-SHA256", "ECDHE-RSA AES128-CBC-SHA256"),
|
||
("ECDHE-RSA-AES256-SHA384", "ECDHE-RSA AES256-CBC-SHA384"),
|
||
("DHE-RSA-AES128-SHA256", "DHE-RSA AES128-CBC-SHA256"),
|
||
("DHE-RSA-AES256-SHA256", "DHE-RSA AES256-CBC-SHA256"),
|
||
# AEAD (ChaCha20-Poly1305)
|
||
("ECDHE-RSA-CHACHA20-POLY1305", "ECDHE-RSA CHACHA20-POLY1305"),
|
||
("DHE-RSA-CHACHA20-POLY1305", "DHE-RSA CHACHA20-POLY1305"),
|
||
]
|
||
|
||
if feats["tls12"]:
|
||
if feats["secure_reneg"]:
|
||
print("\n--- TLS 1.2: plaintext + encrypted multi-message "
|
||
"records ---")
|
||
print(" Each connection verifies BOTH code paths:")
|
||
print(" * initial handshake -> plaintext SH+Cert+SKE+SHD")
|
||
print(" * renegotiation -> encrypted SH+Cert+SKE+SHD")
|
||
else:
|
||
print("\n--- TLS 1.2: plaintext multi-message records ---")
|
||
print(" wolfSSL built without HAVE_SECURE_RENEGOTIATION;")
|
||
print(" skipping the encrypted (renegotiation) half.")
|
||
print(" Covers multiple key-exchanges, ciphers and MAC "
|
||
"families.\n")
|
||
|
||
for cipher, desc in tls12_suites:
|
||
if cipher and cipher not in feats["ciphers"]:
|
||
skipped(f"TLS1.2 {desc} - cipher not in wolfSSL build")
|
||
continue
|
||
run_tls12_test(cipher, rsa_chain, rsa_key,
|
||
f"TLS1.2 {desc}",
|
||
do_reneg=feats["secure_reneg"])
|
||
if not feats["secure_reneg"]:
|
||
skipped("TLS1.2 encrypted multi-msg record "
|
||
"(requires HAVE_SECURE_RENEGOTIATION)")
|
||
|
||
run_tls12_ticket_expect_test(
|
||
rsa_chain, rsa_key, "TLS1.2 promised session ticket never sent")
|
||
else:
|
||
skipped(f"TLS 1.2 tests ({len(tls12_suites)} suites) - "
|
||
"wolfSSL built without TLS 1.2")
|
||
|
||
# ------------------------------------------------------------------
|
||
# TLS 1.3 – encrypted multi-message records
|
||
# ------------------------------------------------------------------
|
||
tls13_suites = [
|
||
# (wolfSSL cipher name, description)
|
||
(None, "default negotiated"),
|
||
("TLS13-AES128-GCM-SHA256", "AES-128-GCM"),
|
||
("TLS13-AES256-GCM-SHA384", "AES-256-GCM"),
|
||
("TLS13-CHACHA20-POLY1305-SHA256", "CHACHA20-POLY1305"),
|
||
]
|
||
|
||
if feats["tls13"]:
|
||
print("\n--- TLS 1.3: encrypted multi-message records ---")
|
||
print(" Server sends EE+Cert+CV+Fin in a single encrypted "
|
||
"record;")
|
||
print(" wolfSSL client must decrypt and parse.\n")
|
||
|
||
for cipher, desc in tls13_suites:
|
||
if cipher and cipher not in feats["ciphers"]:
|
||
skipped(f"TLS1.3 {desc} - cipher not in wolfSSL build")
|
||
continue
|
||
run_tls13_test(cipher, rsa_chain, rsa_key,
|
||
f"TLS1.3 {desc}")
|
||
else:
|
||
skipped(f"TLS 1.3 tests ({len(tls13_suites)} suites) - "
|
||
"wolfSSL built without TLS 1.3")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Summary
|
||
# ------------------------------------------------------------------
|
||
print()
|
||
print("=" * 60)
|
||
print(f" Results: {PASS_COUNT} passed, {FAIL_COUNT} failed, "
|
||
f"{SKIP_COUNT} skipped")
|
||
print("=" * 60)
|
||
|
||
# If nothing at all could run, signal SKIP (exit 77) so automake
|
||
# records the test as skipped rather than passed-with-nothing.
|
||
if PASS_COUNT == 0 and FAIL_COUNT == 0:
|
||
sys.exit(77)
|
||
|
||
return FAIL_COUNT == 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(0 if main() else 1)
|