SECURITY: With Tor ON, all HTTP traffic now routes through SOCKS proxy. Previously only relay WebSocket connections used Tor; 8 other egress paths created bare OkHttpClient() instances bypassing Tor entirely. Fail-closed behavior: - When Tor expected but bootstrapping → dead SOCKS proxy on port 1 (requests fail instead of leaking IP) - lateinit var crashes on misconfiguration (loud failure vs silent leak) Leak sites fixed (all use DesktopHttpClient.currentClient()): - AnimatedGifImage: GIF fetch - SaveMediaAction: media downloads - EncryptedMediaService: NIP-17 DM media - ServerHealthCheck: Blossom server probes - NoteActions: zap/lightning LNURL resolution - DesktopBlossomClient: media uploads - AccountManager: NIP-46 bunker relay connections - Coil image loader: TODO (OkHttpNetworkFetcher import issue) Also: - Relay reconnect on Tor Active (prevents stale clearnet connections) - isTorExpected() for fail-closed logic - Tests updated for new torTypeProvider parameter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 KiB
title, type, status, date, deepened, origin
| title | type | status | date | deepened | origin |
|---|---|---|---|---|---|
| fix: Desktop Tor traffic leaks — zero direct connections when Tor ON | fix | active | 2026-04-01 | 2026-04-01 | docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md |
fix: Desktop Tor traffic leaks
Enhancement Summary
Deepened on: 2026-04-01 Agents: Security sentinel, Simplicity reviewer, Coil3 API verifier, Performance oracle
Critical Findings from Deepening
- SECURITY: Fail-closed —
proxyClient.value ?: directClientsilently leaks during Tor bootstrap. Must return a dead-socket client when Tor expected but not ready. - SECURITY: NIP-46 bunker —
AccountManagerusessimpleClient(always direct). Bunker relay connections bypass Tor entirely. - SECURITY: DesktopBlossomClient — must use
get() =not=(stale capture at construction). - PERFORMANCE: Stagger relay reconnect — 30 relays × Tor circuit simultaneously = 30-90s freeze. Need 500ms jitter.
- SIMPLICITY: lateinit var — fail loudly on misconfiguration rather than silently falling back to direct client (IP leak).
Overview
Manual testing with lsof revealed that with Tor ON (Full Privacy), only relay WebSocket connections go through the SOCKS proxy. 8 other network egress paths create their own OkHttpClient() and bypass Tor — leaking user IP to image CDNs, media servers, lightning providers, and Blossom hosts.
Problem Statement
With Tor enabled, the Java process should have ONLY:
- Connections to
127.0.0.1:PORT(local SOCKS proxy) - The Tor daemon's own guard node connections
Instead, lsof shows 9+ direct external TCP connections from Java to Cloudflare, Hetzner, and relay IPs — defeating Tor's privacy guarantees.
Proposed Solution
Global DesktopHttpClient.currentClient() accessor (see brainstorm: docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md). Each leak site changes 1-2 lines to use the Tor-aware client instead of a bare OkHttpClient().
Implementation
Phase 1: Add fail-closed global accessor to DesktopHttpClient
File: desktopApp/.../network/DesktopHttpClient.kt
Add to companion object:
lateinit var instance: DesktopHttpClient
private set
fun setInstance(client: DesktopHttpClient) { instance = client }
/** Fail-closed client — routes to dead proxy, requests fail instead of leaking. */
private val failClosedClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.proxy(java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", 1)))
.connectTimeout(1, java.util.concurrent.TimeUnit.SECONDS)
.build()
}
/**
* Returns the current Tor-aware client.
* - Tor active → proxy client (SOCKS)
* - Tor off → direct client
* - Tor expected but bootstrapping → FAIL-CLOSED (dead proxy, requests fail)
*/
fun currentClient(): OkHttpClient {
if (!::instance.isInitialized) return simpleClient // Pre-init only (tests, startup)
val client = instance
val proxyClient = client.proxyClient.value
if (proxyClient != null) return proxyClient
// Tor not active — check if it SHOULD be
return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient()
}
Add isTorExpected() to DesktopHttpClient instance:
/** Returns true if user's settings expect Tor routing (INTERNAL or EXTERNAL mode). */
fun isTorExpected(): Boolean = torTypeFlow.value != TorType.OFF
File: desktopApp/.../Main.kt
After httpClient creation in App:
DesktopHttpClient.setInstance(httpClient)
Phase 2: Fix 8 leak sites
| File | Before | After |
|---|---|---|
AnimatedGifImage.kt:52 |
private val gifHttpClient by lazy { OkHttpClient.Builder()...build() } |
private val gifHttpClient get() = DesktopHttpClient.currentClient() |
SaveMediaAction.kt:32 |
private val httpClient = OkHttpClient() |
private val httpClient get() = DesktopHttpClient.currentClient() |
EncryptedMediaService.kt:36 |
private val httpClient = OkHttpClient() |
private val httpClient get() = DesktopHttpClient.currentClient() |
ServerHealthCheck.kt:30 |
private val httpClient = OkHttpClient.Builder()...build() |
Remove property. Inline DesktopHttpClient.currentClient() at call site. |
NoteActions.kt:939 |
val httpClient = OkHttpClient.Builder()...build() |
val httpClient = DesktopHttpClient.currentClient() |
DesktopBlossomClient.kt:37 |
private val okHttpClient: OkHttpClient = OkHttpClient() |
private val okHttpClient: OkHttpClient get() = DesktopHttpClient.currentClient() |
AccountManager.kt:141 |
DesktopHttpClient::getSimpleHttpClient |
{ url -> DesktopHttpClient.currentClient() } |
Note on DesktopBlossomClient: Must use get() = (not =) so each upload picks up current Tor state. If Tor activates after dialog opens, uploads still route through Tor.
Note on AccountManager (NEW): NIP-46 bunker relay connections currently always bypass Tor via getSimpleHttpClient. Security review flagged this as HIGH — bunker connections associate user's IP with signing operations.
Phase 3: Wire Coil image loader through Tor
File: desktopApp/.../service/images/DesktopImageLoaderSetup.kt
import coil3.network.okhttp.OkHttpNetworkFetcher
ImageLoader.Builder(PlatformContext.INSTANCE)
.components {
add(OkHttpNetworkFetcher.factory { DesktopHttpClient.currentClient() })
// ... existing decoders (SvgDecoder, SkiaGifDecoder, etc.)
}
Confirmed API: OkHttpNetworkFetcher.factory(callFactory: () -> Call.Factory) — lambda called per-request, so each image load picks up current Tor state. Coil's memory + disk caches (256MB + 512MB) mean cached images don't re-trigger network.
Phase 4: Staggered relay reconnection on Tor Active
File: desktopApp/.../Main.kt
LaunchedEffect(torManager) {
var previouslyActive = false
torManager.status.collect { status ->
val nowActive = status is TorServiceStatus.Active
if (nowActive && !previouslyActive) {
// Tor just became active — stagger reconnect to avoid thundering herd
// NostrClient.reconnect() disconnects all then reconnects
// 30 relays × Tor circuit = potential 30-90s if simultaneous
delay(500) // Brief delay for proxy client to propagate
relayManager.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
}
previouslyActive = nowActive
}
}
Performance oracle recommends staggering inside NostrClient.reconnect(), but that requires modifying quartz. For this fix, the delay(500) ensures the proxy client StateFlow has propagated before reconnection starts. Full staggered reconnection is a follow-up.
Phase 5: Tests
New unit tests (desktopApp/src/jvmTest/.../network/DesktopHttpClientTest.kt):
// Add to existing DesktopHttpClientTest:
- currentClient_instanceNotSet_returnsSimpleClient
- currentClient_torActive_returnsProxyClient
- currentClient_torOff_returnsDirectClient
- currentClient_torExpectedButBootstrapping_returnsFailClosed
- currentClient_failClosed_hasSocksProxy // Verify it has a proxy, not direct
- currentClient_failClosed_pointsToDeadPort // port 1, will fail fast
Manual verification (lsof):
# WITH Tor ON (Full Privacy):
lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1"
# Expected: EMPTY — zero direct connections
# WITH Tor OFF:
lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1"
# Expected: Direct connections to relay IPs (control test)
# DURING Tor bootstrap (first 10-30s):
lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1"
# Expected: EMPTY — fail-closed prevents leaks during bootstrap
Grep CI check (prevent regressions):
# Should return ZERO matches outside DesktopHttpClient.kt:
grep -r "OkHttpClient()" desktopApp/src/jvmMain/ --include="*.kt" | grep -v "DesktopHttpClient.kt" | grep -v "Test"
Acceptance Criteria
currentClient()returns proxy client when Tor activecurrentClient()returns direct client when Tor offcurrentClient()returns fail-closed client when Tor expected but bootstrappingcurrentClient()throws (lateinit) if instance never set (misconfiguration = loud failure)- Fail-closed client has SOCKS proxy pointing to dead port (requests fail, don't leak)
AnimatedGifImageusescurrentClient()viaget() =SaveMediaActionusescurrentClient()viaget() =EncryptedMediaServiceusescurrentClient()viaget() =ServerHealthCheckinlinescurrentClient()at call siteNoteActions.zapNote()usescurrentClient()DesktopBlossomClientusescurrentClient()viaget() =(not=)AccountManagerNIP-46 bunker usescurrentClient()(notsimpleClient)- Coil uses
OkHttpNetworkFetcher.factory { currentClient() } - Relays reconnect through SOCKS when Tor transitions to Active
lsofshows ZERO direct external connections from Java when Tor ONlsofshows ZERO direct connections during Tor bootstrap (fail-closed)lsofshows direct connections when Tor OFF (control)- All existing tests pass
- Images load through Tor (slower but functional)
- Zaps work through Tor
Files Changed
| File | Change | Lines |
|---|---|---|
DesktopHttpClient.kt |
lateinit, currentClient(), failClosedClient, isTorExpected() |
+25 |
Main.kt |
setInstance, reconnect LaunchedEffect |
+12 |
AnimatedGifImage.kt |
get() = currentClient() |
1 |
SaveMediaAction.kt |
get() = currentClient() |
1 |
EncryptedMediaService.kt |
get() = currentClient() |
1 |
ServerHealthCheck.kt |
Inline currentClient() at call site, remove property |
-3, +1 |
NoteActions.kt |
Replace inline builder | 1 |
DesktopBlossomClient.kt |
get() = currentClient() |
1 |
AccountManager.kt |
Replace getSimpleHttpClient with currentClient() |
1 |
DesktopImageLoaderSetup.kt |
Add OkHttpNetworkFetcher.factory |
2 |
DesktopHttpClientTest.kt |
Add fail-closed + currentClient tests | +25 |
Total: ~70 lines changed across 11 files.
Sources & References
Origin
- Brainstorm: docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md
- Key decisions: Global accessor (Option A), fail-closed during bootstrap, Coil3 per-request lambda
Deepening Findings
| Agent | Key Finding | Impact |
|---|---|---|
| Security | ?: directClient fallback leaks during bootstrap |
CRITICAL — added fail-closed |
| Security | NIP-46 bunker bypasses Tor via simpleClient |
HIGH — added AccountManager fix |
| Simplicity | DesktopBlossomClient = captures stale client |
HIGH — changed to get() = |
| Simplicity | lateinit var fails loudly vs silent leak |
Applied |
| Simplicity | ServerHealthCheck: inline, drop custom timeout | Applied |
| Coil3 | OkHttpNetworkFetcher.factory { client } correct API |
Confirmed |
| Performance | 30-relay thundering herd through Tor = 30-90s | Added delay, follow-up for stagger |
| Performance | Image latency +450-1250ms first load, cached OK | Expected, documented |