fix(tor): close 8 network traffic leaks — fail-closed when Tor expected

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>
This commit is contained in:
nrobi144
2026-04-01 12:49:44 +03:00
parent 8a23a1cd65
commit 11d0a657ac
14 changed files with 458 additions and 43 deletions
@@ -487,14 +487,32 @@ fun App(
val httpClient =
remember(torRelayEvaluation) {
com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient(
torManager = torManager,
shouldUseTorForRelay = { url -> torRelayEvaluation.useTor(url) },
scope = scope,
)
com.vitorpamplona.amethyst.desktop.network
.DesktopHttpClient(
torManager = torManager,
shouldUseTorForRelay = { url -> torRelayEvaluation.useTor(url) },
torTypeProvider = { torTypeFlow.value },
scope = scope,
).also {
com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
.setInstance(it)
}
}
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
// Reconnect relays through Tor when it becomes active (prevents stale clearnet connections)
LaunchedEffect(torManager, relayManager) {
var previouslyActive = false
torManager.status.collect { status ->
val nowActive = status is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active
if (nowActive && !previouslyActive) {
kotlinx.coroutines.delay(500) // Brief delay for proxy client to propagate
relayManager.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
}
previouslyActive = nowActive
}
}
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
val subscriptionsCoordinator =
@@ -138,7 +138,7 @@ class AccountManager internal constructor(
private suspend fun getOrCreateNip46Client(): INostrClient =
nip46ClientMutex.withLock {
nip46Client ?: NostrClient(
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getSimpleHttpClient),
BasicOkHttpWebSocket.Builder { url -> DesktopHttpClient.currentClient() },
).also {
nip46Client = it
it.connect()
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.amethyst.commons.tor.ITorManager
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
@@ -49,8 +50,12 @@ import java.util.concurrent.TimeUnit
class DesktopHttpClient(
torManager: ITorManager,
private val shouldUseTorForRelay: (NormalizedRelayUrl) -> Boolean,
private val torTypeProvider: () -> TorType,
scope: CoroutineScope,
) {
/** Returns true if user expects Tor routing (INTERNAL or EXTERNAL mode). */
fun isTorExpected(): Boolean = torTypeProvider() != TorType.OFF
private val sharedConnectionPool = ConnectionPool()
private val directClient: OkHttpClient by lazy {
@@ -119,7 +124,24 @@ class DesktopHttpClient(
private const val BASE_TIMEOUT_SECONDS = 30L
private const val TOR_TIMEOUT_MULTIPLIER = 2 // Desktop: 2x, not Android's 3x
/** Simple direct client for code that doesn't need Tor routing (e.g. NIP-46 bunker). */
lateinit var instance: DesktopHttpClient
private set
fun setInstance(client: DesktopHttpClient) {
instance = client
}
/** Fail-closed client: SOCKS proxy on dead port 1. Requests fail instead of leaking IP. */
private val failClosedClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 1)))
.connectTimeout(1, TimeUnit.SECONDS)
.readTimeout(1, TimeUnit.SECONDS)
.build()
}
/** Simple direct client for pre-init only (tests, startup). */
private val simpleClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
@@ -131,7 +153,22 @@ class DesktopHttpClient(
.build()
}
/** Backward-compatible static accessor for code that doesn't need Tor. */
fun getSimpleHttpClient(url: NormalizedRelayUrl): OkHttpClient = simpleClient
/**
* 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 not leak)
* - Instance not set simpleClient (pre-init/tests only)
*/
fun currentClient(): OkHttpClient {
if (!::instance.isInitialized) return simpleClient
val client = instance
val proxyClient = client.proxyClient.value
if (proxyClient != null) return proxyClient
return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient()
}
/** Backward-compatible static accessor for relay WebSocket builder. */
fun getSimpleHttpClient(url: NormalizedRelayUrl): OkHttpClient = currentClient()
}
}
@@ -44,6 +44,8 @@ object DesktopImageLoaderSetup {
.diskCache { newDiskCache() }
.precision(Precision.INEXACT)
.components {
// TODO: Wire Coil through Tor — OkHttpNetworkFetcher.factory() not resolving in JVM module
// add(coil3.network.okhttp.OkHttpNetworkFetcher.factory { DesktopHttpClient.currentClient() })
add(SvgDecoder.Factory())
add(SkiaGifDecoder.Factory())
add(DesktopBase64Fetcher.Factory)
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
@@ -33,7 +33,7 @@ import java.util.concurrent.ConcurrentHashMap
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
*/
object EncryptedMediaService {
private val httpClient = OkHttpClient()
private val httpClient get() = DesktopHttpClient.currentClient()
private val cache = ConcurrentHashMap<String, ByteArray>()
private const val MAX_CACHE_ENTRIES = 20
@@ -20,20 +20,12 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
object ServerHealthCheck {
private val httpClient =
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
enum class ServerStatus {
ONLINE,
OFFLINE,
@@ -53,7 +45,7 @@ object ServerHealthCheck {
.url(url)
.head()
.build()
val response = httpClient.newCall(request).execute()
val response = DesktopHttpClient.currentClient().newCall(request).execute()
response.use {
if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
@@ -34,8 +35,10 @@ import okio.source
import java.io.File
class DesktopBlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
private val clientOverride: OkHttpClient? = null,
) {
private val okHttpClient: OkHttpClient get() = clientOverride ?: DesktopHttpClient.currentClient()
suspend fun upload(
file: File,
contentType: String,
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -84,10 +85,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)
@@ -936,12 +935,7 @@ private suspend fun zapNote(
}
// Create HTTP client and resolver
val httpClient =
OkHttpClient
.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val httpClient = DesktopHttpClient.currentClient()
val resolver = LightningAddressResolver(httpClient)
// Get relay URLs for zap request
@@ -35,27 +35,20 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Codec
import org.jetbrains.skia.Data
import java.util.concurrent.TimeUnit
private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF
private const val MIN_FRAME_DURATION_MS = 20
private val gifHttpClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
private val gifHttpClient get() = DesktopHttpClient.currentClient()
fun isAnimatedGifUrl(url: String): Boolean {
val lower = url.lowercase()
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.media
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
object SaveMediaAction {
private val httpClient = OkHttpClient()
private val httpClient get() = DesktopHttpClient.currentClient()
/**
* Opens a save dialog and downloads the media URL to the chosen file.