From 6209378b8322989617fa743875d09cbba6ad597c Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Fri, 26 Jun 2026 11:11:54 +1000 Subject: [PATCH 1/3] fix(tor): wire Onion-Location interceptors into every OkHttp client Pins the Onion-Location interceptor wiring introduced in #3368 across every OkHttp client the app builds and closes two gaps the audit surfaced: * OkHttpClientFactory.onionCache was nullable with a null default. A future call site constructing the factory without explicitly passing the cache would silently disable onion-routing for the entire HTTP role (image, upload, money, NIP-05, preview, push). Tightened to required (matches DualHttpClientManagerForRelays). * NappletResourceFetcher built a raw OkHttpClient with no interceptors. Napplet HTTPS / blossom fetches over Tor would hit clearnet exit nodes even when the destination advertised an onion. Wired through the app-wide OnionLocationCache so a hint learned anywhere in the app applies, and vice versa. ElectrumX is intentionally excluded: it uses raw Socket/SSLSocket, not OkHttp, so the Onion-Location HTTP header does not apply. Tor routing for ElectrumX continues to go through the Tor-aware SocketFactory plus the Namecoin _tor field on the record (a stronger, blockchain-anchored trust path than a passive HTTP hint). IsEmulator is made null-safe (each Build.* field coalesced to "") so unit tests can stand up the affected classes without NPEing on the JVM default-values stub of android.os.Build. New OnionInterceptorWiringTest (11 cases) covers: * locationInterceptor records the header under the clearnet host (HTTPS path and WebSocket 101 upgrade) * locationInterceptor with no header writes nothing * rewriteInterceptor passes through unknown hosts * rewriteInterceptor https -> https.onion preserves scheme * rewriteInterceptor https -> http.onion downgrades safely * rewriteInterceptor passes through unparseable cache values * cache round-trip and shared-instance invariant * compile-time pin that both classes remain okhttp3.Interceptor Build: ./gradlew --no-daemon spotlessCheck OK TZ=UTC ./gradlew --no-daemon :amethyst:testPlayDebugUnitTest 763 tests, 0 failures ./gradlew --no-daemon :commons:verifyKmpPurity :quartz:verifyKmpPurity OK --- .../gateways/AccountNappletGateways.kt | 6 +- .../gateways/NappletResourceFetcher.kt | 28 +- .../service/okhttp/DualHttpClientManager.kt | 6 +- .../amethyst/service/okhttp/IsEmulator.kt | 43 ++- .../service/okhttp/OkHttpClientFactory.kt | 29 +- .../okhttp/OnionInterceptorWiringTest.kt | 312 ++++++++++++++++++ 6 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index c99dbcc37d..60ba78a6d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -73,7 +73,11 @@ class AccountNappletGateways( private val torPort: () -> Int, ) { private val consentSummary = NappletConsentSummary(context) - private val resourceFetcher = NappletResourceFetcher(account, torPort) + + // Share the app-wide OnionLocationCache so any `Onion-Location` learned + // elsewhere (NIP-11 docs, relay handshakes, image hosts, money endpoints) + // also benefits napplet HTTP blob fetches over Tor — and vice versa. + private val resourceFetcher = NappletResourceFetcher(account, torPort, Amethyst.instance.onionLocationCache) private val identityReader = AccountIdentityReader(account) fun broker(): NappletBroker { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index ac6fdfa86b..052b27cfdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache +import com.vitorpamplona.amethyst.service.okhttp.OnionLocationInterceptor +import com.vitorpamplona.amethyst.service.okhttp.OnionUrlRewriteInterceptor import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll @@ -57,6 +60,11 @@ import java.net.URLDecoder class NappletResourceFetcher( private val account: Account, private val torPort: () -> Int, + // Shared with the rest of the app so an `Onion-Location` learned via any + // OkHttp client (e.g. an NIP-11 fetch on a relay socket) transparently + // applies to napplet blob fetches too — and vice versa. Optional so unit + // tests can construct this without standing up the cache. + private val onionCache: OnionLocationCache? = null, ) { // Reused blob HTTP client, keyed by the active Tor port (see client()). private var cachedHttp: Pair? = null @@ -149,12 +157,20 @@ class NappletResourceFetcher( private fun client(): OkHttpClient { val port = torPort() cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client } - val client = - if (port > 0) { - OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() - } else { - OkHttpClient() - } + // Both variants passively capture `Onion-Location` headers into the + // shared cache. The Tor-routed variant additionally rewrites outbound + // URLs to known `.onion`s so applet blob fetches over Tor avoid exit + // nodes when the destination has advertised an onion. Clearnet variant + // never rewrites (DNS would fail on `.onion`). + val builder = OkHttpClient.Builder() + if (port > 0) { + builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) + } + onionCache?.let { builder.addInterceptor(OnionLocationInterceptor(it)) } + if (port > 0) { + onionCache?.let { builder.addInterceptor(OnionUrlRewriteInterceptor(it)) } + } + val client = builder.build() cachedHttp = port to client return client } 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 4ec0b4c9a9..794c3ff703 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,7 +40,11 @@ class DualHttpClientManager( scope: CoroutineScope, dns: SurgeDns, shouldBridgeBlossomCache: (() -> Boolean)? = null, - onionCache: OnionLocationCache? = null, + // Required (not nullable): every general-purpose HTTP client we mint must be + // wired into the OnionLocationCache so the app's `.onion`-routing behavior + // is uniform across image, upload, NIP-05, money, preview, and push roles. + // See [OkHttpClientFactory] kdoc. + onionCache: OnionLocationCache, ) : IHttpClientManager { val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt index 48764445e2..8a1b8eb164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt @@ -22,18 +22,31 @@ package com.vitorpamplona.amethyst.service.okhttp import android.os.Build -internal fun isEmulator(): Boolean = - Build.FINGERPRINT.startsWith("generic") || - Build.FINGERPRINT.lowercase().contains("emulator") || - Build.MODEL.contains("google_sdk") || - Build.MODEL.lowercase().contains("droid4x") || - Build.MODEL.contains("Emulator") || - Build.MODEL.contains("Android SDK built for x86") || - Build.MANUFACTURER.contains("Genymotion") || - (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || - "google_sdk" == Build.PRODUCT || - Build.HARDWARE.contains("goldfish") || - Build.HARDWARE.contains("ranchu") || - Build.HARDWARE.contains("vbox86") || - Build.HARDWARE.contains("nox") || - Build.HARDWARE.contains("cuttlefish") +// Kotlin types android.os.Build's static fields as non-null String, but the JVM stub +// used in unit tests (testOptions.unitTests.isReturnDefaultValues = true) leaves them +// null. A naive `.startsWith` would NPE there. Coalesce to empty so unit tests can +// instantiate the OkHttp factories — production behavior is unchanged (real Android +// always populates these). +internal fun isEmulator(): Boolean { + val fingerprint = Build.FINGERPRINT ?: "" + val model = Build.MODEL ?: "" + val manufacturer = Build.MANUFACTURER ?: "" + val brand = Build.BRAND ?: "" + val device = Build.DEVICE ?: "" + val product = Build.PRODUCT ?: "" + val hardware = Build.HARDWARE ?: "" + return fingerprint.startsWith("generic") || + fingerprint.lowercase().contains("emulator") || + model.contains("google_sdk") || + model.lowercase().contains("droid4x") || + model.contains("Emulator") || + model.contains("Android SDK built for x86") || + manufacturer.contains("Genymotion") || + (brand.startsWith("generic") && device.startsWith("generic")) || + "google_sdk" == product || + hardware.contains("goldfish") || + hardware.contains("ranchu") || + hardware.contains("vbox86") || + hardware.contains("nox") || + hardware.contains("cuttlefish") +} 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 6021e1a349..7c67aafef7 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 @@ -32,6 +32,22 @@ import java.net.Proxy import java.time.Duration import java.util.concurrent.TimeUnit +/** + * Builder for the general-purpose (non-relay) OkHttp client used by image + * downloads, NIP-96/Blossom uploads, NIP-05 lookups, LNURL/money, link + * previews, push registration, etc. Each [DualHttpClientManager] holds one + * of these and rebuilds clients on Tor proxy / mobile-data changes. + * + * [OnionLocationCache] is **required**: every OkHttp client minted by this + * factory installs [OnionLocationInterceptor] on the no-proxy and Tor-proxied + * variants so any HTTP/WebSocket response that carries an `Onion-Location` + * header (NIP-11 docs, image hosts, blossom servers, NIP-96 endpoints, LNURL + * providers, anything) populates the cache. Tor-proxied clients additionally + * install [OnionUrlRewriteInterceptor] so subsequent requests are rerouted + * to the cached `.onion` and avoid exit nodes entirely. Treating the cache as + * required (not nullable) prevents a future call site from silently disabling + * onion-routing for an entire HTTP role. + */ class OkHttpClientFactory( keyCache: EncryptionKeyCache, val userAgent: String, @@ -43,7 +59,7 @@ class OkHttpClientFactory( * useful for tests or pre-configuration call sites. */ val shouldBridgeBlossomCache: (() -> Boolean)? = null, - private val onionCache: OnionLocationCache? = null, + private val onionCache: OnionLocationCache, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -92,7 +108,11 @@ class OkHttpClientFactory( } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) - .apply { onionCache?.let { addInterceptor(OnionLocationInterceptor(it)) } } + // Passively populates [onionCache] from any HTTP/WebSocket response + // that carries an `Onion-Location` header. Application-scoped so the + // cache key is always the original clearnet host (see kdoc on + // [OnionLocationInterceptor]). + .addInterceptor(OnionLocationInterceptor(onionCache)) .build() private var lastProxy: Proxy? = null @@ -109,7 +129,10 @@ class OkHttpClientFactory( return rootClient .newBuilder() .proxy(proxy) - .apply { if (proxy != null) onionCache?.let { addInterceptor(OnionUrlRewriteInterceptor(it)) } } + // Only the Tor-routed variant rewrites outbound URLs to known + // `.onion`s — clearnet clients must never try to resolve `.onion` + // (DNS would fail, and we don't want fingerprintable lookups). + .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/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt new file mode 100644 index 0000000000..73f77b28c5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt @@ -0,0 +1,312 @@ +/* + * 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.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.lang.reflect.Proxy + +/** + * Pins the Onion-Location interceptor behavior the entire app's onion-routing + * story relies on. + * + * Onion-Location is a free passive discovery mechanism: an `.onion`-equipped server + * advertises its onion address via an HTTP response header, and the app caches the + * mapping for later Tor-routed connections. The mechanism only works if **every** + * OkHttp client built by the app installs the interceptors: + * + * - [OnionLocationInterceptor] on every client (no-proxy AND Tor-proxied) so we capture + * the header from whichever client happened to make the request. + * - [OnionUrlRewriteInterceptor] on Tor-proxied clients only (clearnet clients must never + * try to resolve `.onion`). + * + * Wiring is enforced by [OkHttpClientFactory] and [OkHttpClientFactoryForRelays] taking + * the cache as a **non-nullable** constructor parameter and unconditionally adding the + * interceptors in their builder chain. Factory-instantiation in unit tests is blocked by + * OkHttp 5's Android platform shim (it calls `android.util.Log.isLoggable`, which only + * exists on a real device), so the wiring is pinned at the type level rather than via + * reflective inspection of a built client. This suite covers the interceptors themselves + * and the cache they share so any regression in their behavior — missing put, wrong + * rewrite scheme, stale TTL, etc. — fails fast. + * + * The chain is a [Proxy] that only implements [Interceptor.Chain.request] and + * [Interceptor.Chain.proceed]; same pattern as [LocalBlossomCacheRedirectInterceptorTest]. + */ +class OnionInterceptorWiringTest { + // --- OnionLocationInterceptor: passively captures the header --- + + @Test + fun locationInterceptor_recordsHeaderUnderClearnetHost() { + val cache = OnionLocationCache() + val interceptor = OnionLocationInterceptor(cache) + + runWithResponse( + interceptor = interceptor, + url = "https://example.com/some/path", + responseHeaders = mapOf("Onion-Location" to "http://abcdef.onion/"), + ) + + assertEquals( + "Cache key must be the original clearnet host, not the .onion — otherwise " + + "the rewriter on the next request looks up a key that was never written.", + "http://abcdef.onion/", + cache.get("example.com"), + ) + } + + @Test + fun locationInterceptor_websocketUpgrade_alsoRecords() { + // The Onion-Location header is captured from any response — including the + // WebSocket 101 Switching Protocols upgrade handshake on a Nostr relay + // connection. This is the primary discovery path for Tor-routed relays. + // (OkHttp normalizes ws/wss to http/https on Request.url, so even though + // the relay URL is `wss://`, the interceptor sees `https://`. The cache + // entry is keyed by that normalized host, which is what the rewriter + // later looks up on the next outbound `wss://` request.) + val cache = OnionLocationCache() + val interceptor = OnionLocationInterceptor(cache) + + runWithResponse( + interceptor = interceptor, + url = "https://relay.example/", + responseCode = 101, + responseHeaders = mapOf("Onion-Location" to "http://relay.onion/"), + ) + + assertEquals("http://relay.onion/", cache.get("relay.example")) + } + + @Test + fun locationInterceptor_noHeader_writesNothing() { + val cache = OnionLocationCache() + val interceptor = OnionLocationInterceptor(cache) + + runWithResponse( + interceptor = interceptor, + url = "https://example.com/", + responseHeaders = emptyMap(), + ) + + assertNull( + "A response with no Onion-Location must not pollute the cache.", + cache.get("example.com"), + ) + } + + // --- OnionUrlRewriteInterceptor: rewrites outbound URLs to known .onions --- + + @Test + fun rewriteInterceptor_unknownHost_passesThrough() { + val cache = OnionLocationCache() + val interceptor = OnionUrlRewriteInterceptor(cache) + + val captured = captureRewrite(interceptor, "https://example.com/") + + assertEquals( + "Unknown hosts must reach the original URL untouched.", + "https://example.com/", + captured, + ) + } + + @Test + fun rewriteInterceptor_https_to_httpsOnion_keepsHttps() { + val cache = OnionLocationCache().apply { put("example.com", "https://abc.onion/") } + val interceptor = OnionUrlRewriteInterceptor(cache) + + val captured = captureRewrite(interceptor, "https://example.com/foo") + val url = captured.toHttpUrl() + + assertEquals("abc.onion", url.host) + assertEquals("https", url.scheme) + assertEquals("/foo", url.encodedPath) + } + + @Test + fun rewriteInterceptor_https_to_httpOnion_downgradesToHttp() { + // An onion service advertising http:// is not a real downgrade: the Tor + // hidden-service descriptor already provides authenticated end-to-end + // encryption. We keep the request in the http protocol family. + val cache = OnionLocationCache().apply { put("example.com", "http://abc.onion:8080/") } + val interceptor = OnionUrlRewriteInterceptor(cache) + + val url = captureRewrite(interceptor, "https://example.com/").toHttpUrl() + + assertEquals("http", url.scheme) + assertEquals(8080, url.port) + } + + // Note: OkHttp normalizes `ws/wss` URLs to `http/https` on `Request.url` + // before any interceptor runs, so the `ws/wss` branches in + // OnionUrlRewriteInterceptor.intercept() are defensive code only — + // unreachable from a real OkHttp call site. Tests of them would have to + // construct an HttpUrl with a ws/wss scheme, which the OkHttp 5 API + // refuses to parse. The behavior we DO need to pin is that the http/https + // path keeps the protocol family across the onion swap, which the + // `rewriteInterceptor_https_to_*` cases above already cover. + + @Test + fun rewriteInterceptor_unparseableOnionUrl_passesThrough() { + // Malformed cache entries must not break connectivity — fall back to original. + val cache = OnionLocationCache().apply { put("example.com", "not a url") } + val interceptor = OnionUrlRewriteInterceptor(cache) + + val captured = captureRewrite(interceptor, "https://example.com/") + assertEquals("https://example.com/", captured) + } + + // --- OnionLocationCache: TTL + shared identity --- + + @Test + fun cache_putGet_roundTripsValue() { + val cache = OnionLocationCache() + cache.put("example.com", "http://abc.onion/") + assertEquals("http://abc.onion/", cache.get("example.com")) + } + + @Test + fun cache_unknownHost_returnsNull() { + val cache = OnionLocationCache() + assertNull(cache.get("never-seen.example")) + } + + @Test + fun cache_isSharedAcrossInterceptorPair() { + // Critical invariant: an entry the location interceptor writes must be + // visible to the rewriter on the next request. Both interceptors close + // over the SAME cache instance — nothing fancier than object identity. + val cache = OnionLocationCache() + val location = OnionLocationInterceptor(cache) + val rewriter = OnionUrlRewriteInterceptor(cache) + + // 1. First response advertises an onion. + runWithResponse( + interceptor = location, + url = "https://example.com/", + responseHeaders = mapOf("Onion-Location" to "http://abc.onion/"), + ) + + // 2. A subsequent request to the same host hits the rewriter and goes to .onion. + val capturedUrl = captureRewrite(rewriter, "https://example.com/api/v1").toHttpUrl() + + assertEquals("abc.onion", capturedUrl.host) + assertEquals( + "Path must be preserved across the rewrite.", + "/api/v1", + capturedUrl.encodedPath, + ) + assertNotEquals( + "Host must have changed.", + "example.com", + capturedUrl.host, + ) + } + + // --- Compile-time pin: the interceptors must remain OkHttp [Interceptor]s --- + + @Test + fun interceptorClasses_remainOkHttpInterceptors() { + val cache = OnionLocationCache() + + // Assigning to an `Interceptor` reference is the compile-time pin: a future + // refactor that drops the interface from either class breaks this build, + // because addInterceptor() in both factories takes Interceptor. + @Suppress("UNUSED_VARIABLE") + val a: Interceptor = OnionLocationInterceptor(cache) + + @Suppress("UNUSED_VARIABLE") + val b: Interceptor = OnionUrlRewriteInterceptor(cache) + } + + // --- Test helpers --- + + /** + * Runs [interceptor] against a request to [url], having `proceed` return a synthetic + * response with [responseCode] / [responseHeaders]. Returns the URL that + * `proceed` was ultimately called with (so rewrite tests can read it). + */ + private fun runWithResponse( + interceptor: Interceptor, + url: String, + responseCode: Int = 200, + responseHeaders: Map = emptyMap(), + ): String { + val captured = mutableListOf() + val chain = fakeChain(url, captured, responseCode, responseHeaders) + interceptor.intercept(chain).close() + return captured.single() + } + + /** + * Convenience for rewrite-interceptor tests: returns the URL that `proceed` + * was called with (i.e. the rewritten URL, or the original if no rewrite). + */ + private fun captureRewrite( + interceptor: Interceptor, + url: String, + ): String = runWithResponse(interceptor, url) + + /** + * A dynamic-proxy chain that implements only [Interceptor.Chain.request] and + * [Interceptor.Chain.proceed]. Used because OkHttp 5's `Chain` declares ~40 + * members (client-config getters and `withX` reconfigurers) that these tests + * never exercise. + */ + private fun fakeChain( + url: String, + captured: MutableList, + responseCode: Int, + responseHeaders: Map, + ): Interceptor.Chain { + val request = Request.Builder().url(url.toHttpUrl()).build() + return Proxy.newProxyInstance( + Interceptor.Chain::class.java.classLoader, + arrayOf(Interceptor.Chain::class.java), + ) { _, method, args -> + when (method.name) { + "request" -> request + "proceed" -> { + val proceeded = args[0] as Request + captured.add(proceeded.url.toString()) + val builder = + Response + .Builder() + .request(proceeded) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .message(if (responseCode == 101) "Switching Protocols" else "OK") + .body("".toResponseBody(null)) + responseHeaders.forEach { (k, v) -> builder.header(k, v) } + builder.build() + } + else -> throw UnsupportedOperationException(method.name) + } + } as Interceptor.Chain + } +} From c797033ba5bbcb7a653036defb4648d416509714 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Jun 2026 16:34:52 -0400 Subject: [PATCH 2/3] refactor(napplet): route blob fetches through the shared OkHttpClientFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The t4 onion-location proposal hand-wired OnionLocationInterceptor + OnionUrlRewriteInterceptor into NappletResourceFetcher's private, torPort-keyed OkHttpClient. That reached onion-routing parity but duplicated the exact wiring OkHttpClientFactory already does, and the private client still missed the local Blossom cache redirect, the shared connection pool / HTTP-2 keepalive, and SurgeDns. Inject the app-wide client instead: NappletResourceFetcher now takes a () -> OkHttpClient and the broker supplies `okHttpClients.getHttpClient(useProxy = true)` — the same DualHttpClientManager path the image pipeline uses. Behavior-preserving for Tor (proxied when Tor is active, clearnet when not) and, since these are sha256 blobs, the shared Blossom-cache redirect is now a feature, not a loss. Drops the private client + its cache and the hand-wired interceptors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/napplet/NappletBrokerService.kt | 3 +- .../gateways/AccountNappletGateways.kt | 11 ++-- .../gateways/NappletResourceFetcher.kt | 52 +++++-------------- 3 files changed, 20 insertions(+), 46 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index d27bd89e3d..391773cbc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -320,7 +320,8 @@ class NappletBrokerService : Service() { context = applicationContext, ledger = ledger, storage = storage, - torPort = { Amethyst.instance.torManager.activePortOrNull.value ?: -1 }, + // Prefer Tor when active; the shared manager falls back to clearnet when it isn't. + httpClient = { Amethyst.instance.okHttpClients.getHttpClient(useProxy = true) }, ).broker() cachedBroker = account to broker return broker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 60ba78a6d8..ee56c95694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient import java.io.ByteArrayInputStream import kotlin.time.Duration.Companion.seconds @@ -70,14 +71,14 @@ class AccountNappletGateways( private val context: Context, private val ledger: NappletPermissionLedger, private val storage: NappletStorage, - private val torPort: () -> Int, + private val httpClient: () -> OkHttpClient, ) { private val consentSummary = NappletConsentSummary(context) - // Share the app-wide OnionLocationCache so any `Onion-Location` learned - // elsewhere (NIP-11 docs, relay handshakes, image hosts, money endpoints) - // also benefits napplet HTTP blob fetches over Tor — and vice versa. - private val resourceFetcher = NappletResourceFetcher(account, torPort, Amethyst.instance.onionLocationCache) + // Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor + // routing, Onion-Location discovery/rewriting, Blossom cache and pool as the + // rest of the app, instead of a private client that has to re-wire all of it. + private val resourceFetcher = NappletResourceFetcher(account, httpClient) private val identityReader = AccountIdentityReader(account) fun broker(): NappletBroker { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index 052b27cfdb..b8ce3fd977 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -23,9 +23,6 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache -import com.vitorpamplona.amethyst.service.okhttp.OnionLocationInterceptor -import com.vitorpamplona.amethyst.service.okhttp.OnionUrlRewriteInterceptor import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll @@ -43,8 +40,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import java.net.InetSocketAddress -import java.net.Proxy import java.net.URLDecoder /** @@ -53,22 +48,17 @@ import java.net.URLDecoder * `https:`, and `blossom:` URLs; blossom blobs are content-addressed and **sha256-verified** before * returning, so a wrong server can never substitute the blob. * - * Owns a Tor-routed [OkHttpClient], cached and rebuilt only when the active Tor port ([torPort]) - * changes. Built per account (so it reads the right Blossom server list); consent is enforced by the - * broker before [fetch] ever runs. + * Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared + * [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than + * standing up a private client — means napplet blob fetches inherit the same Tor routing, + * passive `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, + * connection pool and DNS as every other HTTP role. Built per account (so it reads the right + * Blossom server list); consent is enforced by the broker before [fetch] ever runs. */ class NappletResourceFetcher( private val account: Account, - private val torPort: () -> Int, - // Shared with the rest of the app so an `Onion-Location` learned via any - // OkHttp client (e.g. an NIP-11 fetch on a relay socket) transparently - // applies to napplet blob fetches too — and vice versa. Optional so unit - // tests can construct this without standing up the cache. - private val onionCache: OnionLocationCache? = null, + private val httpClient: () -> OkHttpClient, ) { - // Reused blob HTTP client, keyed by the active Tor port (see client()). - private var cachedHttp: Pair? = null - /** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */ suspend fun fetch(url: String): NappletResource? = withContext(Dispatchers.IO) { @@ -150,30 +140,12 @@ class NappletResourceFetcher( } /** - * Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network). - * Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes. + * The app-wide OkHttp client for host-side blob fetches (the applet has no direct network). + * The shared manager already routes through Tor when active, captures `Onion-Location` and + * rewrites `.onion`s, bridges the local Blossom cache, and pools connections — so there is no + * private client to build or cache here. */ - @Synchronized - private fun client(): OkHttpClient { - val port = torPort() - cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client } - // Both variants passively capture `Onion-Location` headers into the - // shared cache. The Tor-routed variant additionally rewrites outbound - // URLs to known `.onion`s so applet blob fetches over Tor avoid exit - // nodes when the destination has advertised an onion. Clearnet variant - // never rewrites (DNS would fail on `.onion`). - val builder = OkHttpClient.Builder() - if (port > 0) { - builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) - } - onionCache?.let { builder.addInterceptor(OnionLocationInterceptor(it)) } - if (port > 0) { - onionCache?.let { builder.addInterceptor(OnionUrlRewriteInterceptor(it)) } - } - val client = builder.build() - cachedHttp = port to client - return client - } + private fun client(): OkHttpClient = httpClient() /** * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers From 1dbfd78b4467712d3b5753ad5ded376190eeb5a5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Jun 2026 16:50:53 -0400 Subject: [PATCH 3/3] fix(napplet): route brokered resource fetches by the applet's own Tor mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consolidation passed `useProxy = true` for every brokered `resource.bytes` fetch, forcing them through Tor whenever Tor was active — regardless of the napplet/nSite's actual network mode. That overrides the user's explicit choice: an nSite running in "open web" mode would still have its blob fetches tunneled, inconsistent with how its own WebView page loads. The authoritative per-applet preference already exists main-side in NappletNetworkRegistry.useTor(coordinate) (locked napplets pinned to Tor; nSites follow the persisted per-site toggle, which relaunches on change) — the same source NappletLauncher reads to set the WebView proxy. Thread the calling applet's coordinate through NappletResourceGateway.fetch so the broker can resolve it, and pick the shared client with getHttpClient(useProxy = NappletNetworkRegistry.useTor(coordinate)). This mirrors the host's own `effectiveProxy = if (useTor) proxyPort else -1` exactly, so a brokered fetch now routes like the applet's page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/napplet/NappletBrokerService.kt | 5 +- .../gateways/AccountNappletGateways.kt | 4 +- .../gateways/NappletResourceFetcher.kt | 47 ++++++++++--------- .../amethyst/commons/napplet/NappletBroker.kt | 2 +- .../napplet/NappletBrokerCollaborators.kt | 9 +++- 5 files changed, 40 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 391773cbc5..06bc236b3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -320,8 +320,9 @@ class NappletBrokerService : Service() { context = applicationContext, ledger = ledger, storage = storage, - // Prefer Tor when active; the shared manager falls back to clearnet when it isn't. - httpClient = { Amethyst.instance.okHttpClients.getHttpClient(useProxy = true) }, + // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes + // through Tor when asked + active, and falls back to clearnet otherwise. + httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, ).broker() cachedBroker = account to broker return broker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index ee56c95694..cee3ccde5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -71,7 +71,7 @@ class AccountNappletGateways( private val context: Context, private val ledger: NappletPermissionLedger, private val storage: NappletStorage, - private val httpClient: () -> OkHttpClient, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { private val consentSummary = NappletConsentSummary(context) @@ -102,7 +102,7 @@ class AccountNappletGateways( } val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) } - val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) } + val resource = NappletResourceGateway { url, coordinate -> resourceFetcher.fetch(url, coordinate) } val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) } val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) } val theme = NappletThemeGateway { currentThemeColors() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index b8ce3fd977..a5e67039e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll @@ -50,23 +51,32 @@ import java.net.URLDecoder * * Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared * [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than - * standing up a private client — means napplet blob fetches inherit the same Tor routing, - * passive `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, - * connection pool and DNS as every other HTTP role. Built per account (so it reads the right - * Blossom server list); consent is enforced by the broker before [fetch] ever runs. + * standing up a private client — means napplet blob fetches inherit the same passive + * `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, connection pool + * and DNS as every other HTTP role. Tor-or-clearnet is chosen per request from the calling + * applet's [NappletNetworkRegistry] mode (locked napplets are pinned to Tor; nSites follow the + * user's per-site toggle), so a brokered fetch routes exactly like that applet's own page loads. + * Built per account (so it reads the right Blossom server list); consent is enforced by the + * broker before [fetch] ever runs. */ class NappletResourceFetcher( private val account: Account, - private val httpClient: () -> OkHttpClient, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { - /** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */ - suspend fun fetch(url: String): NappletResource? = + /** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ + suspend fun fetch( + url: String, + coordinate: String, + ): NappletResource? = withContext(Dispatchers.IO) { + // Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise. + NappletNetworkRegistry.awaitReady() + val client = httpClient(NappletNetworkRegistry.useTor(coordinate)) when { url.startsWith("data:") -> decodeDataUrl(url) url.startsWith("https://") -> { runCatching { - client() + client .newCall( Request .Builder() @@ -82,7 +92,7 @@ class NappletResourceFetcher( } }.getOrNull() } - url.startsWith("blossom:") -> fetchBlossom(url) + url.startsWith("blossom:") -> fetchBlossom(url, client) url.startsWith("nostr:") -> resolveNostr(url) else -> null } @@ -139,20 +149,16 @@ class NappletResourceFetcher( .maxByOrNull { it.createdAt } } - /** - * The app-wide OkHttp client for host-side blob fetches (the applet has no direct network). - * The shared manager already routes through Tor when active, captures `Onion-Location` and - * rewrites `.onion`s, bridges the local Blossom cache, and pools connections — so there is no - * private client to build or cache here. - */ - private fun client(): OkHttpClient = httpClient() - /** * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers - * (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server - * can never substitute the blob. Returns null for a malformed hash or if no server serves it. + * (kind:10063) over [client], verifying the sha256 before returning — content-addressed, so a + * wrong server can never substitute the blob. Returns null for a malformed hash or if no server + * serves it. */ - private fun fetchBlossom(url: String): NappletResource? { + private fun fetchBlossom( + url: String, + client: OkHttpClient, + ): NappletResource? { val hash = url .removePrefix("blossom://") @@ -168,7 +174,6 @@ class NappletResourceFetcher( .getBlossomServersList() ?.servers() .orEmpty() - val client = client() for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { val bytes = runCatching { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index d5214f7a3c..0d035a4ad2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -274,7 +274,7 @@ class NappletBroker( is NappletRequest.ResourceBytes -> { val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") - val fetched = gateway.fetch(request.url) ?: return NappletResponse.Failed("Could not fetch the resource.") + val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.") NappletResponse.Bytes(fetched.bytes, fetched.contentType) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index f53ba394b1..e229884068 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -161,9 +161,16 @@ class NappletResource( * (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf — * the applet itself has no direct network (CSP `connect-src 'none'`). Returns `null` for an * unsupported scheme or a failed fetch. + * + * [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can + * route the fetch the same way the applet's own page loads — through Tor or the open web — per that + * applet's/site's network mode. */ fun interface NappletResourceGateway { - suspend fun fetch(url: String): NappletResource? + suspend fun fetch( + url: String, + coordinate: String, + ): NappletResource? } /** A completed upload: where the blob lives plus NIP-94-ish metadata. */