From 653cfd963475ef6e530c325a2dd91209f4b6162a Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:46:56 -0400 Subject: [PATCH] TLS13-C10: a TLS 1.3 responder, and the consumer path proven against it The consumer criterion had no runtime evidence: FETCHS.BIN compiles and installs, but nothing had ever driven the client along the *consumer* path -- the bytes of the vendored root blob, and the exact identity FETCHS.BIN is built to expect. This adds the peer that closes that gap. tlsresponder.py is a TLS 1.3-only Python `ssl` responder: a fifth kind of implementation in the matrix (a different binding over a possibly different OpenSSL build), and the artifact a class-B gate would drive. TLS 1.3 is mandatory in it and `--timeout` bounds its life, so a lost peer cannot hang a runner. run_consumer_interop.sh drives the real client against it: python-tls13 127.0.0.1:24533 leaf.example.com -> ok python-tls13-wronghost 127.0.0.1:24534 wrong.example.com -> handshake_failed The responder's own log corroborates both directions: served TLSv1.3 TLS_AES_128_GCM_SHA256: GET / HTTP/1.0 refused: [SSL: UNEXPECTED_EOF_WHILE_READING] ... So the client completed a TLS 1.3 handshake with an independent stack, negotiated the mandatory suite, sent an HTTP/1.0 GET, and read a 200 response whose body it verified -- and failed closed when the name did not match. Honest scope: this exercises the consumer's trust path and request/response logic on the host, not the kernel seams beneath it. The guest's live handshake still needs a boot. What changes is that the runner-side half of that gate now exists in-tree instead of being a plan, so INTEGRATION.md can name the artifact rather than describe one. Evidence: bash run_consumer_interop.sh interop/consumer_ledger.jsonl -> exit 0, two rows (1 ok, 1 handshake_failed) zig build test -> 198/198 steps, 3585/3585 tests (unchanged) --- user/src/lib/tls/INTEGRATION.md | 38 +++++-- .../src/lib/tls/interop/consumer_ledger.jsonl | 2 + .../lib/tls/vectors/run_consumer_interop.sh | 79 ++++++++++++++ user/src/lib/tls/vectors/tlsresponder.py | 101 ++++++++++++++++++ 4 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 user/src/lib/tls/interop/consumer_ledger.jsonl create mode 100755 user/src/lib/tls/vectors/run_consumer_interop.sh create mode 100755 user/src/lib/tls/vectors/tlsresponder.py diff --git a/user/src/lib/tls/INTEGRATION.md b/user/src/lib/tls/INTEGRATION.md index 6983b7d9..5e1d7cf3 100644 --- a/user/src/lib/tls/INTEGRATION.md +++ b/user/src/lib/tls/INTEGRATION.md @@ -110,23 +110,39 @@ Read these before wiring a consumer: worth knowing before reusing `bigint.zig` for signing). 6. **RSASSA-PSS certificate signatures are verified as SHA-256** because the parser does not read the PSS parameters. PKCS#1 v1.5 is fully covered. -7. **The guest has no consumer yet.** `fetch.zig` and `download.zig` are - plain-HTTP to the host gateway; neither speaks TLS. Wiring one is the next - card, and the gate design below lands with it. +7. **The guest consumer exists but is not runtime-proven.** `FETCHS.BIN` + (`user/src/fetchs.zig`) is the first guest HTTPS consumer: it connects to + the host gateway on 443, completes a 1-RTT handshake against the vendored + root blob, and streams one HTTP/1.0 GET. It compiles, converts and installs + into the boot image, and its root-loading path is asserted on the host. + What is *not* proven is the guest actually completing a live handshake, + because that needs a boot this session could not perform. `fetch.zig` and + `download.zig` remain plain-HTTP on port 80 and are untouched. ## The gate to land with the consumer Every `vgate` spec requires a guest boot, so a spec cannot be committed before -a guest TLS consumer exists without breaking the gate fleet. When `TLS.BIN` -lands, the spec should be: +the runner side can serve TLS: an unrunnable spec fails the gate fleet. + +Two halves of that prerequisite are now met and in-tree: + +- **The runner-side responder exists.** `tlsresponder.py` is a TLS 1.3-only + Python `ssl` responder that serves the fixture identity `FETCHS.BIN` expects + and exits on a bounded deadline, so it cannot hang a runner. It is exercised + today by `run_consumer_interop.sh`. +- **The consumer's identity expectation is pinned**: `leaf.example.com`, + matching the fixture leaf's SAN and its chain to the vendored root. + +The remaining half is a *validated* boot. The spec should be: - `vgate_name live-tls13-handshake` - `vgate_share seed`, `vgate_runner_flags -Xswiftc -DSPIKE` -- a `vgate_file` script that runs `TLS.BIN :443 ` against the - runner's TLS responder -- `vgate_client` to drive the responder side -- asserts on the guest serial markers (`tls: handshake ok`, the negotiated +- `vgate_setup_python` to start `tlsresponder.py` on a high port bound to the + gateway address the guest dials +- a `vgate_file` script that runs `FETCHS.BIN` against it +- asserts on the guest serial markers (`fetchs: handshake ok`, the negotiated suite, and the response body), plus a negative run that must fail closed - against a self-signed responder + against a wrong-hostname responder -Class B (Apple silicon VZ). +Class B (Apple silicon VZ). It lands with the first boot that can validate it; +committing it unvalidated would redden the fleet rather than prove anything. diff --git a/user/src/lib/tls/interop/consumer_ledger.jsonl b/user/src/lib/tls/interop/consumer_ledger.jsonl new file mode 100644 index 00000000..4f431abd --- /dev/null +++ b/user/src/lib/tls/interop/consumer_ledger.jsonl @@ -0,0 +1,2 @@ +{"peer":"python-tls13","peer_version":"OpenSSL 3.6.4 25 Aug 2026","endpoint":"127.0.0.1:24533","servername":"leaf.example.com","tls_version":"TLS1.3","cipher_suite":"TLS_AES_128_GCM_SHA256","timestamp":"2026-09-15T02:46:31Z","outcome":"ok","detail":"first bytes: HTTP/1.0 200 OK\r\nContent-Length: 19\r\nConnection:","handshake_ms":25,"response_bytes":77,"command":"./driver python-tls13 'OpenSSL 3.6.4 25 Aug 2026' 2026-09-15T02:46:31Z 127.0.0.1 24533 leaf.example.com "} +{"peer":"python-tls13-wronghost","peer_version":"OpenSSL 3.6.4 25 Aug 2026","endpoint":"127.0.0.1:24534","servername":"wrong.example.com","tls_version":"TLS1.3","cipher_suite":"TLS_AES_128_GCM_SHA256","timestamp":"2026-09-15T02:46:31Z","outcome":"handshake_failed","detail":"handshake failed","handshake_ms":26,"response_bytes":0,"command":"./driver python-tls13-wronghost 'OpenSSL 3.6.4 25 Aug 2026' 2026-09-15T02:46:31Z 127.0.0.1 24534 wrong.example.com "} diff --git a/user/src/lib/tls/vectors/run_consumer_interop.sh b/user/src/lib/tls/vectors/run_consumer_interop.sh new file mode 100755 index 00000000..490a45cd --- /dev/null +++ b/user/src/lib/tls/vectors/run_consumer_interop.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Consumer interop for the in-tree TLS 1.3 client (card TLS13-C10). +# +# run_interop.sh pairs the client with OpenSSL, GnuTLS and Go. This pairs it +# with Python's `ssl` — a different binding over a possibly different OpenSSL +# build — and does so along the *consumer* path: the bytes of the vendored root +# blob and the exact identity FETCHS.BIN is built to expect. That is the +# closest thing to a consumer run that does not need a guest boot, and the +# responder it starts is the artifact a class-B gate would drive. +# +# Usage: bash run_consumer_interop.sh +set -uo pipefail + +DRV="${1:?driver binary}" +FX="${2:?fixture dir}" +LEDGER="${3:?ledger path}" +HERE="$(cd "$(dirname "$0")" && pwd)" +PORT="${CONSUMER_PORT:-24533}" + +ROOT_HEX=$(xxd -p -c 100000 "$FX/root.der" | tr -d '\n') +# The responder wants one PEM with the chain, leaf first. +cat "$FX/leaf-ec.pem" "$FX/inter.pem" > "$FX/chain-ec.pem" +mkdir -p "$(dirname "$LEDGER")" + +start_responder() { # port body + local port="$1" body="$2" + python3 "$HERE/tlsresponder.py" --port "$port" --cert "$FX/chain-ec.pem" \ + --key "$FX/leaf-ec.key" --accept 1 --timeout 45 --body "$body" \ + > "$FX/../responder-$port.log" 2>&1 & + echo $! +} + +wait_listen() { # pid port + local pid="$1" port="$2" i + for i in $(seq 1 100); do + grep -q "listening" "$FX/../responder-$port.log" 2>/dev/null && return 0 + kill -0 "$pid" 2>/dev/null || return 1 + sleep 0.1 + done + return 1 +} + +FAILED=0 +run() { # label version port sni root expect + local label="$1" ver="$2" port="$3" sni="$4" root="$5" expect="$6" + local now; now=$(date +%s) + "$DRV" "$label" "$ver" "$now" 127.0.0.1 "$port" "$sni" "$root" >> "$LEDGER" + local out; out=$(tail -1 "$LEDGER" | grep -o '"outcome":"[^"]*"' | cut -d'"' -f4) + if [ "$out" = "$expect" ]; then + echo " $label -> $out (expected $expect) OK" + else + echo " $label -> $out (expected $expect) MISMATCH"; FAILED=1 + fi +} + +PYVER=$(python3 -c 'import ssl; print(ssl.OPENSSL_VERSION)') + +echo "== positive: python TLS 1.3, fixture identity, vendored root ==" +PID=$(start_responder "$PORT" "consumer-interop-ok") +if wait_listen "$PID" "$PORT"; then + run python-tls13 "$PYVER" "$PORT" leaf.example.com "$ROOT_HEX" ok +else + echo " responder did not come up; see responder-$PORT.log"; FAILED=1 +fi +wait "$PID" 2>/dev/null || true + +echo "== negative: same peer, wrong hostname, must fail closed ==" +PORT2=$((PORT + 1)) +PID2=$(start_responder "$PORT2" "consumer-interop-ok") +if wait_listen "$PID2" "$PORT2"; then + run python-tls13-wronghost "$PYVER" "$PORT2" wrong.example.com "$ROOT_HEX" handshake_failed +else + echo " responder did not come up; see responder-$PORT2.log"; FAILED=1 +fi +wait "$PID2" 2>/dev/null || true + +echo "== responder logs ==" +cat "$FX/../responder-$PORT.log" "$FX/../responder-$PORT2.log" 2>/dev/null | sed 's/^/ /' +exit "$FAILED" diff --git a/user/src/lib/tls/vectors/tlsresponder.py b/user/src/lib/tls/vectors/tlsresponder.py new file mode 100755 index 00000000..a7d73e8e --- /dev/null +++ b/user/src/lib/tls/vectors/tlsresponder.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""A TLS 1.3 responder for the in-tree client's consumer interop. + +The matrix in run_interop.sh pairs the client with OpenSSL, GnuTLS and Go. This +is the fourth kind of peer: Python's `ssl` module, i.e. a completely different +binding over a different OpenSSL build. It exists because the *consumer* path +needs a peer that serves the exact fixture identity the guest expects +(`leaf.example.com`, issued by the test intermediate to the vendored root), and +because a class-B gate will need a runner-side responder speaking TLS 1.3 with +a bounded, scriptable lifetime. + +TLS 1.3 only, on purpose: the client implements nothing older, so a downgrade +must be impossible rather than merely unlikely. `--accept` counts *attempts*, +not successes, so a negative case (where the client aborts mid-handshake) still +terminates the loop instead of hanging the runner. +""" +import argparse +import socket +import ssl +import sys +import time + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--cert", required=True, help="PEM chain, leaf first") + ap.add_argument("--key", required=True) + ap.add_argument("--body", default="consumer-interop-ok\n") + ap.add_argument("--accept", type=int, default=1, help="attempts before exit") + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--timeout", type=float, default=30.0, + help="hard deadline, so a lost peer cannot hang a runner") + a = ap.parse_args() + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ctx.maximum_version = ssl.TLSVersion.TLSv1_3 + try: + ctx.load_cert_chain(a.cert, a.key) + except Exception as e: # noqa: BLE001 - surface the reason, not a traceback + sys.stderr.write("responder: cannot load %s: %s\n" % (a.cert, e)) + return 2 + + ls = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ls.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + ls.bind((a.host, a.port)) + ls.listen(8) + sys.stdout.write("responder: listening on %s:%d\n" % (a.host, a.port)) + sys.stdout.flush() + + attempts = 0 + deadline = time.monotonic() + a.timeout + while attempts < a.accept: + remaining = deadline - time.monotonic() + if remaining <= 0: + sys.stdout.write("responder: deadline reached, exiting\n") + sys.stdout.flush() + break + ls.settimeout(remaining) + attempts += 1 + try: + raw, _ = ls.accept() + except socket.timeout: + sys.stdout.write("responder: accept timed out\n") + sys.stdout.flush() + break + try: + with ctx.wrap_socket(raw, server_side=True) as s: + req = b"" + while b"\r\n\r\n" not in req and len(req) < 8192: + chunk = s.recv(4096) + if not chunk: + break + req += chunk + body = a.body.encode() + s.sendall( + b"HTTP/1.0 200 OK\r\nContent-Length: %d\r\n" + b"Connection: close\r\n\r\n" % len(body) + body + ) + ver = s.version() + cs = s.cipher() + sys.stdout.write("responder: served %s %s: %s\n" % ( + ver, cs[0] if cs else "?", req.split(b"\r\n")[0].decode("latin1"))) + sys.stdout.flush() + except (ssl.SSLError, OSError) as e: + # Expected on a negative case: the client aborts during or after + # the handshake, which is the behaviour under test. + sys.stdout.write("responder: refused: %s\n" % e) + sys.stdout.flush() + finally: + try: + raw.close() + except OSError: + pass + ls.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main())