Skip to content

fix(tls): honor ciphers/SECLEVEL from every configured source when building TLS listeners#1841

Merged
kriszyp merged 3 commits into
mainfrom
fix/tls-effective-ciphers
Jul 17, 2026
Merged

fix(tls): honor ciphers/SECLEVEL from every configured source when building TLS listeners#1841
kriszyp merged 3 commits into
mainfrom
fix/tls-effective-ciphers

Conversation

@heskew

@heskew heskew commented Jul 17, 2026

Copy link
Copy Markdown
Member

Summary

A TLS listener has exactly one effective cipher string: OpenSSL takes the cipher list — and any @SECLEVEL=n embedded in it, which governs client-certificate chain verification — from the context the server was created with, and an SNI-swapped context does not carry its own cipher list onto the connection. Harper applied only tls.ciphers ?? tls[0].ciphers and silently ignored every other configured ciphers value: entries beyond tls[0] and certificate records (authority records' ciphers were never read at all).

This resolves the listener cipher string from every configured source (resolveEffectiveTlsCiphers in security/keys.ts), wired into both Node listener-creation sites (server/http.ts, server/threads/threadServer.js):

  1. Candidates come from the listener's config layers in priority order (operationsApi.tls before root tls for the operations listener; array entries relevance-filtered — CA entries only when the listener verifies client certs, served entries matched by uses with the selector's tolerant rule incl. legacy 'https' and no-uses generics) and from relevant certificate records.
  2. The suite list comes from the highest-priority suite-bearing candidate — a client CA that needs DEFAULT@SECLEVEL=0 (SHA-1-signed chains, else authorizationError: UNSPECIFIED) must not replace or broaden the listener's configured suites.
  3. The security level is the minimum explicit @SECLEVEL across candidates; strings without one keep the runtime default (no level is assumed — the OpenSSL default varies across Node builds). The two compose, e.g. HIGH:!aNULL + CA DEFAULT@SECLEVEL=0HIGH:!aNULL@SECLEVEL=0, and any cross-source composition or dropped suite list is logged.
  4. When a cert-table change alters the resolved value after boot, updateTLS warns (once per value) that a restart is required — the listener-level string can't be swapped by an SNI-context rebuild.

Closes #1840.

Purpose

An mTLS deployment whose client certs chain through SHA-1-signed CAs configured ciphers: DEFAULT@SECLEVEL=0 on the CA entry — the natural, least-privilege place. list_certificates showed the override present while the verify path rejected every client. The only working escape hatch (top-level tls.ciphers) relaxes all listeners and is easy to lose in platform config reconciliation. (Field incident on 4.7; 5.x has the same shape.)

Where to look

  • Suite/level composition (resolveEffectiveTlsCiphers) is the policy decision: preserve the configured suites, apply only the minimum explicit @SECLEVEL any relevant cert requires, warn on everything composed or dropped. This follows the external (Codex) review on the first cut, which correctly objected to whole-string replacement and to an assumed default security level.
  • Authority entries/records participate only when the listener verifies client certs. A CA's SECLEVEL matters exactly when its chains are being verified; a non-mTLS listener is not relaxed.
  • server.appliedCiphers / server.verifiesClientCerts stamps at both creation sites feed the divergence warning in updateTLS; the Bun path is untouched (BoringSSL has no @SECLEVEL).
  • Certificate records are read at listener creation (cold path, boot-time) and on cert-table rebuilds (debounced); nothing added to per-connection paths.

Tests

  • Unit (unitTests/security/keys.test.js): 17 cases over the pure resolver — layer precedence (incl. operationsApi.tls), array entries beyond [0], CA-entry mTLS gating, authority/uses/legacy-https relevance, scalar-uses normalization, suite+minimum-SECLEVEL composition, no-assumed-level, bare-@SECLEVEL anchoring.
  • Integration (integrationTests/security/tls-ciphers-seclevel.test.ts): boots Harper with tls as an array whose second entry is a SHA-1-signed client CA carrying ciphers: DEFAULT@SECLEVEL=0 (the previously-ignored position) and http.mtls.required; asserts a client cert chained through that CA completes the handshake and an unchained cert is refused. Verified the test fails on unfixed code (the SHA-1 chain is refused — the incident) and passes with the fix.

Local runs: test:unit:main green except two consistently-failing-locally, unrelated cases also failing without this change (globalIsolation "should enforce process spawning restrictions", uwsServer "rejects a body over maxBodyBytes with 413" EPIPE) and an intermittent macOS-local EMFILE teardown flake in keys.test.js tied to system fd pressure, not this diff. CI should arbitrate.

Cross-model review

Thorough pass (Gemini leg + Harper-domain adjudication): no blockers, no significant concerns. Gemini's one substantive finding (an isMtls/mtls asymmetry that could fire a spurious restart warning) was fixed via the verifiesClientCerts stamps before this PR was opened; the repeat-warning and Bun-comment suggestions are folded in. Caveat: the Codex leg failed to complete (CLI exited during context collection), so there is no Codex coverage on this run. A follow-up external (Codex) review raised five findings on the first-cut resolution policy (whole-string replacement, assumed default level, unfiltered tls[] entries, operationsApi.tls gap, relevance drift from the selector); all five are addressed by the composed model in 773b42b — see the resolved threads.

Docs

reference/http/tls.md in HarperFast/documentation documents tls.ciphers as a single string only; a companion docs PR describing the multi-source resolution (and the restart-required caveat) will follow once the resolution policy here survives review.

🤖 Generated with Claude Code (Claude Fable 5)

…ilding TLS listeners

A TLS listener has exactly one effective cipher string: OpenSSL takes the
cipher list (and any @SECLEVEL, which governs client-cert chain
verification) from the context the server was created with; SNI-swapped
contexts don't carry their own cipher list onto the connection. Harper
applied only tls.ciphers ?? tls[0].ciphers and silently ignored every
other configured value — tls[] entries beyond [0] and certificate
records, including client-CA records carrying DEFAULT@SECLEVEL=0 for
SHA-1-signed chains, which then failed with authorizationError
UNSPECIFIED on valid in-date certs.

resolveEffectiveTlsCiphers (security/keys.ts) now resolves the listener
string from all sources: top-level tls.ciphers wins; otherwise tls[]
entries plus relevant cert records (uses-matched, and authorities when
the listener verifies client certs) are candidates, with the lowest
explicit @SECLEVEL winning conflicts and everything ignored logged.
Post-boot changes to the resolved value warn (once per value) that a
restart is required. Bun path untouched (BoringSSL has no @SECLEVEL).

Closes #1840

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves TLS cipher resolution by ensuring that the single effective cipher string (and its @SECLEVEL) governing a TLS listener is correctly resolved from all configured sources, rather than only checking the first entry. It updates the HTTP and socket servers to use this resolved configuration and adds corresponding unit and integration tests. The review feedback suggests guarding the cleanup step in the integration test's after hook to prevent a secondary TypeError if the setup fails early.

Comment thread integrationTests/security/tls-ciphers-seclevel.test.ts Outdated
Comment thread integrationTests/security/tls-ciphers-seclevel.test.ts Outdated
@claude

This comment has been minimized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@heskew
heskew marked this pull request as ready for review July 17, 2026 00:56

@heskew heskew left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ignored ciphers behavior is real, and the base/head regression is convincing. The listener wiring, restart warning, teardown guard, and cold-path performance look sound. I think the resolution policy needs the inline issues addressed before merge so a CA cannot relax unrelated listeners or replace the server's cipher suites, and listener-specific values are not still silently ignored.

🤖 Reviewed by OpenAI Codex.

Comment thread security/keys.ts Outdated
Comment thread security/keys.ts Outdated
Comment thread security/keys.ts Outdated
Comment thread security/keys.ts Outdated
Comment thread security/keys.ts Outdated
…picking one cipher string

Addresses the external review on #1841: config array entries are now
relevance-filtered like certificate records (CA entries only when the
listener verifies client certs; uses matched with the selector's
tolerant rule incl. legacy 'https' and no-uses generics), the suite
list is preserved from the highest-priority suite-bearing candidate
with only the minimum explicit @SECLEVEL composed on (no assumed
runtime default level), and the operations API listener resolves from
operationsApi.tls before root tls so an inherited-certificate override
is no longer ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@heskew

heskew commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

@kriszyp forward-looking note for the edge-termination roadmap (no action needed on this PR): if public mTLS ever moves to symphony's edge termination (h2/h3 routes), the legacy-client-CA problem this PR fixes moves with it — client chains signed with weak digests (e.g. SHA-1-signed CAs) need a SECLEVEL=0-style relaxation in whatever stack verifies them, ideally scoped per route rather than listener-wide. Harper-side replication and operations-API mTLS stay Harper-terminated regardless, so this resolver remains the fix there.

🤖 posted by Claude Code on Nathan's behalf

@heskew
heskew requested a review from kriszyp July 17, 2026 16:16
@kriszyp
kriszyp merged commit 56a2bac into main Jul 17, 2026
63 checks passed
@kriszyp
kriszyp deleted the fix/tls-effective-ciphers branch July 17, 2026 20:39
@heskew

heskew commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Docs follow-up (promised in the description): HarperFast/documentation#598 — multi-source cipher/SECLEVEL resolution reference. 🤖 posted by Claude Code on Nathan's behalf

@kriszyp

kriszyp commented Jul 21, 2026

Copy link
Copy Markdown
Member

forward-looking note for the edge-termination roadmap (no action needed on this PR): if public mTLS ever moves to symphony's edge termination (h2/h3 routes), the legacy-client-CA problem this PR fixes moves with it

Looking into this, RusTLS flatly refuses and will not support insecure ciphers (SECLEVEL=0), so for users that need it, Symphony won't terminate (t will forward TLS on to Harper).

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.

Per-certificate TLS ciphers (including client-CA SECLEVEL overrides) silently ignored — only tls.ciphers/tls[0].ciphers takes effect

2 participants