Skip to content

feat: server-side mtls — certificaterequest + client-cert verify - #439

Merged
kacy merged 1 commit into
mainfrom
feat/mtls-server-side
Jun 11, 2026
Merged

feat: server-side mtls — certificaterequest + client-cert verify#439
kacy merged 1 commit into
mainfrom
feat/mtls-server-side

Conversation

@kacy

@kacy kacy commented Jun 10, 2026

Copy link
Copy Markdown
Owner

summary

fifth PR-4 chunk (after #435, #436, #437, #438). adds the server side
of mTLS — CertificateRequest emit, client Certificate + CertificateVerify
parse, X.509 chain verify against the cluster CA, and ECDSA-verify of
the client's CertVerify signature. with this, an in-process mTLS
handshake completes end-to-end and the verified peer identity (SAN URI)
is surfaced to the caller.

what's in here

  • refactor: extract handshake portion of handleTlsSession into a new
    acceptServerHandshake() -> ServerSession. zero behavior change for
    the existing non-mTLS path — handleTlsSession now calls accept with
    MtlsOpts = null then runs the forwarding loop with the returned
    session.
  • new MtlsOpts: require_client_cert, trust_ca_pem,
    expected_identity?, now_unix. when non-null:
    • server inserts CertificateRequest between EncryptedExtensions and
      its own Certificate
    • after the server's Finished, expects client Certificate +
      (optional) CertificateVerify + Finished, possibly spread across
      several records
    • verifies the cert chain via feat: x509 chain verifier for the mtls trust path #435's verifyLeafAgainstCa
    • verifies the client's CertVerify with the .client context string
      against the cert's pubkey
    • require=true rejects an empty Certificate with
      error.MissingClientCert; require=false (warn) accepts an empty
      cert and surfaces no identity
  • new ServerSession.peer_identity: dup'd SAN URI (or subject CN
    fallback) for successful mTLS, null otherwise
  • minor parseCertificateMessage fix: empty cert lists are valid per
    RFC 8446 §4.4.2 (TLS 1.3's "I have nothing" signal). previously
    short-bodied as Truncated.

tests

three e2e tests on a unix socketpair that drive the real
client_session.doHandshake against the new acceptServerHandshake:

  1. valid client cert — handshake completes, peer_identity is the
    client cert's SAN URI
  2. require_client_cert + empty cert — server returns
    MissingClientCert
  3. warn mode + empty cert — handshake completes, identity is null

not in this PR

  • proxy/listener wiring (PR 5)
  • manifest tls.peer = require|warn|off
  • yoq cert service <name> CLI + handshake metrics
  • the small ergonomic refactor that pushes the test-only server
    handshake helper out of client_session.zig (since the production
    acceptServerHandshake is now reachable from tests)

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

extracts the handshake portion of handleTlsSession into a new
acceptServerHandshake() that returns a ServerSession. handleTlsSession
keeps doing exactly what it did before — calls accept(.., null, ..)
then enters the forwarding loop with the returned session.

new MtlsOpts threads client-cert behavior through:
- when non-null, the server emits CertificateRequest between
  EncryptedExtensions and Certificate
- after the server's Finished, expects Certificate (possibly empty),
  optional CertificateVerify, then Finished
- verifies the cert chain against trust_ca_pem, then verifies the
  CertVerify signature against the cert's pubkey using the .client
  context string
- require_client_cert=true rejects an empty Certificate with
  MissingClientCert; require_client_cert=false accepts (warn mode)
- returns the SAN URI (or subject CN) as session.peer_identity

parseCertificateMessage now accepts an empty cert list per RFC 8446
§4.4.2 so the server can detect the empty-cert case.

three e2e tests on a socketpair drive the new path through the real
client_session.doHandshake:
1. valid client cert → handshake completes, identity surfaces
2. require + empty cert → server returns MissingClientCert
3. warn mode + empty cert → handshake completes, no identity
@kacy
kacy merged commit 24480f7 into main Jun 11, 2026
10 checks passed
@kacy
kacy deleted the feat/mtls-server-side branch June 11, 2026 12:44
kacy added a commit that referenced this pull request Jun 18, 2026
threads the manifest's tls.peer setting through the backend registry
to the L7 listener:
- Backend gains a peer_mode field (default .off)
- BackendRegistry.register takes a peer_mode parameter
- startup_runtime passes svc.tls.peer when registering each domain
- handleTlsSession takes an optional MtlsOpts; the listener builds
  one from cluster CA + backend.peer_mode and passes it through
- listener loads the cluster CA on demand; missing CA on a tls.peer
  service is a misconfiguration — logs and downgrades to plain TLS

two new tests on BackendRegistry confirm peer_mode round-trips through
register/lookup and is overwritten correctly. the end-to-end mTLS
protocol is already covered by the socketpair tests in
session_runtime (PR #439); this PR's contract is just "the listener
picks up tls.peer and reaches acceptServerHandshake with the right
MtlsOpts", which the type-level wiring proves.
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