feat(tls): explicit WebSocket TLS trust, rustls-tls-native-roots feature, Deepgram::tls_config - #175
feat(tls): explicit WebSocket TLS trust, rustls-tls-native-roots feature, Deepgram::tls_config#175dg-coreylweathers wants to merge 5 commits into
Conversation
… feature and tls_config
Every WebSocket surface (live transcription, Flux STT, Flux TTS) now
connects through one explicit rustls connector owned by the Deepgram
client, so trust roots and provider are identical across surfaces and
no longer depend on which TLS features other crates in the dependency
graph enable on tokio-tungstenite.
- New `rustls-tls-native-roots` cargo feature: also trust the OS
certificate store, merged on top of the bundled webpki roots (never
instead of them). Named after the tokio-tungstenite and reqwest
features it mirrors. Fixes connections behind TLS-inspecting proxies
(Zscaler) that 0.10.1's explicit connector broke by bypassing the
feature-unified OS-roots merge consumers had been relying on.
- New `Deepgram::tls_config(impl Into<Arc<rustls::ClientConfig>>)`:
supply your own config once, on the client; every WebSocket uses it
verbatim. `rustls` re-exported as `deepgram::rustls`.
- New `DeepgramError::UntrustedTlsCertificate { host, trust, source }`
whose message names the remedy for the trust roots in effect.
- New `deepgram::tls` module with `TlsTrust`.
- Connect-diagnostics records gain `tls_trust` and `tls_resumed`; the
TLS config is resolved before any phase timer starts so an OS-store
read is never charged to `tls_handshake_ms`.
- Default config is built once per client and reused, enabling TLS
session resumption across connections from the same client.
- TLS deps move from `connect-diagnostics` to `listen`/`speak` (already
in the graph via tokio-tungstenite; nothing new is downloaded).
- Local TLS tests for all three surfaces, both paths, every feature
combination; CI matrix extended.
Native-roots test fixture adapted from the feat/native-roots-tls-trust
branch.
Co-authored-by: Lawrence Ng <lawrence.ng@deepgram.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GregHolmes
left a comment
There was a problem hiding this comment.
Review: deepgram-rust-sdk #175, feat(tls): explicit WebSocket TLS trust, rustls-tls-native-roots feature, Deepgram::tls_config
Classification: mixed (code-led)
Verdict: request-changes
Intent
Restore explicit native-root trust for TLS-inspecting proxies, make WebSocket TLS trust consistent across live Listen, Flux STT, and Flux TTS, and offer a client-wide custom rustls configuration. The release is planned as 0.10.2 after Twilio verifies the branch behind Zscaler. This re-review verified the post-review README fix; no earlier GitHub review or local review record was available to map.
Blocking
- [B1]
src/lib.rs:426andREADME.md:120,tls_configis documented as covering plaintext WebSockets.
Title: [B1] TLS configuration documentation falsely covers ws:// connections
Summary: Deepgram::tls_config and the README say the supplied rustls configuration is used by every WebSocket connection. The SDK deliberately converts an http base URL to ws://, and the phase-timed connector explicitly takes its MaybeTlsStream::Plain path for that scheme; no TLS handshake or certificate verification occurs. The public self-hosted constructors document http:// base URLs, so this is a realistic path, not only a theoretical transport distinction.
Expected: Documentation states that custom trust roots and TLS configuration apply only to wss:// connections, and it warns that ws:// is plaintext.
Observed: A developer can configure a private CA and reasonably believe it protects the documented self-hosted WebSocket connection even though the configuration is unused.
Recommended fix: Amend the tls_config docs and README to say "every WSS WebSocket connection" and add a short warning to use https:// or wss:// whenever credentials or private traffic are involved. Keep the current ws://localhost examples explicitly scoped to local testing.
Should-fix
- [S1]
src/tls.rs:150, native-root load errors are hidden but error hints report successful OS-store checking.
Title: [S1] Native-root failure produces a misleading certificate remedy
Summary: rustls-native-certs errors are written only to tracing and then discarded while the client continues with webpki roots. When an invalid or unreadable SSL_CERT_FILE produces no usable roots, the later WebpkiAndNative error says the OS store was checked and suggests pointing SSL_CERT_FILE at the CA, even though that attempted source may be the failed input.
Expected: Certificate errors distinguish successfully loaded native roots from a failed native-root load and offer a remedy that matches the actual trust store.
Observed: The customer receives an actionable-looking but false diagnosis unless they also happen to capture the tracing warning.
Recommended fix: Preserve native-root load status in the TLS settings and use it in UntrustedTlsCertificate; either surface the load failure directly or change the hint to state that native roots could not be loaded and recommend a valid SSL_CERT_FILE or tls_config.
- [S2]
src/diagnostics.rs:129, plaintext diagnostic records claim certificate trust information.
Title: [S2] tls_trust is reported for connections without TLS
Summary: ConnectRecord::tls_trust is documented as the roots used to verify a server certificate and is always populated. The diagnostics connector records the configured trust mode even when the URL is ws:// and takes the plain TCP path without a certificate.
Expected: A plaintext connection does not report TLS verification state as though a certificate was checked.
Observed: Consumers can read tls_trust: webpki for a ws:// attempt with no TLS phase.
Recommended fix: Make tls_trust optional and omit it for ws://, or revise its documentation to say it is the configured trust mode and require callers to use tls_handshake_ms to determine whether TLS occurred. The optional field is clearer because this is an unreleased additive diagnostic field.
Nits
- None.
Verified (evidence)
- The follow-up README change correctly wraps DER bytes in
rustls::pki_types::CertificateDerbefore callingRootCertStore::add; the doc test compiles. - Client TLS routing is correct on all claimed surfaces: live Listen, Flux STT, Flux TTS, and the diagnostics path pass the client-owned connector/configuration.
- Default webpki roots, additive native roots, custom configurations, UnknownIssuer mapping, and session resumption are covered by the local TLS integration tests.
- Local Rust 1.94.1 passes
cargo test --all-features --no-fail-fast, clippy, fmt, docs, and the reviewed feature combinations. CI reports all checks, including SemVer, passing. - The branch merges cleanly with current
main.
Needs human
- Twilio's Zscaler validation remains the stated pre-release gate. The local fixture proves that
SSL_CERT_FILEroots are merged and routed on every claimed WebSocket surface, but it cannot verify Twilio's proxy and deployment configuration. The release owner should hold the 0.10.2 tag until jfoster confirms the git-dependency test.
Developer-facing messaging
- The changelog accurately explains the 0.10.1 regression and the additive native-root feature. It should make the same WSS-only distinction as [B1], especially because it mentions self-hosted deployments and credentials.
…e tls_trust optional (#175 review) Addresses Greg Holmes's review of #175. - B1: `Deepgram::tls_config`, the `deepgram::tls` module docs, the README "TLS Trust" section, the `rustls-tls-native-roots` feature comment, and the Unreleased changelog entries now say the feature and `tls_config` apply to `wss://` connections only. Each warns that an `http://` base URL yields plaintext `ws://` with no TLS handshake and no certificate verification, keeps `http://localhost` scoped to local testing, and recommends `https://` whenever an API key, temporary token, or private traffic (including self-hosted deployments) is involved. The two self-hosted constructors (`with_base_url`, `with_base_url_and_api_key`) carry the same note next to their `http://` examples. - S1: the native-root load status now travels with the default TLS config instead of being dropped after a tracing warning. `TlsSettings` caches a `ResolvedTls { config, trust }`; `default_root_store` returns the `TlsTrust` that actually resulted, and a new `TlsTrust::WebpkiNativeUnavailable` (`webpki_native_unavailable`) marks a feature-enabled build whose OS store yielded no roots. Its `UntrustedTlsCertificate` hint says the native roots could not be loaded and points at a valid `SSL_CERT_FILE` or `Deepgram::tls_config`, instead of claiming the OS store was checked. All three connect surfaces resolve config and trust together so the hint can never describe roots other than the ones the handshake used. New process-isolated fixture `tests/connect_native_roots_unavailable_local.rs` points `SSL_CERT_FILE` at a garbage PEM and asserts the variant, the hint text, the `tls_config` fallback, and the diagnostics record. - S2: `ConnectRecord::tls_trust` is now `Option<TlsTrust>`, omitted from JSON when `None`. It is `None` for plaintext `ws://` attempts (no TLS phase) and present on every `wss://` attempt, refined to the trust in effect once the TLS phase runs. Docs, `ConnectRecord::sample`, the local integration tests, and a new unit test cover both shapes. The field is unreleased, so the type change is additive relative to 0.10.1 (`cargo semver-checks --baseline-version 0.10.1 --release-type patch`: 223 pass, 0 fail). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks Greg. All three findings addressed in f7c36e5. [B1] [S1] Native-root load failure produced a misleading hint — [S2] Gates: fmt, clippy |
…pe docs and diagnostics trust - tests/connect_native_roots_unavailable_local.rs: on Linux `cargo` exports `SSL_CERT_DIR` (openssl-probe) into every test process, so the garbage `SSL_CERT_FILE` alone still loaded ~150 real roots and the SDK correctly reported `WebpkiAndNative`. The fixture now overrides both variables with a non-PEM file and an empty directory, but only around the first connect: reqwest's platform verifier reads the same variables when the `Deepgram` client is built and errors on an empty store, while the WebSocket trust resolves lazily on first connect. A mutex serializes that window and a Drop guard restores the environment. - README / tls.rs / lib.rs: drop the "works in containers with no OS store" claim (true for wss:// only; the REST client uses the OS store), note that behind a TLS-inspecting proxy REST works while wss:// fails until a trust option is enabled, and qualify the feature bullet with `wss://`. - diagnostics: `DiagnosticsGuard::set_tls_trust` is now called as soon as the client's TLS config is resolved, so a connect attempt that fails in DNS or TCP records the effective trust (e.g. `webpki_native_unavailable`) rather than the configured one; it only refines records that already carry a trust, so plaintext `ws://` never gains one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up push after a second local review pass found the new Why it failed on Linux only: Also in this push:
Host gates all green (fmt, clippy, |
…ptional tracing, diagnostics doc Second-pass review fixes for #175, plus a credential-leak fix in Debug output. - UntrustedTlsCertificate hints that name SSL_CERT_FILE now ask for a PEM bundle holding the CA together with the public roots you rely on, and say the variable replaces the OS store for these WebSockets and, on Linux, for the REST client too. A file holding only the proxy CA broke REST calls to hosts that CA did not sign. Mirrored in CHANGELOG and the README TLS section; new unit test pins the wording. - Plaintext ws:// clients no longer resolve TLS: TlsSettings::resolve_for returns ConnectTls::Plain for a non-wss URL, so the default config is not built and the OS certificate store is neither read nor warned about. wss:// behavior is byte-identical (same Connector::Rustls). The phase-timed path mirrors tokio-tungstenite's TlsFeatureNotEnabled for a wss request without a config. Unit test covers ws:// vs wss:// resolve. - connect_duration_ms doc and the CHANGELOG "Fixed" bullet note that a client's first connect can include the one-time trust-store load, which is attributed to no phase. - tls_trust doc: the configured trust until the client's TLS config has resolved, then the effective one. - {:?} on Deepgram (and sub-clients holding it) printed the API key or temp token via reqwest's Client Debug, which dumps default_headers. The Authorization HeaderValue is now marked sensitive; unit test asserts the literal credential and scheme prefix are absent. Noted under Fixed. - tracing is optional and declared by the internal __tls feature, so a manage-only build no longer lists it. - connect_native_roots_local: the SSL_CERT_FILE fixture is reference counted (Mutex<Weak<_>>) and its PEM is removed from the temp dir when the last running test drops it, with a per-fixture file name so a racing cleanup cannot remove a successor's file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
One more push (2557efd) from a fresh full review pass on the branch, no blockers found, but a few things worth having before 0.10.2:
Host gates all green (fmt, clippy, all-features and default-features tests, rustdoc, all no-default feature combos) and |
Why
Twilio reported that enabling the 0.10.1
connect-diagnosticsfeature breaks websocket connections behind their Zscaler proxy. Root cause: our crate has never enabled tokio-tungstenite'srustls-tls-native-roots. Twilio's build enables it through another crate, and tokio-tungstenite's default connector merges the OS certificate store when that feature is unified in. 0.10.0 used that default connector, so the Zscaler CA was trusted by accident. The explicit webpki-only connector 0.10.1 introduced (so the timed and untimed paths could not diverge) bypassed that merge. Our regression, not their environment.Twilio asked for a native-roots feature or a way to supply the TLS connector directly. This PR does both, and fixes the class of bug rather than the instance.
What
rustls-tls-native-rootscargo feature. Also trusts the OS certificate store, merged on top of the bundled webpki roots (never instead, so containers with no OS store keep working). Named after the tokio-tungstenite and reqwest features it mirrors. For Twilio: one word inCargo.toml, no code change.Deepgram::tls_config(impl Into<Arc<rustls::ClientConfig>>). Supply your own rustls config once, on the client; every websocket it opens uses it verbatim.rustlsis re-exported asdeepgram::rustlsso versions match.DeepgramError::UntrustedTlsCertificate { host, trust, source }. Returned instead of a bareWsErroron an unknown issuer; the message names the remedy for the trust roots in effect.src/tls.rs). Trust can no longer differ by surface or be changed by downstream feature unification. Flux users who relied on the unification accident need the feature too; the changelog says so plainly.tls_trustandtls_resumed(schema version unchanged, additive). The TLS config is resolved before the DNS timer starts, so an OS-store read is never charged totls_handshake_ms. Default config is built once per client, so TLS sessions resume across connections from the same client.rustls,tokio-rustls,rustls-pki-types,webpki-rootsmove fromconnect-diagnosticstolisten/speakvia an internal__tlsfeature (already in the graph through tokio-tungstenite; nothing new downloads). rustls floor raised to^0.23.27forhandshake_kind().tracingbounded to<0.2.From Lawrence's branch
The trust-merge design (webpki always present, OS roots additive, warn-and-continue on bad certs, mirroring upstream) and the
SSL_CERT_FILEtest fixture come fromfeat/native-roots-tls-trust, with co-author credit. Changed from that branch: a feature flag instead of a per-builder method, no process-wide cache or TTL (the config lives on the client instead), and the code moved out of the diagnostics module so every surface uses it.Tests and gates
tests/common/mod.rs(local TLS server),tests/connect_tls_config_local.rs(7 tests),tests/connect_native_roots_local.rs(5 tests): every surface, both connect paths, feature on and off. Includes the session-resumption assertion and the error-hint assertions.cargo fmt,cargo clippy --all-targets --all-features, 9 feature combinations with-D warnings,cargo test --all-features(112 unit, 25 local integration, 142 doctests), default and listen-only test runs,RUSTDOCFLAGS=-D warnings cargo doc --all-features --no-deps, 1.87 toolchain check, nightly minimal-versions job as CI runs it.cargo semver-checks --baseline-version 0.10.1 --all-features --release-type patch: 223 pass, 0 fail. Ships as 0.10.2.Before tagging
Twilio (jfoster) verifies this branch behind Zscaler with a git dependency. Then a separate
chore: release 0.10.2PR.🤖 Generated with Claude Code