Skip to content

feat: tls 1.3 client session driver - #438

Merged
kacy merged 1 commit into
mainfrom
feat/mtls-client-session
Jun 10, 2026
Merged

feat: tls 1.3 client session driver#438
kacy merged 1 commit into
mainfrom
feat/mtls-client-session

Conversation

@kacy

@kacy kacy commented Jun 9, 2026

Copy link
Copy Markdown
Owner

summary

fourth PR-4 chunk (after #435, #436, #437). adds the client-side TLS 1.3
handshake + a ClientSession whose read/write methods transparently
encrypt and decrypt over the record layer. the client verifies the
server's cert against the cluster CA, optionally enforces a SAN URI,
and can present its own cert when the server sends CertificateRequest.

what's in here

src/tls/client_session.zig with:

  • doHandshake(io, alloc, fd, HandshakeOpts) -> ClientSession — runs
    the full TLS 1.3 client handshake on an already-connected fd, returns
    the session ready for app data.
  • ClientSession.read / write / deinit — record-layer encryption around
    the same fd, with carryover buffers for partial reads.
  • per-side CertificateVerify (uses .client from PR feat: client hello + certificate request builders #437), empty-cert
    response when the server asks but we have nothing.

scope:

  • TLS 1.3, AES-256-GCM, X25519, ECDSA P-256 + SHA-256 (same profile as
    the existing server, what x509_gen produces).
  • single-cert chains.

tests

three end-to-end tests on a unix socketpair. server side is a small
in-test helper that mirrors proxy/session_runtime's handshake portion
(no proxying); a follow-up chunk refactors session_runtime to expose
a shared acceptServerHandshake so the helper goes away.

  1. happy path — handshake completes, one byte of app data round-trips
  2. wrong CA — server cert signed by an unrelated CA → UntrustedServerCert
  3. identity mismatch — server cert's SAN doesn't match the expected URI
    IdentityMismatch

the negative tests half-close the socket after the client errors out so
the server thread unblocks and joins cleanly.

not in this PR

  • server-side MtlsOpts (CertificateRequest emit + client-cert verify
    on the server side)
  • require_client_cert end-to-end and the empty-cert / wrong-client-CA
    negative tests
  • proxy wiring (PR 5)

still no auto-merge — security-critical handshake code.

connects an already-open fd to a TLS 1.3 server: builds + sends
ClientHello, parses ServerHello, derives handshake keys, walks the
encrypted EE/Cert/CertVerify/Finished stream, verifies the server
cert against the cluster CA (with optional SAN identity check),
verifies the server's CertVerify signature against the server cert
pubkey, sends client Cert + CertVerify if asked, then client
Finished. derives application keys and returns a ClientSession
whose read/write methods transparently encrypt/decrypt over the
record layer.

three end-to-end tests on a unix socketpair: happy-path handshake +
one byte of app data, wrong-CA reject, identity-mismatch reject.
the negative-case tests half-close the socket on client error so
the server thread unblocks and joins cleanly.

scope: same narrow profile as the rest of the stack — TLS 1.3 only,
AES-256-GCM, X25519, ECDSA P-256 / SHA-256.
@kacy
kacy merged commit 121cba8 into main Jun 10, 2026
9 of 10 checks passed
@kacy
kacy deleted the feat/mtls-client-session branch June 10, 2026 03:27
kacy added a commit that referenced this pull request Jun 22, 2026
…442)

small glue between the existing connectToUpstream-style raw TCP dial
and the client_session.doHandshake driver from #438. callers pass an
address + optional CA + optional client cert/key and get back a tagged
union: .bare (plain fd, when no CA was supplied) or .session (an mTLS
ClientSession wrapping the dialed fd).

the union shape keeps the consumer (forwardSingleAttempt and friends)
agnostic to the per-target mTLS decision — they just match on the
outcome and use one of two well-defined apis.

two tests on a real local TCP listener:
1. dial with no CA → .bare fd, accept on the listener, close.
2. dial with full mTLS opts → handshake completes against
   session_runtime.acceptServerHandshake on the listener side.

production wiring (plumbing peer_mode through the L7 service registry
and switching reverse_proxy reads/writes onto the union) is the next
chunk — this PR just lands the helper so that chunk has a clean
target.
kacy added a commit that referenced this pull request Jun 23, 2026
* feat: carry peer_mode through upstream + service registry types

adds tls.peer to the spec-side types so the L7 proxy can read the
service's mtls posture at dial time:

- Upstream gains peer_mode (defaults .off)
- ServiceDefinition and ServiceState gain peer_mode (defaults .off)
- ServiceSnapshot exposes peer_mode read-only
- assignCompatProxyFields copies peer_mode from def to state
- snapshotService propagates peer_mode
- resolveUpstreamWithPolicy copies peer_mode onto each Upstream
  candidate and onto the selected one returned to the caller

the cluster-state-DB column to persist peer_mode end-to-end (so the
manifest's tls.peer reaches resolveUpstream in production) is a small
follow-up schema migration; this PR's purpose is the in-memory carry
so forwardSingleAttempt can dispatch on it.

* feat: forwardSingleAttempt dispatches mtls upstreams to client_dial

upstream.peer_mode != .off now branches into a new
forwardSingleAttemptMtls:
- loads the cluster CA via store.getClusterCa
- calls client_dial.dial with the CA pem + sni
- writes the request and drains the response through the session,
  no pooling (mtls sessions hold encryption state and can't share a
  bare-fd pool key)

policy:
- .require + missing cluster CA → ClusterCaMissing
- .warn + missing cluster CA → log + downgrade to the plaintext
  dial+pool path (kept as forwardPlainAttempt, exact copy of the
  legacy leg) so service-to-service traffic keeps flowing while the
  ca_bootstrap thread catches up

readResponseFromSession is the session-aware equivalent of the
existing bare-fd readResponse: drains chunks until PeerClosed (the
session's orderly EOF) or max_bytes, returns the bytes.

three tests on a duck-typed FakeSession cover the read loop's
happy path, max-bytes rejection, and immediate-close fallthrough.
the live tls handshake itself is already covered end-to-end by
the socketpair tests in #438/#439/#442.

* feat: persist tls.peer in the services table

closes the manifest → registry → upstream loop. previously peer_mode
was added to all the in-memory types but production code paths could
not set it because the cluster state DB had no column. with this
commit:

- services.peer_mode TEXT column (default 'off'); ALTER TABLE
  migration for existing databases (no NOT NULL so the migration is
  forward-safe).
- ServiceRecord.peer_mode (?[]const u8 — null means 'off', keeps
  every existing literal compiling).
- services_core.createInDb inserts peer_mode; syncConfig takes a
  peer_mode arg and updates the column alongside lb_policy.
- syncServiceDefinitions in the manifest apply path now passes
  svc.tls.peer.label() through.
- serviceDefinitionFromRecord parses the textual peer_mode back into
  the enum so registry → snapshot → Upstream carries it cleanly.

with this in place: a manifest with tls.peer = 'require' on a service
flows end-to-end — orchestrator writes 'require' to the services row,
the registry snapshot reads it back as .require, resolveUpstream
copies it onto Upstream, and forwardSingleAttempt dispatches mtls
upstreams to client_dial (the changes from the previous commits).

one new round-trip test (syncConfig persists peer_mode) confirms the
column behaves and updates apply.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant