Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions user/src/lib/tls/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>:443 <path>` 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.
2 changes: 2 additions & 0 deletions user/src/lib/tls/interop/consumer_ledger.jsonl
Original file line number Diff line number Diff line change
@@ -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 <root.der hex>"}
{"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 <root.der hex>"}
79 changes: 79 additions & 0 deletions user/src/lib/tls/vectors/run_consumer_interop.sh
Original file line number Diff line number Diff line change
@@ -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 <driver> <fixture-dir> <ledger.jsonl>
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"
101 changes: 101 additions & 0 deletions user/src/lib/tls/vectors/tlsresponder.py
Original file line number Diff line number Diff line change
@@ -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())
Loading