From f12926fa39d1226e9ef4aba6ab46032f063e0709 Mon Sep 17 00:00:00 2001 From: Arun Tummala Date: Mon, 27 Jul 2026 09:51:00 -0500 Subject: [PATCH] Use platform TLS verifier for relay/websocket connections Relay client (buzz-acp), ws-client, and Tauri native_websocket now use rustls-platform-verifier instead of the default cert store, fixing TLS handshake failures against relays with certs not in the bundled CA set. Also refactors membership lookup to fetch a relay-membership snapshot directly instead of resolving identity + membership in parallel, and adds sidecar bundling / Justfile updates. --- Cargo.lock | 3 ++ Cargo.toml | 1 + Justfile | 15 ++++---- crates/buzz-acp/Cargo.toml | 1 + crates/buzz-acp/src/relay.rs | 28 ++++++++++++--- crates/buzz-ws-client/Cargo.toml | 2 ++ crates/buzz-ws-client/src/connection.rs | 17 +++++++--- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/src/commands/relay_members.rs | 10 ++++-- desktop/src-tauri/src/native_websocket.rs | 25 ++++++++++++-- desktop/src/shared/api/relayMembers.ts | 27 +++++++++++---- desktop/src/shared/api/tauri.ts | 34 +++++++++++-------- desktop/src/testing/e2eBridge.ts | 10 ++++++ desktop/tests/e2e/onboarding.spec.ts | 24 +++++++++++++ scripts/bundle-sidecars.sh | 1 + 16 files changed, 158 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32152e41abc..f3af4b6b350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,7 @@ dependencies = [ "nostr", "reqwest 0.13.4", "rustls", + "rustls-platform-verifier", "serde", "serde_json", "sha2 0.11.0", @@ -1287,6 +1288,8 @@ version = "0.1.0" dependencies = [ "futures-util", "nostr", + "rustls", + "rustls-platform-verifier", "serde_json", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 3499285f917..7f95d5ef55f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ futures-util = "0.3" # WebSocket client (test client) tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } +rustls-platform-verifier = "0.7" url = "2" # Property-based testing (dev-only) diff --git a/Justfile b/Justfile index 773738bdfd2..6089cbd900e 100644 --- a/Justfile +++ b/Justfile @@ -233,14 +233,15 @@ desktop-release-build target="aarch64-apple-darwin": #!/usr/bin/env bash set -euo pipefail TARGET={{target}} - mkdir -p desktop/src-tauri/binaries - touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" - touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" - touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" - touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" - touch "desktop/src-tauri/binaries/buzz-$TARGET" + cargo build --release --target "$TARGET" \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" pnpm install - cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} + cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target "$TARGET" # Run desktop checks suitable for CI / pre-push desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806f..08eff564681 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -29,6 +29,7 @@ tokio = { workspace = true } # WebSocket tokio-tungstenite = { workspace = true } +rustls-platform-verifier = { workspace = true } # Codec (bounded line reads) tokio-util = { workspace = true } diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61e5..5c9cb7340e7 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -22,6 +22,7 @@ //! channel. `next_event()` reads from the event receiver. use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; use std::time::Duration; /// Default capacity of the event channel from background task to harness. @@ -119,15 +120,21 @@ use buzz_core::kind::{ }; use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; +use rustls_platform_verifier::ConfigVerifierExt; use serde_json::{json, Value}; use tokio::sync::mpsc; use tokio::time::timeout; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{ + connect_async_tls_with_config, tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream, +}; use tracing::{debug, info, warn}; use uuid::Uuid; use crate::config::ChannelFilter; +#[cfg(test)] +use tokio_tungstenite::connect_async; + /// Metadata about a channel, populated at discovery time. #[derive(Debug, Clone)] pub struct ChannelInfo { @@ -3830,11 +3837,22 @@ async fn do_connect( let parsed = relay_url .parse::() .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?; + let _ = rustls::crypto::ring::default_provider().install_default(); + let tls_connector = rustls::ClientConfig::with_platform_verifier() + .map(|config| Connector::Rustls(Arc::new(config))) + .map_err(|error| { + RelayError::Http(format!( + "failed to initialize platform TLS verifier: {error}" + )) + })?; - let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str())) - .await - .map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure - .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + let (ws, _response) = tokio::time::timeout( + CONNECT_TIMEOUT, + connect_async_tls_with_config(parsed.as_str(), None, false, Some(tls_connector)), + ) + .await + .map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; debug!("connected to relay at {relay_url}"); let mut ws = ws; diff --git a/crates/buzz-ws-client/Cargo.toml b/crates/buzz-ws-client/Cargo.toml index 5cec925677f..a18518bc8e5 100644 --- a/crates/buzz-ws-client/Cargo.toml +++ b/crates/buzz-ws-client/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true nostr = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +rustls-platform-verifier = { workspace = true } futures-util = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index bec5b56bb43..0fcc5091ac2 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -1,11 +1,15 @@ use std::collections::VecDeque; +use std::sync::Arc; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use nostr::{Event, Keys, Tag}; +use rustls_platform_verifier::ConfigVerifierExt; use serde_json::{json, Value}; use tokio::time::timeout; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{ + connect_async_tls_with_config, tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream, +}; use tracing::debug; use crate::error::WsClientError; @@ -46,13 +50,18 @@ impl NostrWsConnection { /// Connects to the relay at `url` without performing authentication. pub async fn connect(url: &str) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); let parsed = url .parse::() .map_err(|e| WsClientError::Url(e.to_string()))?; + let tls_connector = rustls::ClientConfig::with_platform_verifier() + .map(|config| Connector::Rustls(Arc::new(config))) + .map_err(|error| WsClientError::Url(format!("TLS verifier: {error}")))?; - let (ws, _response) = connect_async(parsed.as_str()) - .await - .map_err(WsClientError::WebSocket)?; + let (ws, _response) = + connect_async_tls_with_config(parsed.as_str(), None, false, Some(tls_connector)) + .await + .map_err(WsClientError::WebSocket)?; debug!("connected to relay at {url}"); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 810f9217691..aec0058057f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1055,6 +1055,7 @@ dependencies = [ "rubato", "rusqlite", "rustls", + "rustls-platform-verifier", "security-framework 3.7.0", "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7826f44efd9..b0ed98e3e42 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -72,6 +72,7 @@ ed25519-dalek = "=3.0.0-rc.0" tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } +rustls-platform-verifier = "0.7" bytes = "1" futures-util = "0.3" opus = "0.3" diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs index a9230dff950..34e7f9d678b 100644 --- a/desktop/src-tauri/src/commands/relay_members.rs +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -72,7 +72,10 @@ pub async fn get_my_relay_membership( .await?; let Some(event) = events.first() else { - return Ok(serde_json::json!({ "member": null })); + return Ok(serde_json::json!({ + "snapshot_found": false, + "member": null + })); }; let members_value = nostr_convert::relay_members_from_event(event); @@ -85,7 +88,10 @@ pub async fn get_my_relay_membership( .cloned() }); - Ok(serde_json::json!({ "member": me })) + Ok(serde_json::json!({ + "snapshot_found": true, + "member": me + })) } #[tauri::command] diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index c0cf2e76f1d..cd87233d548 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,12 +1,14 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use futures_util::{SinkExt, StreamExt}; +use rustls_platform_verifier::ConfigVerifierExt; use serde::{Deserialize, Serialize}; use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio_tungstenite::{ - connect_async, + connect_async_tls_with_config, tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}, + Connector, }; use tokio_util::sync::CancellationToken; @@ -20,6 +22,12 @@ pub(crate) fn install_crypto_provider() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); } +fn platform_tls_connector() -> Result { + rustls::ClientConfig::with_platform_verifier() + .map(|config| Connector::Rustls(Arc::new(config))) + .map_err(|error| format!("failed to initialize platform TLS verifier: {error}")) +} + type Id = u32; #[derive(Debug, Deserialize)] @@ -127,9 +135,13 @@ async fn open_connection( on_message: Channel, ) -> Result { let connect_cancel = manager.connect_cancel.lock().await.clone(); + let tls_connector = platform_tls_connector()?; let (socket, _) = tokio::select! { _ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()), - result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(url)) => result + result = tokio::time::timeout( + CONNECT_TIMEOUT, + connect_async_tls_with_config(url, None, false, Some(tls_connector)), + ) => result .map_err(|_| "WebSocket connection timed out".to_string())? .map_err(|error| error.to_string())?, }; @@ -337,6 +349,15 @@ mod tests { Channel::new(|_: InvokeResponseBody| Ok(())) } + #[test] + fn platform_tls_connector_initializes() { + install_crypto_provider(); + assert!( + matches!(platform_tls_connector(), Ok(Connector::Rustls(_))), + "platform TLS verifier must initialize" + ); + } + #[tokio::test] async fn secure_websocket_reaches_tls_without_panicking() { install_crypto_provider(); diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts index b0d6e5675e0..c413425f655 100644 --- a/desktop/src/shared/api/relayMembers.ts +++ b/desktop/src/shared/api/relayMembers.ts @@ -1,6 +1,9 @@ import { relayClient } from "@/shared/api/relayClient"; -import { invokeTauri, signRelayEvent } from "@/shared/api/tauri"; -import { getIdentity } from "@/shared/api/tauriIdentity"; +import { + getMyRelayMembershipSnapshot, + invokeTauri, + signRelayEvent, +} from "@/shared/api/tauri"; import type { RelayEvent, RelayMember, @@ -127,11 +130,21 @@ async function relayRequiresMembership(): Promise { } export async function getMyRelayMembershipLookup(): Promise { - const [{ pubkey }, membershipRequired] = await Promise.all([ - getIdentity(), - relayRequiresMembership(), - ]); - return loadRelayMembershipLookup(pubkey, membershipRequired); + const membershipRequired = await relayRequiresMembership(); + if (!membershipRequired) { + return { + snapshotFound: false, + membershipRequired: false, + membership: null, + }; + } + + const { snapshotFound, membership } = await getMyRelayMembershipSnapshot(); + return { + snapshotFound, + membershipRequired: true, + membership, + }; } export async function getMyRelayMembership(): Promise { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index d2848626aec..67314701206 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -237,6 +237,11 @@ type RawListRelayMembersResponse = { members: RawRelayMember[]; }; +type RawMyRelayMembershipResponse = { + snapshot_found: boolean; + member: RawRelayMember | null; +}; + type RawCanvasResponse = { content: string | null; updated_at: number | null; @@ -797,21 +802,20 @@ export async function listRelayMembers(): Promise { } export async function getMyRelayMembership(): Promise { - try { - const raw = await invokeTauri("get_my_relay_membership"); - return fromRawRelayMember(raw); - } catch (error) { - // "relay returned 404 Not Found" = not a relay member — return null so - // the UI hides the Members tab. Re-throw real errors (network, auth, 500) - // so React Query surfaces them. - if ( - error instanceof Error && - error.message.startsWith("relay returned 404") - ) { - return null; - } - throw error; - } + return (await getMyRelayMembershipSnapshot()).membership; +} + +export async function getMyRelayMembershipSnapshot(): Promise<{ + snapshotFound: boolean; + membership: RelayMember | null; +}> { + const response = await invokeTauri( + "get_my_relay_membership", + ); + return { + snapshotFound: response.snapshot_found, + membership: response.member ? fromRawRelayMember(response.member) : null, + }; } export async function addRelayMember( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cf441829c57..2487afe053b 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9843,6 +9843,16 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "relay_requires_membership": return activeConfig?.mock?.relayRequiresMembership ?? false; + case "get_my_relay_membership": { + const pubkey = getMockMemberPubkey(activeConfig); + const member = + mockRelayMembers.find((candidate) => candidate.pubkey === pubkey) ?? + null; + return { + snapshot_found: true, + member, + }; + } case "discover_acp_providers": return handleDiscoverAcpRuntimes(activeConfig); case "discover_acp_auth_methods": diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0c4d6309947..4f874ba5d5c 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -2982,6 +2982,30 @@ test("open relay skips membership gating during onboarding", async ({ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await expect(page.getByTestId("membership-denied")).toHaveCount(0); + expect(await commandCount(page, "get_my_relay_membership")).toBe(0); +}); + +test("gated relay member uses the native membership preflight", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await installMockBridge( + page, + { + relayRequiresMembership: true, + relayRole: "member", + }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await page.getByTestId("onboarding-next").click(); + + await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); + await expect(page.getByText("Server error — try again")).toHaveCount(0); + await expect(page.getByTestId("membership-denied")).toHaveCount(0); + expect(await commandCount(page, "get_my_relay_membership")).toBe(1); }); test("membership denial can import a different invited key", async ({ diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index be37cbce0dd..a09fd6ffc55 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -36,5 +36,6 @@ fi mkdir -p "$BINARIES_DIR" for bin in "${SIDECARS[@]}"; do cp "$SRC_DIR/${bin}${EXE}" "$BINARIES_DIR/${bin}-${TARGET}${EXE}" + chmod +x "$BINARIES_DIR/${bin}-${TARGET}${EXE}" done echo "Sidecars bundled for $TARGET"