writeonce/docs/stories/runtime-v2/09-in-process-tls.md
shoney.arickathil 3b1a188d77 fix(crypto): branch-free EC signing ladder via complete addition (rv2 9 follow-up)
- pmul_ct: double-and-add-always over the Renes–Costello–Batina complete
  projective addition formula (Alg. 4, a=-3) — one exception-free formula for
  add and double, identity (0:1:0), so there is NO point-at-infinity branch.
  Closes the documented residual: the Jacobian jadd/jdouble ladder's fp_zero
  checks leaked k's leading-zero count (a bit-length hint) during ECDSA sign
- wo_ecdsa_p256_sha256_sign uses it; affine x = X * Z^-1 (projective), the
  inversion via the constant-time modexp. Dead Jacobian jmul_ct/jpt_cmov removed
- RFC 6979 A.2.5 vectors still byte-exact (test_crypto 130/0); server loopback
  (signs with this ladder) still green (test_tls 123/0); ASan/UBSan clean
- docs: rv2 9 review_pending — close_notify + complete-formula ladder moved
  from deferred to landed; lang-41 decision 4 fixture marked landed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit fba30352b965e0f3b749168421a920a832df541b)
2026-09-15 01:16:13 +02:00

390 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
track: runtime-v2
iteration: "9"
status: done
readiness: ready
review_pending: "forks auto-approved 2026-09-08/09 for autonomous execution — developer second review before this ships. COMPLETE BOTH DIRECTIONS, live-gated (just tls 5/0 outbound, just tls-server 4/0 inbound EC+RSA). Outbound: A–E crypto, F1–F3c client (net.connect_tls/read_tls/write_tls, ids 115-117). Inbound: G1 constant-time RSA-PSS + ECDSA-P256 signing (RFC 6979), G2 server FSM, G3 net.accept_tls (id 118) + private-key parse. Follow-ups landed 2026-09-09: TLS close_notify on shutdown (openssl clean EOF) and the complete-formula (RCB) constant-time EC ladder for signing (bit-length leak closed, RFC 6979 vectors unchanged). Still deferred (not blockers): park-based handshake, TlsConn language object, connection pooling. Doctrine docs (34/38/porch) corrected as part of this landing"
---
# runtime-v2 9 — in-process TLS: retiring the proxy-termination doctrine
> Created 2026-09-07 from the gap [`jarvis`](../jarvis/00-story.md) surfaces — an
> assistant must dial an LLM over HTTPS, and the runtime has no outbound TLS. The
> developer chose the **full overturn**: the runtime gains TLS **both
> directions**, and the standing "TLS is the proxy's job" doctrine is retired.
> **Brainstormed to `ready` 2026-09-07**: hand-rolled TLS 1.3, RSA+ECDSA+X.509
> cert verification, decomposed into the bottom-up phase ladder below. The load-
> bearing implementation fork is settled — hand-roll, not vendor — with eyes open
> to the risk (Info).
## Why this exists — and what it overturns
Three documents record the same standing decision, and this story reverses it:
- *"TLS — permanently the proxy's job (framework doctrine)"* —
[language 34](../language-runtime-database/34-crypto-builtins.md) (crypto
builtins, line ~83).
- *"TLS — proxy-terminated, by doctrine, unchanged… the story says so out loud
rather than implying HTTPS clients"* —
[language 38](../language-runtime-database/38-content-platform-capabilities.md)
(which adds `net.connect` as **plaintext** outbound TCP and explicitly refuses
HTTPS).
- *"TLS, HTTP/2 | nobody — proxy-terminated by doctrine"* —
[porch](../porch/00-story.md)'s "what this track does NOT own".
The doctrine was reasonable while nothing in-tree needed to *dial* anything: a
front proxy terminates inbound TLS, and there were no outbound callers. jarvis
breaks that — its whole job is to reach a remote API — and the developer's
direct-HTTPS choice for it means the runtime, not a companion, owns the
connection. Rather than carve out a one-directional exception, the decision is to
give the runtime TLS in **both** directions: outbound so a `.wo` program can dial
HTTPS, and inbound so porch can terminate TLS itself instead of mandating a
proxy in front of every deployment.
This is **not a builtin-sized seam** like the rest of this track. TLS 1.3 plus
X.509 certificate validation is a large, security-critical subsystem — the one
place the runtime's hand-roll-everything habit (the sha256 precedent) should not
be assumed to extend. That tension is the load-bearing fork below.
## Decisions locked (brainstorm 2026-09-07)
1. **Hand-roll TLS 1.3 in C — no vendored library.** The developer chose the
hand-roll over vendoring mbedTLS/BearSSL, extending the runtime's
hand-roll-everything habit (the sha256 precedent) to the hardest place it has
reached. This keeps the pure single-static-binary, zero-external-dependency
story intact — and it is, stated plainly, the largest and highest-risk
undertaking in the project. See the risk note in Info; it is not a caveat to
bury.
2. **TLS 1.3 only.** No 1.2 legacy — smallest attack surface, one handshake to
get right.
3. **Cert verification is full: RSA + ECDSA + X.509.** To reach real endpoints
(Anthropic, OpenAI and most HTTPS servers present RSA-signed chains), the
verifier does RSA-PSS and RSA-PKCS#1v1.5 plus ECDSA-P256, over a real
ASN.1/DER + X.509 chain validator with a system trust store, validity-date and
hostname (SAN) checks. This is the biggest, most CVE-prone slice, and it is in
scope because EC-only cannot talk to the APIs jarvis needs.
4. **Bottom-up, outbound-first.** Build the primitives before the protocol, and
the client (jarvis's need) before the server (porch's), because the primitives
are shared and only the role differs.
## The phase ladder
Each rung is a security-critical slice; C, D and E are each large enough that
they may split into their own runtime-v2 iterations as they are picked up.
| Phase | Delivers | Notes |
| --- | --- | --- |
| A — AEAD | AES-128/256-GCM (TLS 1.3 mandates AES-128-GCM) and ChaCha20-Poly1305 | **is runtime-v2 [8](08-symmetric-cipher.md)** — so 8 must include AES-GCM, not only ChaCha; this rung consumes it |
| B — key schedule | ✅ **LANDED 2026-09-08** — `wo_hkdf_sha256_extract`/`expand` (RFC 5869) + `expand_label` (RFC 8446 §7.1), internal C over `hmac_sha256`; SHA-256 (the mandatory suites' hash; SHA-384 a later add). KAT-gated in `test_crypto.c` (RFC 5869 case 1 + Expand-Label vectors), ASan/UBSan clean. No builtin, no compiler change |
| C — key exchange | ✅ **LANDED 2026-09-08** — `wo_x25519` (RFC 7748), constant-time Montgomery ladder + mask-based cswap, radix-2⁵¹ field arithmetic (curve25519-donna-c64, `__int128`). Internal C. KAT-gated in `test_crypto.c`: RFC 7748 §5.2 both direct vectors **and the 1000-iteration test**, ASan/UBSan clean |
| D — signatures | ✅ **LANDED 2026-09-08** — **RSA** `wo_rsa_pkcs1_sha256_verify` + `wo_rsa_pss_sha256_verify` (bignum Montgomery modexp) and **ECDSA-P256** `wo_ecdsa_p256_sha256_verify` (Jacobian point arithmetic, a=-3, on-curve check, Fermat inverses reusing the bignum). Verification is public data so **not** constant-time by design. Both match python vectors (RSA-2048 PKCS1+PSS; P-256), tamper/wrong-hash rejected, KAT-gated, ASan/UBSan clean |
| E — X.509 | 🔄 **CORE LANDED 2026-09-08** — a defensive ASN.1/DER reader (every length/bound checked, malformation is rejection not over-read) + certificate parse (tbsCertificate span, sig-alg OID, signature, SubjectPublicKeyInfo→RSA n/e or EC P-256 x/y, validity) + `wo_x509_verify_one` (one chain link's signature, dispatching to D's RSA-PKCS1/PSS + ECDSA-P256) + `wo_x509_parse_spki` + `wo_x509_check_validity` (caller supplies the time). KAT-gated in `test_crypto.c` against **real python-generated chains** — RSA CA+leaf (SHA256withRSA) and EC P-256 CA+leaf (ecdsa-with-SHA256): leaf-vs-CA, self-signed CA, wrong-issuer/tampered/truncated rejected, validity window, SPKI extraction — ASan/UBSan clean. **Deferred to F**: SAN/hostname match (needs the target host) and the multi-cert chain walk to a system CA bundle | notoriously bug-prone; consumes D |
| F — record + handshake (client) | ✅ **COMPLETE 2026-09-08/09** (client). F1–F3b LANDED 2026-09-08 — new `tls.c`/`tls.h`. **F1 record layer** (`wo_tls_record_seal`/`open`, RFC 8446 §5.2, per-record nonce = iv XOR seq, both suites) KAT'd byte-for-byte vs python. **F2 key schedule** (`wo_tls_derive_handshake`/`_application`/`_traffic_keys`/`_finished_verify`, §7.1) KAT'd byte-for-byte vs **RFC 8448 §3**. **F3a message layer** (`wo_tls_parse_server_hello` — attacker input, bounded, rejects HRR/bad suite/truncation; `wo_tls_build_client_hello` — SNI, x25519, sig-algs) KAT'd vs RFC 8448 SH + validated by an independent parser. **F3b offline handshake verification** (`wo_tls_verify_cert_verify` over phase E+D; server + client Finished) — the whole handshake **crypto** proven end-to-end offline vs RFC 8448. **F3c-core sans-io driver** (`wo_tls_client` — pure FSM, caller frames records: CH→SH→flight→Finished, message reassembly, per-message transcript timing, constant-time Finished, application encrypt/decrypt) KAT'd against the **full RFC 8448 record trace** — client Finished + first app record byte-for-byte, NewSessionTicket + server app data decrypt, tampered flight refused. **SAN/hostname** (`wo_x509_check_host`, RFC 6125) + driver enforcement landed. **F3c-net chain validation** (`wo_tls_verify_chain`) + **basicConstraints/EKU** hardening KAT'd offline. **F3c-net socket/VM ✅ LANDED 2026-09-09**: `getrandom` ephemeral, per-shard lazy CA-bundle loader (`WO_CA_BUNDLE`), and the `net.connect_tls` / `net.read_tls` / `net.write_tls` builtins (ids 115–117; blocking deadline-bounded connect+handshake then a parked data plane; per-shard fd-keyed slot table, no locks). **Live-gated** (`just tls`, 5/0) from `.wo` against a local TLS 1.3 stub incl. untrusted-chain + hostname-mismatch negatives. Client side complete | jarvis's path; the reason the story exists |
| G — server (inbound) | ✅ **COMPLETE 2026-09-09** — the server handshake FSM (loopback-KAT'd), constant-time RSA-PSS + ECDSA-P256 **signing** (RFC 6979), private-key parse, `net.accept_tls` (id 118); live-gated by `openssl s_client` (EC + RSA), `just tls-server` 4/0 | retires the inbound proxy requirement; doctrine docs corrected |
## F3c-net — the socket/VM slice (✅ **LANDED 2026-09-09**; decisions locked, forks auto-approved, `review_pending`)
> **Landed and live-gated.** `net.connect_tls` / `net.read_tls` / `net.write_tls`
> (ids 115–117) are wired into the VM and dial a real TLS 1.3 server end to end
> from `.wo`: the hand-rolled handshake, the chain + hostname + basicConstraints/
> EKU validation against the system (or `WO_CA_BUNDLE`) trust store, and an
> application round-trip. The `just tls` gate (`scripts/tls-accept.sh`,
> `docs/examples/tls-client`) proves the happy path against a local TLS 1.3 stub
> and refuses the untrusted-chain and hostname-mismatch negatives — 5 checks, 0
> failures, no live network. All six decisions below were implemented as locked.
> **This completes the outbound client; jarvis is unblocked.**
Everything security-critical is landed and offline-KAT'd. What is left is I/O
integration that can only be gated **live** (a local `openssl s_server` / python
TLS server), so it is one cohesive slice, not further split. The integration
forks are settled below — the first four grounded in the existing runtime, the
last two added 2026-09-09 from a comparison against **gofiber v3's client**
(fasthttp + Go `crypto/tls`/`crypto/x509`, in `.dev/reference/fiber`), which
bounds every request with a timeout and delegates full chain checks to
`crypto/x509`. This section is `ready`: the decisions are locked, the acceptance
criteria are stated, and code may start once a developer signs off the
`review_pending` marker.
### The locked decisions
1. **Blocking connect + blocking handshake, then park the data plane.** This
mirrors `net.connect` exactly (`sysio.c` `WO_B_NET_CONNECT`): the socket is
**blocking** through TCP connect and the whole TLS handshake, then switched to
`O_NONBLOCK` once ESTABLISHED. `net.connect`'s own comment already accepts a
blocking connect ("can stall the shard during the handshake, tolerable while
connect is rare"); a TLS connection is likewise rare and long-lived (jarvis
streams a whole conversation over one), so the extra few handshake round-trips
are the same tolerable stall. Application I/O then **parks the fiber** exactly
like `net.read`/`net.write` (`O_NONBLOCK` + `park_fd` on POLLIN/POLLOUT +
retry). A **park-based handshake** is a named follow-up — the same deferral
`net.connect` made for its `_dl`/park variant, not a v1 requirement.
2. **Per-shard fd-keyed slot table, no locks.** TLS connection state lives in a
`wo_tls_conn` slot array **in the shard's own vm**, keyed by fd — the exact
pattern of `wo_child children[WO_PROC_MAX]` (`vm.h`: "live in the owning
shard's vm — no locks, one thread"). One pinned OS thread per shard and fds
that never migrate cross-shard make this thread-safe by construction, with no
new locking. Each slot holds the `wo_tls_client` (keys, seqs, driver state), a
**partial-record read buffer** (a record may arrive in fragments over a
non-blocking socket), and a **leftover-plaintext buffer** (a decrypted record
larger than the caller's `max`). Capped like `WO_PROC_MAX`.
3. **Failures trap `WO_T_IO`, loudly.** Every failure of a secure connect —
DNS, TCP connect, the handshake, and critically the **certificate chain and
hostname** checks (and any later record auth failure) — returns a `WO_T_IO`
trap with a descriptive message, mirroring `net.connect`. A secure-connection
failure is never a silent `nil`; this is the "refuse loudly / no partial
trust" rule made concrete. `net.read_tls`/`net.write_tls` otherwise mirror
`net.read`/`net.write` (EOF is the empty Bytes; a partial write resumes via a
`park_wr_at`-style cursor; a decrypt/auth failure traps).
4. **Per-shard, lazy, read-only CA bundle.** On the first `net.connect_tls` a
shard loads the system PEM bundle into its own vm (read-only thereafter) and
reuses it for every later dial — no cross-shard sharing, no locks, consistent
with (2). Path: `/etc/ssl/certs/ca-certificates.crt` (confirmed present on the
dev box), overridable by the `WO_CA_BUNDLE` environment variable — which is
also how the live gate points the client at its self-signed test CA.
5. **A bounded handshake deadline (no unbounded shard stall).** The blocking
model of decision (1) would otherwise let a slow or hostile server stall the
shard's one thread indefinitely during connect + handshake — the DoS that
gofiber closes with `DoTimeout`. So `net.connect_tls` bounds the whole
connect+handshake by a deadline: **non-blocking `connect()` + `poll` for the
TCP step, and `SO_RCVTIMEO`/`SO_SNDTIMEO` on the blocking socket across the
handshake**, capping the stall without needing the full park refit. Default
from `WO_TLS_HANDSHAKE_MS` (10 000 ms if unset); expiry aborts and traps
`WO_T_IO` ("tls: handshake timeout"). A per-call `_dl` variant and the
park-based handshake remain the named follow-ups.
6. **Chain hardening: basicConstraints + EKU (not just signatures).** Signature
+ validity + SAN is not enough — Go's `crypto/x509` also enforces the
constraints that stop a leaf from masquerading as a CA. So the phase-E
extension walk and `wo_tls_verify_chain` gain: every **non-leaf** cert must
assert `basicConstraints` CA:TRUE and satisfy `pathLenConstraint`, and the
**leaf** must carry Extended Key Usage `id-kp-serverAuth` (or omit EKU
entirely). A `keyUsage` `keyCertSign` check on issuers is included where
present. Failure is a rejection like any other chain fault (no partial trust).
### The builtin surface
Three new builtins on the `net` module (one numeric id space; `WO_B_MAX` moves
114 → 117):
- `net.connect_tls(host, port) -> Int` — id **115**, arity 2. Blocking TCP
connect (reusing the `net.connect` DNS/connect path), `getrandom(2)` ephemeral
X25519 key + ClientHello random/session-id, run the sans-io driver over the
blocking socket (frame each record: read the 5-byte header, then the body;
flush `take_output`) to ESTABLISHED, set the host on the driver so the leaf
SAN is enforced, then `wo_tls_verify_chain` against the lazily-loaded anchors
(with the decision-6 basicConstraints/EKU checks). The whole connect+handshake
is bounded by the decision-5 deadline. Returns the fd (a slot is claimed for
it); traps on any failure.
- `net.read_tls(fd, max) -> Bytes` — id **116**, arity 2. Reads/decrypts one
application record via the slot, returning up to `max` plaintext bytes (EOF is
the empty Bytes), buffering a partial record and parking on POLLIN, and
draining any leftover plaintext first.
- `net.write_tls(fd, bytes) -> Int` — id **117**, arity 2. Seals `bytes` into an
application record and writes it, parking on POLLOUT for a partial write.
`net.close` (existing) additionally frees any `wo_tls_conn` slot for the fd.
VM wiring touches `wob.h` (ids + `WO_B_MAX`), `emit.ml`/`types.ml`
(registration + return types), `loader.c` (arities), `builtin.c` (sysio dispatch
range), and `sysio.c` (the implementations + the slot/bundle helpers). No `.wob`
consumer change beyond the id additions.
### Acceptance criteria
- **Given** a reachable TLS 1.3 server with a chain to a trusted anchor, **when**
a `.wo` program calls `net.connect_tls` for its hostname, **then** the
handshake completes, the chain + hostname validate, and an fd is returned.
- **Given** that fd, **when** the program `net.write_tls`es a request and
`net.read_tls`es, **then** it exchanges application data, and `net.close`
frees the socket and the slot.
- **Given** a server whose certificate does not chain to a trusted anchor, whose
SAN does not match the host, or is expired, **when** `net.connect_tls` runs,
**then** it traps `WO_T_IO` — no connection is returned.
- **Given** a server that accepts the TCP connection but then stalls (never
finishing the handshake), **when** the decision-5 deadline elapses,
**then** `net.connect_tls` aborts and traps `WO_T_IO` rather than stalling the
shard indefinitely — proven with a stub that connects then sleeps.
- **Given** a chain whose issuer lacks `basicConstraints` CA:TRUE (a leaf used
to sign another cert), or a leaf lacking EKU `serverAuth`, **when**
`net.connect_tls` validates it, **then** it is rejected — with negative KATs
in `test_tls` alongside the existing chain cases.
- **Given** two shards each dialing TLS, **when** they run concurrently, **then**
neither reads the other's slot or bundle (per-shard, no locks), proven under
ASan/TSan.
- **Given** the live gate, **when** it runs, **then** it dials a local TLS
server (trusting a test CA via `WO_CA_BUNDLE`), does a request/response
round-trip, and refuses each negative (wrong host, untrusted chain, expired).
### Out of scope (named, deferred)
- **A park-based handshake** — the async refit of decision (1); a first-class
`TlsConn` language object over the fd — both later, only if measured need or
the developer prefers them.
- **The HTTP layer.** `net.connect_tls` is a TLS byte pipe; HTTP/1.1 framing
over it is the caller's (jarvis 1's `.wo`), not this slice's.
- **Inbound TLS (server).** Phase **G**, a separate slice for porch.
## G — inbound TLS server (READY — decisions locked 2026-09-09; forks auto-approved, `review_pending`)
The last rung: porch terminates TLS itself instead of mandating a front proxy,
retiring the "TLS is the proxy's job" doctrine on the inbound side too. Much is
reused — the record layer, the (role-symmetric) key schedule, X.509 and the
per-shard slot table are all direction-agnostic — but the server introduces the
one thing the client never needed: **private-key operations**, which unlike the
verifiers touch secret data and so must be **constant-time**. That, plus a
server-side handshake FSM and a cert/key loading surface, is the whole of G. It
is large and security-critical; it may split into its own runtime-v2 iteration
when picked up.
### What is reused vs new
- **Reused as-is:** the record layer (symmetric), `wo_tls_derive_handshake`/
`_application` (the server just reads with the *client* traffic keys and writes
with the *server* ones — the roles swap, the schedule does not), X.509 (only to
ship the cert; the server does not validate a chain unless mTLS, which is out of
scope), and the `wo_tls_conn` slot table + `net.read_tls`/`net.write_tls` data
plane.
- **New:** a server handshake FSM, constant-time signing, private-key parsing,
and the accept surface — below.
### The locked decisions
1. **Constant-time private-key ops (non-negotiable).** The built RSA modexp and
EC scalar-mult are verify-only over public data and are *not* constant-time
(stated so in `crypto.c`). Signing touches the secret key, so G adds a
**constant-time fixed-window modexp** for the RSA private exponent and a
**constant-time Montgomery-ladder scalar multiply** for EC — verified, not
assumed. This is the load-bearing security requirement of the whole rung.
2. **Both server key types.** RSA (**RSA-PSS** signing, TLS 1.3's scheme) and
**ECDSA-P256**, because real server certs (porch's, Let's Encrypt) are either.
RSA-PSS reuses the bignum; ECDSA reuses the P-256 point arithmetic — each with
the new constant-time cores.
3. **Deterministic ECDSA nonce (RFC 6979).** The signing nonce is derived by
HMAC-DRBG from the key and message, not drawn from an RNG — no catastrophic
nonce-reuse or bias risk, and it is KAT-able against RFC 6979 vectors. (The
ephemeral X25519 key is still random via `getrandom`.)
4. **The accept surface.** `net.accept_tls(listener, certfile, keyfile) -> Int`:
accept a TCP connection on the listener, run the server handshake presenting
the loaded identity, and return a TLS conn fd that `net.read_tls`/`write_tls`/
`net.close` already handle. The parsed cert chain + private key are cached per
path in the shard (lazy, read-only), like the CA bundle. `net.listen` is
unchanged. Handshake blocking + deadline-bounded, data plane parked — exactly
the client's model (decisions 1/5 of §F3c-net).
5. **Full 1-RTT, server-auth only.** A client offering x25519 + a supported suite
gets a complete handshake. No client certificates (mTLS), no session
resumption / PSK / 0-RTT, no HelloRetryRequest (a ClientHello without an
x25519 key_share is refused, not renegotiated).
6. **Sans-io server FSM.** `wo_tls_server`, symmetric to the client driver, so
the security-critical state machine is testable without sockets.
### The sub-phases
- **G1 — signing + key parsing.** 🔄 **signing LANDED 2026-09-09** —
constant-time **RSA-PSS sign** (`wo_rsa_pss_sha256_sign`, `bn_modexp_ct`) and
**ECDSA-P256 sign** (`wo_ecdsa_p256_sha256_sign`, RFC 6979 nonce, `jmul_ct`),
KAT'd byte-for-byte (RSA vs a python from-spec oracle with a fixed salt; ECDSA
vs the RFC 6979 A.2.5 vectors) + sign→verify round-trip, ASan/UBSan clean.
**Remaining G1c**: private-key PEM/DER parsing (PKCS#8, PKCS#1, SEC1) — lands
with G3, which is what reads key files (the FSM takes raw key material).
- **G2 — the server handshake FSM.** ✅ **LANDED 2026-09-09** — `wo_tls_server`
(sans-io): parse ClientHello (pick suite, x25519 share, echo session id; reject
no-x25519/no-1.3), build ServerHello, derive the role-symmetric keys, emit the
encrypted flight (EncryptedExtensions + Certificate + a signed CertificateVerify
+ Finished), verify the client Finished, switch to application keys. Signs with
the G1 primitives (RSA-PSS or ECDSA + a DER SEQ{r,s} encoder). **KAT by
loopback** — our client driver against our server driver, EC then RSA server
identity, ESTABLISHED with an app round-trip both ways. test_tls 123, ASan clean.
- **G3 — `net.accept_tls` + the live gate.** ✅ **LANDED 2026-09-09** — the VM
builtin (id 118, `WO_B_MAX`→118), private-key PEM/DER parse (`wo_pkey_parse`,
PKCS#8/PKCS#1/SEC1), the per-shard identity cache, and the `wo_tls_conn`
refactor (negotiated app keys, not an embedded driver — read/write serve both
directions). **Live-gated** `just tls-server` (`docs/examples/tls-server`):
**`openssl s_client` validates our hand-rolled server (EC + RSA certs) and gets
the reply — 4/0**, and the outbound `just tls` stays 5/0. Interop fix: the
server loops past the client's change_cipher_spec, and `net.close` drains a TLS
conn before FIN so the reply is never lost to an RST.
### Acceptance criteria
- **Given** a loaded RSA (or ECDSA-P256) identity, **when** a TLS 1.3 client
connects, **then** the handshake completes and application data flows both ways.
- **Given** the loopback gate, **when** our client and server drivers run against
each other, **then** they agree on the traffic keys and round-trip app data.
- **Given** `openssl s_client` against `net.accept_tls`, **when** it connects,
**then** it validates our certificate and completes the handshake (both key
types).
- **Given** a signing path, **when** exercised, **then** it is constant-time
(no secret-dependent branch or index — reviewed and tested), and RFC 6979
nonces match the published vectors.
- **Given** a ClientHello without an x25519 key_share, **when** received, **then**
the connection is refused (no HRR).
### Out of scope
- **mTLS / client certificates**, **session resumption / PSK / 0-RTT**, and
**HelloRetryRequest** — each a later slice if a consumer asks.
- **Correcting the doctrine docs** (language 34/38, porch) — the bookkeeping pass
when G lands, named so it is not forgotten.
## Consumers
Named, so this is not a capability shipped as decoration:
- **[jarvis 1](../jarvis/00-story.md)** — outbound HTTPS to the LLM API (the
reason this story exists).
- **porch** — inbound TLS termination, retiring the mandatory front proxy for a
single-binary deployment.
- **language 38** — the outbound HTTPS half it excluded by doctrine; this story
is where that exclusion is lifted.
## Dependencies
- **runtime-v2 [8](08-symmetric-cipher.md)** — the AEAD (phase A). This story
forces 8 to include **AES-GCM** (TLS 1.3 mandates AES-128-GCM), not ChaCha
alone — a consequence to record in 8's own fork.
- **language [34](../language-runtime-database/34-crypto-builtins.md)** —
SHA-256/HMAC for the key schedule (phase B) and the transcript hash.
- **`net.connect`** (id 110, **landed 2026-09-07**) — the outbound TCP socket the
client handshake runs over; the client half sits directly on it.
## Out of scope
- **HTTP/2.** A separate protocol concern, parked behind language iteration 23
regardless; TLS is its prerequisite, not its owner.
- **Mutual TLS / client certificates.** A later slice if a consumer asks; the
first cut authenticates the server, not the client.
- **Updating the doctrine documents.** Retiring "TLS is the proxy's job" means
correcting [language 34](../language-runtime-database/34-crypto-builtins.md),
[language 38](../language-runtime-database/38-content-platform-capabilities.md)
and [porch](../porch/00-story.md) when this lands — a follow-up bookkeeping
pass, named here so it is not forgotten, not part of the runtime work.
## Risk and test strategy
**This is the highest-risk work in the project, and hand-rolling it raises that
risk, not lowers it.** Hand-rolled RSA, ECDSA, X25519 and ASN.1/X.509 are the
classic sources of real-world CVEs (timing side-channels, padding oracles, chain-
validation bypasses, parser memory bugs). The decision to hand-roll is recorded
and owned; the mitigations are non-negotiable:
- **Constant-time** for every secret-dependent operation (X25519, RSA/ECDSA,
AEAD) — verified, not assumed.
- **Reference-tested**: every phase gated against a reference implementation —
`openssl s_client`/`s_server`, real published cert chains, and the RFC 8448
TLS 1.3 test vectors — plus an ASan/UBSan leg on the parser and bignum code.
- **Negative tests as first-class**: an expired cert, a wrong hostname, a broken
chain, a tampered CertificateVerify and a downgrade attempt must each be
refused, with a test that fails if they are accepted.
- **No partial-trust states**: a validation that cannot complete refuses the
connection; there is no "warn and continue".
## Info
This is the heaviest iteration in the runtime-v2 track by a wide margin — a
subsystem, not a builtin-sized seam — and the only one that reverses a project
doctrine. It is pure I/O-plane and compute work (a handshake layer over the
existing socket verbs plus the crypto ladder); no actors, so it is not exposed to
the lang-41 hang. It gates jarvis entirely: until at least phases A–F land,
jarvis cannot reach a model at all. Realistically it is a multi-phase effort
measured in weeks, and phases C (X25519), D (signatures/RSA) and E (X.509) may
each become their own iteration when picked up. Implementation order is the
ladder, bottom-up: A (via rv2 8) → B → C → D → E → F, with G (inbound server)
last.