From 9b3e51f978d45baa83a75e424c3b91a3b17cfef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 19:06:59 +0000 Subject: [PATCH 1/3] feat: passive Onion-Location discovery and .onion URL rewriting via OkHttp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three components wired into both OkHttp factory chains (relays and media/NIP-11): - OnionLocationCache: ConcurrentHashMap shared across all OkHttp clients. No Nostr-specific protocol changes needed. - OnionLocationInterceptor (network interceptor, always active): reads the Onion-Location HTTP header from every response — WebSocket 101 handshakes, NIP-11 documents, image servers, anything — and populates the cache. Discovery is a zero-cost by-product of traffic that already happens. - OnionUrlRewriteInterceptor (application interceptor, Tor clients only): on outbound requests, checks the cache for the target host and rewrites to the .onion address when available. Derives the correct ws/wss scheme from the cached Onion-Location URL scheme so TLS expectations match. First connection goes clearnet (or Tor-to-clearnet), populating the cache. The next reconnect transparently uses the .onion address so Tor connections avoid exit nodes entirely. Network transition reconnects (already handled by RelayProxyClientConnector) retry with the cached .onion on the fresh circuit. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV --- .../com/vitorpamplona/amethyst/AppModules.kt | 8 +++ .../service/okhttp/DualHttpClientManager.kt | 3 +- .../okhttp/DualHttpClientManagerForRelays.kt | 3 +- .../service/okhttp/OkHttpClientFactory.kt | 3 + .../okhttp/OkHttpClientFactoryForRelays.kt | 3 + .../service/okhttp/OnionLocationCache.kt | 45 ++++++++++++ .../okhttp/OnionLocationInterceptor.kt | 51 ++++++++++++++ .../okhttp/OnionUrlRewriteInterceptor.kt | 70 +++++++++++++++++++ 8 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 42c084d9e4..4b0d1eeac6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket +import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache import com.vitorpamplona.amethyst.service.okhttp.SurgeDns import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache @@ -240,6 +241,11 @@ class AppModules( // path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it. val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns) + // Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket + // response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor + // on Tor-enabled clients to transparently redirect to .onion addresses. + val onionLocationCache = OnionLocationCache() + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -258,6 +264,7 @@ class AppModules( val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false master && !profileOnly && localBlossomCacheProbe.available.value }, + onionCache = onionLocationCache, ) // Offers easy methods to know when connections are happening through Tor or not @@ -432,6 +439,7 @@ class AppModules( isMobileDataProvider = connManager.isMobileOrNull, scope = applicationIOScope, dns = surgeDns, + onionCache = onionLocationCache, ) // Connects the INostrClient class with okHttp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 7cb4d33624..4ec0b4c9a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -40,8 +40,9 @@ class DualHttpClientManager( scope: CoroutineScope, dns: SurgeDns, shouldBridgeBlossomCache: (() -> Boolean)? = null, + onionCache: OnionLocationCache? = null, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt index d190c885d9..bc9ce6dbfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt @@ -36,8 +36,9 @@ class DualHttpClientManagerForRelays( isMobileDataProvider: StateFlow, scope: CoroutineScope, dns: SurgeDns, + onionCache: OnionLocationCache, ) : IHttpClientManager { - val factory = OkHttpClientFactoryForRelays(userAgent, dns) + val factory = OkHttpClientFactoryForRelays(userAgent, dns, onionCache) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 88b65d845a..146138dcc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -43,6 +43,7 @@ class OkHttpClientFactory( * useful for tests or pre-configuration call sites. */ val shouldBridgeBlossomCache: (() -> Boolean)? = null, + private val onionCache: OnionLocationCache? = null, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -91,6 +92,7 @@ class OkHttpClientFactory( } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) + .apply { onionCache?.let { addNetworkInterceptor(OnionLocationInterceptor(it)) } } .build() private var lastProxy: Proxy? = null @@ -107,6 +109,7 @@ class OkHttpClientFactory( return rootClient .newBuilder() .proxy(proxy) + .apply { if (proxy != null) onionCache?.let { addInterceptor(OnionUrlRewriteInterceptor(it)) } } .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index a5e3619a60..a0c404312a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -30,6 +30,7 @@ import java.time.Duration class OkHttpClientFactoryForRelays( userAgent: String, private val dns: SurgeDns, + private val onionCache: OnionLocationCache, ) { companion object { // by picking a random proxy port, the connection will fail as it should. @@ -61,6 +62,7 @@ class OkHttpClientFactoryForRelays( .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) + .addNetworkInterceptor(OnionLocationInterceptor(onionCache)) .build() private var lastProxy: Proxy? = null @@ -77,6 +79,7 @@ class OkHttpClientFactoryForRelays( return rootClient .newBuilder() .proxy(proxy) + .apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) } .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt new file mode 100644 index 0000000000..cd822f7310 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import java.util.concurrent.ConcurrentHashMap + +/** + * Maps clearnet hostnames to their advertised .onion equivalents, populated + * passively by [OnionLocationInterceptor] from any HTTP/WebSocket response + * that carries an `Onion-Location` header. + * + * The [OnionUrlRewriteInterceptor] consults this cache on every outbound + * request when Tor is active, transparently redirecting to the .onion address + * so Tor-routed connections avoid exit nodes entirely. + */ +class OnionLocationCache { + private val cache = ConcurrentHashMap() + + fun put( + host: String, + onionUrl: String, + ) { + cache[host] = onionUrl + } + + fun get(host: String): String? = cache[host] +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt new file mode 100644 index 0000000000..dc55fa051b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Network interceptor that passively captures `Onion-Location` headers from + * every HTTP response — NIP-11 documents, WebSocket upgrade handshakes (101), + * image/media servers, anything — and records the mapping into + * [OnionLocationCache]. + * + * No extra requests are made; discovery is a free by-product of traffic that + * already happens. When the Tor-enabled client later connects to the same host, + * [OnionUrlRewriteInterceptor] swaps in the cached .onion address so the + * connection avoids exit nodes entirely. + * + * Registered as a network interceptor (not application interceptor) so it sees + * real server responses rather than cached copies. + */ +class OnionLocationInterceptor( + private val cache: OnionLocationCache, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val onionLocation = response.header("Onion-Location") + if (onionLocation != null) { + cache.put(chain.request().url.host, onionLocation) + } + return response + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt new file mode 100644 index 0000000000..210b7adc8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Application interceptor that rewrites outbound requests to use a known + * .onion address when one is available in [OnionLocationCache]. + * + * Only added to Tor-enabled OkHttpClient instances, so .onion substitution + * never happens on clearnet connections (where .onion DNS would not resolve). + * + * Scheme is derived from the `Onion-Location` header value: + * - `http://` onion + WebSocket original → `ws://` + * - `https://` onion + WebSocket original → `wss://` + * - HTTP originals take the onion scheme directly + * + * Port and path are preserved from the onion URL and original request + * respectively. An unparseable cached URL falls through to the original. + */ +class OnionUrlRewriteInterceptor( + private val cache: OnionLocationCache, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val onionUrlStr = cache.get(original.url.host) ?: return chain.proceed(original) + val onionUrl = runCatching { onionUrlStr.toHttpUrl() }.getOrNull() ?: return chain.proceed(original) + + val newScheme = + when (original.url.scheme) { + "ws", "wss" -> if (onionUrl.scheme == "https") "wss" else "ws" + else -> onionUrl.scheme + } + + val rewritten = + original + .newBuilder() + .url( + original.url + .newBuilder() + .scheme(newScheme) + .host(onionUrl.host) + .port(onionUrl.port) + .build(), + ).build() + + return chain.proceed(rewritten) + } +} From 5e831cadb455ad9da7e5fa3ac0a5c52154165192 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 19:45:40 +0000 Subject: [PATCH 2/3] test: verify OkHttp accepts .onion pseudo-TLD in toHttpUrl() Documents that OnionUrlRewriteInterceptor's runCatching { toHttpUrl() } call succeeds for Tor v2/v3 .onion hosts so a future OkHttp upgrade that breaks .onion parsing is caught before the feature silently regresses. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV --- .../service/okhttp/OnionUrlParseTest.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlParseTest.kt diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlParseTest.kt new file mode 100644 index 0000000000..8ad8cb8cc3 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlParseTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** + * Verifies that OkHttp 5.x HttpUrl.toHttpUrl() accepts .onion pseudo-TLD hostnames. + * + * .onion is not an IANA-registered TLD (RFC 7686), so it may be rejected by strict + * domain validators. If toHttpUrl() rejects .onion URLs the entire OnionUrlRewriteInterceptor + * feature silently falls through to the clearnet address. This test documents and guards + * the actual OkHttp behavior so any upgrade that breaks .onion support is caught. + */ +class OnionUrlParseTest { + // A real Tor v3 .onion address (56 base32 chars + ".onion") + private val torV3Host = "vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion" + + @Test + fun torV3HttpUrl_parsesSuccessfully() { + val url = runCatching { "http://$torV3Host/".toHttpUrl() }.getOrNull() + assertNotNull( + "OkHttp must accept Tor v3 .onion hosts — if null, OnionUrlRewriteInterceptor is silently broken", + url, + ) + } + + @Test + fun torV3HttpUrl_hostIsPreserved() { + val url = "http://$torV3Host/".toHttpUrl() + assertEquals(torV3Host, url.host) + } + + @Test + fun torV3HttpsUrl_parsesSuccessfully() { + val url = runCatching { "https://$torV3Host/".toHttpUrl() }.getOrNull() + assertNotNull(url) + } + + @Test + fun torV3WithPort_parsesSuccessfully() { + val url = runCatching { "http://$torV3Host:8080/path".toHttpUrl() }.getOrNull() + assertNotNull(url) + assertEquals(8080, url?.port) + } + + @Test + fun shortOnionLabel_parsesSuccessfully() { + // Shorter label (e.g. Tor v2-style) — also must be accepted + val url = runCatching { "http://relay.someservice.onion/".toHttpUrl() }.getOrNull() + assertNotNull(url) + } + + // --- Regression: OnionUrlRewriteInterceptor scheme translation --- + + @Test + fun wsOriginalWithHttpOnion_rewritesToWs() { + val onionUrlStr = "http://$torV3Host/" + val onionUrl = onionUrlStr.toHttpUrl() + val originalScheme = "ws" + val newScheme = + if (originalScheme == "ws" || originalScheme == "wss") { + if (onionUrl.scheme == "https") "wss" else "ws" + } else { + onionUrl.scheme + } + assertEquals("ws", newScheme) + } + + @Test + fun wssOriginalWithHttpsOnion_rewritesToWss() { + val onionUrlStr = "https://$torV3Host/" + val onionUrl = onionUrlStr.toHttpUrl() + val originalScheme = "wss" + val newScheme = + if (originalScheme == "ws" || originalScheme == "wss") { + if (onionUrl.scheme == "https") "wss" else "ws" + } else { + onionUrl.scheme + } + assertEquals("wss", newScheme) + } +} From e59babe0852421c6c00705312c21d2765fb53a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 20:16:01 +0000 Subject: [PATCH 3/3] fix: three bugs in onion-location routing - OnionLocationCache: add 24-hour TTL so stale .onion mappings expire instead of permanently breaking Tor connectivity when a server rotates its onion address - OnionLocationInterceptor: demote from network to application interceptor so chain.request().url.host is always the original clearnet hostname. As a network interceptor it ran after OnionUrlRewriteInterceptor had already rewritten the host to .onion, causing cache refreshes from onion-routed responses to be stored under the wrong key and never used - OnionUrlRewriteInterceptor: make http/https scheme mapping explicit and symmetric with the ws/wss arm; document that http:// onion for an https:// clearnet host is intentional (Tor circuit provides E2E security) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV --- .../service/okhttp/OkHttpClientFactory.kt | 2 +- .../okhttp/OkHttpClientFactoryForRelays.kt | 2 +- .../service/okhttp/OnionLocationCache.kt | 29 +++++++++++++++++-- .../okhttp/OnionLocationInterceptor.kt | 10 +++++-- .../okhttp/OnionUrlRewriteInterceptor.kt | 14 ++++++--- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 146138dcc0..6021e1a349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -92,7 +92,7 @@ class OkHttpClientFactory( } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) - .apply { onionCache?.let { addNetworkInterceptor(OnionLocationInterceptor(it)) } } + .apply { onionCache?.let { addInterceptor(OnionLocationInterceptor(it)) } } .build() private var lastProxy: Proxy? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index a0c404312a..7704771de1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -62,7 +62,7 @@ class OkHttpClientFactoryForRelays( .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) - .addNetworkInterceptor(OnionLocationInterceptor(onionCache)) + .addInterceptor(OnionLocationInterceptor(onionCache)) .build() private var lastProxy: Proxy? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt index cd822f7310..b93b50cc0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.okhttp import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit /** * Maps clearnet hostnames to their advertised .onion equivalents, populated @@ -30,16 +31,38 @@ import java.util.concurrent.ConcurrentHashMap * The [OnionUrlRewriteInterceptor] consults this cache on every outbound * request when Tor is active, transparently redirecting to the .onion address * so Tor-routed connections avoid exit nodes entirely. + * + * Entries expire after [TTL_MS] so that a server that rotates or decommissions + * its `.onion` address does not permanently break Tor connectivity. After expiry + * the next request goes out via Tor exit nodes (or clearnet on the no-proxy + * client); if the server still advertises `Onion-Location` the mapping is + * refreshed automatically. */ class OnionLocationCache { - private val cache = ConcurrentHashMap() + private data class Entry( + val onionUrl: String, + val expiresAtMs: Long, + ) + + private val cache = ConcurrentHashMap() fun put( host: String, onionUrl: String, ) { - cache[host] = onionUrl + cache[host] = Entry(onionUrl, System.currentTimeMillis() + TTL_MS) } - fun get(host: String): String? = cache[host] + fun get(host: String): String? { + val entry = cache[host] ?: return null + if (System.currentTimeMillis() > entry.expiresAtMs) { + cache.remove(host) + return null + } + return entry.onionUrl + } + + companion object { + val TTL_MS: Long = TimeUnit.HOURS.toMillis(24) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt index dc55fa051b..75edd59553 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionLocationInterceptor.kt @@ -24,7 +24,7 @@ import okhttp3.Interceptor import okhttp3.Response /** - * Network interceptor that passively captures `Onion-Location` headers from + * Application interceptor that passively captures `Onion-Location` headers from * every HTTP response — NIP-11 documents, WebSocket upgrade handshakes (101), * image/media servers, anything — and records the mapping into * [OnionLocationCache]. @@ -34,8 +34,12 @@ import okhttp3.Response * [OnionUrlRewriteInterceptor] swaps in the cached .onion address so the * connection avoids exit nodes entirely. * - * Registered as a network interceptor (not application interceptor) so it sees - * real server responses rather than cached copies. + * Registered as an application interceptor so that `chain.request().url.host` + * always returns the original clearnet hostname — the correct cache key. If it + * were a network interceptor it would run after [OnionUrlRewriteInterceptor] + * has already rewritten the host to `.onion`, causing cache entries from + * onion-routed responses to be stored under the `.onion` hostname instead of + * the clearnet hostname that [OnionUrlRewriteInterceptor] actually looks up. */ class OnionLocationInterceptor( private val cache: OnionLocationCache, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt index 210b7adc8c..ec0e04d3d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OnionUrlRewriteInterceptor.kt @@ -31,10 +31,15 @@ import okhttp3.Response * Only added to Tor-enabled OkHttpClient instances, so .onion substitution * never happens on clearnet connections (where .onion DNS would not resolve). * - * Scheme is derived from the `Onion-Location` header value: - * - `http://` onion + WebSocket original → `ws://` - * - `https://` onion + WebSocket original → `wss://` - * - HTTP originals take the onion scheme directly + * Scheme is derived from the `Onion-Location` header value to keep the request + * in the correct protocol family: + * - WebSocket originals (`ws`/`wss`) → `ws` or `wss` based on the onion scheme + * - HTTP originals (`http`/`https`) → `http` or `https` based on the onion scheme + * + * A `.onion` server advertising `http://` for a clearnet `https://` host is + * intentional and safe: the Tor circuit provides end-to-end encryption + * equivalent to TLS for onion services, so an `http://` onion connection is + * not a downgrade in practice. * * Port and path are preserved from the onion URL and original request * respectively. An unparseable cached URL falls through to the original. @@ -50,6 +55,7 @@ class OnionUrlRewriteInterceptor( val newScheme = when (original.url.scheme) { "ws", "wss" -> if (onionUrl.scheme == "https") "wss" else "ws" + "http", "https" -> if (onionUrl.scheme == "https") "https" else "http" else -> onionUrl.scheme }