diff --git a/docs/examples/tls-client/main.wo b/docs/examples/tls-client/main.wo new file mode 100644 index 0000000..277dd20 --- /dev/null +++ b/docs/examples/tls-client/main.wo @@ -0,0 +1,50 @@ +-- tls-client — runtime-v2 9 F3c-net's acceptance workload. An outbound HTTPS +-- client, written end to end in .wo: it dials a TLS 1.3 server with +-- `net.connect_tls`, which runs the hand-rolled handshake (X25519 + AES-GCM / +-- ChaCha20-Poly1305 + the RFC 8446 key schedule), validates the certificate +-- chain to a trust anchor and matches the hostname (SAN), then carries +-- application bytes over `net.write_tls` / `net.read_tls`. +-- +-- woc --emit main.wo -o tls-client.wob +-- WO_CA_BUNDLE=ca.pem wovm tls-client.wob localhost 18443 +-- +-- A connection whose chain does not chain to a trusted anchor, whose SAN does +-- not match the host, or that is expired, is refused loudly (the connect traps, +-- caught here). The gate (scripts/tls-accept.sh, `just tls`) proves the happy +-- path and those negatives against a local stub — no live network. +use net + +fn main(args: multi Text) -> Int { + if len(args) < 2 { + print_err("usage: tls-client "); + return 2; + } + let host = args[0]; + let port = parse_int(args[1]); + if port == nil { + print_err("tls-client: must be a number"); + return 2; + } + + -- connect_tls traps on DNS/connect/handshake/chain/hostname failure — a + -- secure connection is never silently downgraded, so we catch and report. + let fd = try net.connect_tls(host, port) catch (e) -1; + if fd < 0 { + print("tls: refused (handshake, chain, or hostname)"); + return 1; + } + + net.write_tls(fd, "GET / HTTP/1.0\r\nHost: ${host}\r\n\r\n"); + + let acc = ""; + while true { + let chunk = try net.read_tls(fd, 4096) catch (e) ""; + if len(chunk) == 0 { break; } + acc = acc .. chunk; + } + net.close(fd); + + print("recv ${len(acc)} bytes"); + print(acc); + return 0; +} diff --git a/justfile b/justfile index 86a5c15..6e4b51e 100644 --- a/justfile +++ b/justfile @@ -89,6 +89,13 @@ residency: subprocess: ./scripts/subprocess-accept.sh +# tls: runtime-v2 9 F3c-net's gate (docs/examples/tls-client) — net.connect_tls +# from .wo end to end against a local TLS 1.3 stub with a test CA: the +# hand-rolled handshake + chain/hostname validation + an app round-trip, plus +# the untrusted-chain and hostname-mismatch negatives refused. Log: /tmp/tls.log. +tls: + ./scripts/tls-accept.sh + # db-bench: iteration 22's campaign (docs/examples/db-bench) — OFF the # fast path, minutes long: ram+durable x 1/N shards, durability legs, # gates vs bench/baseline.json. quick = seconds, floors only. diff --git a/scripts/tls-accept.sh b/scripts/tls-accept.sh new file mode 100755 index 0000000..c1b5bc1 --- /dev/null +++ b/scripts/tls-accept.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# scripts/tls-accept.sh — runtime-v2 9 F3c-net's gate: outbound TLS 1.3. +# The runtime suite (test_tls/test_crypto) proves the crypto + handshake + +# chain validation offline against RFC 8448 / real certs; this proves the half +# only a real program shows: net.connect_tls called FROM .wo through the +# compiler, dialing a live TLS 1.3 server, validating the chain to a trust +# anchor and the hostname, exchanging application bytes — and refusing loudly +# when the chain is untrusted or the hostname does not match. No live network: +# the server is a local stub with a test CA. Log: /tmp/tls.log (tail -F it). +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WOC="$ROOT/compiler/_build/default/bin/woc" +WOVM="$ROOT/runtime/wovm" +APP="$ROOT/docs/examples/tls-client" +PORT="${TLS_PORT:-18443}" +LOG=/tmp/tls.log + +pass=0; fail=0 +ok() { echo "ok $1"; pass=$((pass + 1)); } +bad() { echo "FAIL $1"; fail=$((fail + 1)); } + +WORK="$(mktemp -d)" +SRV_PID="" +cleanup() { + [[ -n "$SRV_PID" ]] && kill -KILL "$SRV_PID" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT +mkdir -p "$WORK/data" + +# ---- 0. prerequisites ---------------------------------------------------- +[[ -x "$WOVM" ]] || { echo "tls-accept: wovm not built — run: make -C runtime wovm" >&2; exit 1; } +[[ -x "$WOC" ]] || { echo "tls-accept: woc not built — run: just woc-build" >&2; exit 1; } +python3 -c 'import cryptography, ssl' 2>/dev/null || { echo "tls-accept: needs python3 cryptography + ssl" >&2; exit 1; } + +# ---- 1. test CA + certs -------------------------------------------------- +cat > "$WORK/mkcerts.py" <<'PY' +import datetime, sys +from cryptography import x509 +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +d = sys.argv[1] +NB = datetime.datetime(2020, 1, 1); NA = datetime.datetime(2035, 1, 1) +def nm(cn): return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) +def leaf(cak, ca, cn, san): + k = ec.generate_private_key(ec.SECP256R1()) + c = (x509.CertificateBuilder().subject_name(nm(cn)).issuer_name(ca.subject) + .public_key(k.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(NB).not_valid_after(NA) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(san)]), False) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), False) + .sign(cak, hashes.SHA256())) + return k, c +def ca(cn): + k = ec.generate_private_key(ec.SECP256R1()) + c = (x509.CertificateBuilder().subject_name(nm(cn)).issuer_name(nm(cn)) + .public_key(k.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(NB).not_valid_after(NA) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), True) + .sign(k, hashes.SHA256())) + return k, c +def wpem(p, c): open(p, "wb").write(c.public_bytes(serialization.Encoding.PEM)) +def wkey(p, k): open(p, "wb").write(k.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption())) +cak, cac = ca("wo-test-ca") +lk, lc = leaf(cak, cac, "localhost", "localhost") +wk, wc = leaf(cak, cac, "wrong", "wrong.example") +_, oc = ca("wo-other-ca") +wpem(d + "/ca.pem", cac); wpem(d + "/leaf.pem", lc); wkey(d + "/leaf.key", lk) +wpem(d + "/wrong.pem", wc); wkey(d + "/wrong.key", wk); wpem(d + "/other.pem", oc) +PY +python3 "$WORK/mkcerts.py" "$WORK" || { bad "cert generation"; echo "tls-accept: $fail failures"; exit 1; } +ok "test CA + leaf (SAN localhost) + wrong-host + unrelated-CA generated" + +# ---- 2. TLS 1.3 stub server ---------------------------------------------- +cat > "$WORK/stub.py" <<'PY' +import socket, ssl, sys, threading, time +host, port, cert, key = "127.0.0.1", int(sys.argv[1]), sys.argv[2], sys.argv[3] +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.minimum_version = ssl.TLSVersion.TLSv1_3 +ctx.maximum_version = ssl.TLSVersion.TLSv1_3 +ctx.load_cert_chain(certfile=cert, keyfile=key) +srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind((host, port)); srv.listen(8) +print("READY", flush=True) +def serve(): + while True: + try: c, _ = srv.accept() + except OSError: return + try: + s = ctx.wrap_socket(c, server_side=True) + s.recv(4096); s.sendall(b"wo-tls-ok\n"); s.close() + except Exception as e: + print("stub-err", e, flush=True) +threading.Thread(target=serve, daemon=True).start() +time.sleep(60) +PY + +start_stub() { # $1 = cert, $2 = key + [[ -n "$SRV_PID" ]] && kill -KILL "$SRV_PID" 2>/dev/null + : > "$WORK/stub.log" + python3 "$WORK/stub.py" "$PORT" "$1" "$2" >"$WORK/stub.log" 2>&1 & + SRV_PID=$! + disown "$SRV_PID" 2>/dev/null || true # keep job-control noise out of gate output + for _ in $(seq 1 100); do grep -q READY "$WORK/stub.log" 2>/dev/null && return 0; sleep 0.1; done + return 1 +} + +run_client() { # $1 = CA bundle ; stdout: client output ; return: client exit + WO_CA_BUNDLE="$1" WO_DATA="$WORK/data" timeout 20 "$WOVM" "$WORK/app.wob" localhost "$PORT" 2>>"$LOG" +} + +# ---- 3. build the client ------------------------------------------------- +{ echo; echo "== tls-accept $(date -Is) port $PORT =="; } >>"$LOG" +if "$WOC" --emit "$APP" -o "$WORK/app.wob" 2>"$WORK/cerr"; then + ok "build: tls-client compiles" +else + bad "build: $(head -3 "$WORK/cerr")"; echo "tls-accept: $fail failures"; exit 1 +fi + +# ---- 4. happy path: trusted chain + matching host ------------------------ +start_stub "$WORK/leaf.pem" "$WORK/leaf.key" || { bad "stub server did not start"; echo "tls-accept: $fail failures"; exit 1; } +out="$(run_client "$WORK/ca.pem")"; rc=$? +if [[ $rc -eq 0 && "$out" == *"wo-tls-ok"* ]]; then + ok "handshake + trusted chain + host match + app round-trip" +else + bad "happy path (exit $rc): $out" +fi + +# ---- 5. negative: untrusted chain (bundle = unrelated CA) ----------------- +out="$(run_client "$WORK/other.pem")"; rc=$? +if [[ $rc -eq 1 && "$out" == *"refused"* ]]; then + ok "untrusted chain refused" +else + bad "untrusted chain not refused (exit $rc): $out" +fi + +# ---- 6. negative: hostname mismatch (cert SAN=wrong.example) -------------- +start_stub "$WORK/wrong.pem" "$WORK/wrong.key" || { bad "stub restart (wrong cert)"; } +out="$(run_client "$WORK/ca.pem")"; rc=$? +if [[ $rc -eq 1 && "$out" == *"refused"* ]]; then + ok "hostname mismatch refused" +else + bad "hostname mismatch not refused (exit $rc): $out" +fi + +echo "tls-accept: $((pass + fail)) checks, $fail failures" +[[ $fail -eq 0 ]]