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:
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+40
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-10
@@ -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
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+2
-9
@@ -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()
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user