Skip to content

fix(tls): trust OS root certificates for wss connections - #7029

Open
nahyeongjin1 wants to merge 1 commit into
block:mainfrom
nahyeongjin1:fix/ws-tls-native-roots
Open

fix(tls): trust OS root certificates for wss connections#7029
nahyeongjin1 wants to merge 1 commit into
block:mainfrom
nahyeongjin1:fix/ws-tls-native-roots

Conversation

@nahyeongjin1

@nahyeongjin1 nahyeongjin1 commented Aug 29, 2026

Copy link
Copy Markdown

Summary

Every wss client in the workspace builds its rustls trust store from
webpki-roots alone, so it ignores the operating system trust store. On a
machine where the OS trusts a CA that the bundled Mozilla root set does not —
the common case behind a TLS-inspecting corporate proxy, or with a self-hosted
relay using a private CA — every wss:// connection fails at the handshake:

WebSocket connection failed: IO error: invalid peer certificate: UnknownIssuer

Desktop pairing surfaces this straight to the user: start_pairing
(desktop/src-tauri/src/commands/pairing.rs) never gets a socket, so the QR
code never renders and the phone cannot be paired at all.

This enables rustls-tls-native-roots alongside the existing
rustls-tls-webpki-roots rather than replacing it:

-tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
+tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots"] }

tokio-tungstenite merges both sources into a single root store and only
treats an empty native store as fatal when webpki roots are absent
(src/tls.rs), so hosts without a system trust store — minimal containers —
behave exactly as they do today. rustls-native-certs also reads SSL_CERT_FILE and
SSL_CERT_DIR, which gives operators a supported way to supply a custom CA.
Note these replace the platform store rather than extend it — when
SSL_CERT_FILE is set, the native certificates are read from that file
instead of the platform trust store. The webpki roots stay in the store
either way, so public endpoints keep verifying.

No call sites change. The two manifests that pin the feature set are the only
edits; the remaining crates inherit through workspace = true.
rustls-native-certs was already resolved in both lockfiles, so this adds a
dependency edge rather than a new package.

Trade-off, stated deliberately: this widens the client's trust scope to
whatever the host already trusts. On a machine with an inspection CA
installed, the app will now accept the intercepted connection — the same
posture browsers and curl already take there. That is the intent; the
opt-out is removing the CA from the host, not per-connection configuration.

Concretely, what that exposes: KIND_STREAM_MESSAGE (kind:9) events carry
plaintext content, so transport confidentiality is TLS alone. Before this
change an inspecting proxy caused the socket to fail closed and no data
flowed; after it, the proxy terminates TLS and can read channel content. What
it does not expose is the pairing payload — including the raw nsec — which
is NIP-44 v2 encrypted under an ECDH secret and bound to a human-verified SAS
and transcript hash (crates/buzz-core/src/pairing/), independent of TLS. The
ncryptsec1 egress guard on the native WebSocket send loop is likewise
unaffected.

One new input worth naming: SSL_CERT_FILE and SSL_CERT_DIR now influence
WebSocket TLS trust, where previously they had no effect on this path. Because
those variables replace the platform store rather than add to it, anything able
to set the process environment can substitute a single CA of its choosing for
the host's native trust — not merely append one. The prerequisite (control of
the process environment) is unchanged, so this is not a new privilege boundary,
but it is a new lever behind it. Most relevant for buzz-acp, which is
configured through environment variables.

Scope: this covers Buzz's own wss paths (tokio-tungstenite 0.29) — the
desktop native WebSocket and pairing clients, buzz-ws-client, buzz-acp,
and buzz-pairing-cli. buzz-relay inherits the dependency but only reaches
connect_async from #[cfg(test)] code against plaintext ws:// loopback
servers, so no server-side outbound TLS path changes. The mesh compute path
reaches the network through nostr-sdkasync-wsocket, which resolves a
separate tokio-tungstenite 0.28 and is untouched here.

Related issue

Fixes #5197 — same mechanism, same conclusion: tokio-tungstenite is pinned to
rustls-tls-webpki-roots, and connect_async is called with no custom
Connector, so there is no way to supply an internal root.

Fixes #2940 — an earlier report of the same defect, including the same
asymmetry where WebView HTTP succeeds while the native WebSocket does not.

Not addressed here: #3281 concerns proxy settings on the same call sites, which
this change does not affect.

Testing

Reproduced and verified against a live TLS-inspecting proxy, using the same
crate versions, feature flags, and bare connect_async call shape as the
repository.

Configuration Result
webpki-roots only (before) UnknownIssuer — matches the reported failure verbatim
both root sources (after) connects
both root sources, SSL_CERT_FILE pointed at an empty file native store empty despite a populated platform store — falls back to webpki roots and fails only on the proxy CA, with no hard error. This covers both the container case and the replace-not-extend semantics above
both root sources, SSL_CERT_FILE set to the proxy CA connects

The third row is the one that matters for review: with an empty native store
the connection does not abort with no native root CA certificates found, it
degrades to the webpki set, which is exactly today's behaviour.

Beyond the synthetic client, the fix was verified against a real product
workflow on the same machine. buzz agents draft-create opens a WebSocket to
deliver an owner-review draft, and on this network it fails outright:

$ buzz agents draft-create --display-name ...          # released binary
{"error":"error","message":"WebSocket error: IO error:
 invalid peer certificate: UnknownIssuer","retryable":false}

$ ./target/release/buzz agents draft-create ...        # rebuilt from this branch
{"accepted":true,"action":"create","saved":false,
 "message":"Draft sent to Buzz Desktop for owner review."}

Same host, same network, same command, same minute — the binary is the only
variable. A user-facing feature that was broken here works with this change.

Repository gates run locally, all passing:

  • cargo fmt --all -- --check
  • cargo check --workspace --all-targets
  • cargo clippy --workspace --all-targets -- -D warnings
  • just desktop-tauri-check
  • just desktop-tauri-test
  • just test-unit (all 9 lanes)

The JS and Flutter lanes of just ci were not run locally — they are
unaffected by a Cargo feature flag, and I could not fetch their dependencies
from the network I reproduced this on. Leaving those to CI.

No UI change; the fix restores an existing screen rather than altering it.

Every wss client in the workspace builds its rustls trust store from
webpki-roots alone, so it ignores the operating system trust store. On a
machine behind a TLS-inspecting proxy — where the OS store holds the
proxy's CA but the bundled Mozilla roots do not — connecting fails with:

    WebSocket connection failed: IO error: invalid peer certificate: UnknownIssuer

Desktop pairing (desktop/src-tauri/src/commands/pairing.rs) surfaces this
straight to the user: the pairing QR code never renders, so the phone
cannot be paired at all.

Enable rustls-tls-native-roots alongside the existing
rustls-tls-webpki-roots rather than replacing it. tokio-tungstenite merges
both sources into one root store and only treats an empty native store as
fatal when webpki roots are absent, so hosts without a system trust store
(minimal containers) keep behaving exactly as they do today.
rustls-native-certs additionally honors SSL_CERT_FILE and SSL_CERT_DIR,
which gives operators a supported way to supply a custom CA.

No call sites change: the remaining crates inherit the dependency through
`workspace = true`. rustls-native-certs was already present in both
lockfiles, so this adds a dependency edge rather than a new package.

Trade-off: this widens the client's trust scope to whatever the host
already trusts — the same posture browsers and curl take on these
machines. That is the intent, and it is why the change is opt-out by
uninstalling the CA rather than opt-in per connection.

Scope: this covers Buzz's own wss paths (tokio-tungstenite 0.29). The mesh
compute path reaches the network through nostr-sdk -> async-wsocket, which
resolves a separate tokio-tungstenite 0.28 and is untouched here.

Fixes block#5197
Fixes block#2940

Signed-off-by: 나형진 <hyeongjin.na@musinsa.com>
Co-authored-by: Claude <noreply@anthropic.com>
@nahyeongjin1
nahyeongjin1 requested a review from a team as a code owner August 29, 2026 08:35
@github-actions

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 00e61eafa917d296104006576b7a2ddbfd58bb5a...4c8a6e09deb9518db9de8b65c6f12a2e3ffa7fe7.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 4c8a6e09deb9518db9de8b65c6f12a2e3ffa7fe7 to authorize a new review.
Any previous review applies only to its recorded range.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant