From 3f39f96e81eaeb28b0a5038b1a851cb13d9b17d3 Mon Sep 17 00:00:00 2001 From: M Date: Tue, 3 Mar 2026 06:28:01 +1100 Subject: [PATCH] fix: route ElectrumX through Tor proxy, add onion server - ElectrumxClient accepts injected SocketFactory (lambda) so connections respect the user's Tor/proxy settings instead of leaking their IP through raw sockets - Add ProxiedSocketFactory for SOCKS5 proxy routing - Add .onion ElectrumX server as primary when Tor is enabled, with electrumx.testls.space as clearnet fallback - NamecoinNameResolver accepts serverListProvider lambda for dynamic server selection based on current Tor settings - RoleBasedHttpClientBuilder.socketFactoryForNip05() bridges Amethyst's Tor settings to the socket factory - NamecoinNameService now requires explicit init with proxy-aware client - Remove dead code: Nip05NamecoinAdapter (never referenced), NamecoinVerificationDisplay (never called) - Update design documentation --- .../com/vitorpamplona/amethyst/AppModules.kt | 16 +- .../privacyOptions/ProxiedSocketFactory.kt | 70 +++++ .../RoleBasedHttpClientBuilder.kt | 23 ++ .../service/namecoin/NamecoinNameService.kt | 15 +- .../service/namecoin/Nip05NamecoinAdapter.kt | 62 ----- .../namecoin/NamecoinVerificationDisplay.kt | 259 ------------------ docs/namecoin-nip05-design.md | 122 ++++++--- .../quartz/nip05/namecoin/ElectrumxClient.kt | 57 ++-- .../nip05/namecoin/NamecoinNameResolver.kt | 3 +- 9 files changed, 241 insertions(+), 386 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 25ef825e6d..41e45652be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -142,11 +142,23 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val namecoinElectrumxClient = + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) val namecoinResolver = com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver( - com.vitorpamplona.quartz.nip05.namecoin - .ElectrumxClient(), + electrumxClient = namecoinElectrumxClient, + serverListProvider = { + if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS + } else { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS + } + }, ) + val namecoinNameService = + com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService.init(namecoinElectrumxClient) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt new file mode 100644 index 0000000000..53a52361f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.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.model.privacyOptions + +import java.net.InetAddress +import java.net.Proxy +import java.net.Socket +import javax.net.SocketFactory + +/** + * A [SocketFactory] that creates sockets routed through a SOCKS proxy. + * + * Used to ensure raw TCP connections (e.g. ElectrumX for Namecoin) + * respect the user's Tor/proxy settings, preventing IP leaks. + */ +class ProxiedSocketFactory( + private val proxy: Proxy, +) : SocketFactory() { + override fun createSocket(): Socket = Socket(proxy) + + override fun createSocket( + host: String, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localHost, localPort)) + connect(java.net.InetSocketAddress(host, port)) + } + + override fun createSocket( + host: InetAddress, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localAddress, localPort)) + connect(java.net.InetSocketAddress(address, port)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index aacb3e2621..daea244bb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, @@ -141,4 +144,24 @@ class RoleBasedHttpClientBuilder( override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + + /** + * Returns a [SocketFactory] that routes through the user's Tor proxy + * when NIP-05 verification traffic should use Tor. + * + * Used by [ElectrumxClient] so that Namecoin lookups respect the + * same proxy/Tor settings as HTTP-based NIP-05 verification, + * preventing IP leaks through direct socket connections. + */ + fun socketFactoryForNip05(): SocketFactory { + // ElectrumX servers are always external, so we use a dummy + // non-localhost, non-onion URL to query the Tor policy. + val useTor = shouldUseTorForNIP05("https://electrumx.example.com") + if (!useTor) return SocketFactory.getDefault() + + val proxy = okHttpClient.getCurrentProxy() ?: return SocketFactory.getDefault() + val proxyAddr = proxy.address() as? InetSocketAddress ?: return SocketFactory.getDefault() + + return ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS, proxyAddr)) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 178a755b66..6f58bfcaa1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -38,10 +38,11 @@ import kotlinx.coroutines.launch * Thread-safe, lifecycle-aware, and designed for integration * into Amethyst's existing `ServiceManager` infrastructure. */ -class NamecoinNameService private constructor() { +class NamecoinNameService( + electrumxClient: ElectrumxClient = ElectrumxClient(), +) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val electrumxClient = ElectrumxClient() private val resolver = NamecoinNameResolver(electrumxClient) private val cache = NamecoinLookupCache() @@ -53,8 +54,14 @@ class NamecoinNameService private constructor() { private var instance: NamecoinNameService? = null fun getInstance(): NamecoinNameService = - instance ?: synchronized(this) { - instance ?: NamecoinNameService().also { instance = it } + instance ?: throw IllegalStateException( + "NamecoinNameService not initialized. Call init() first.", + ) + + fun init(electrumxClient: ElectrumxClient): NamecoinNameService = + synchronized(this) { + instance?.let { return it } + NamecoinNameService(electrumxClient).also { instance = it } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt deleted file mode 100644 index 706570c163..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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.namecoin - -import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver - -/** - * Static adapter for hooking Namecoin into NIP-05 verification. - */ -object Nip05NamecoinAdapter { - /** - * Attempt Namecoin-based NIP-05 verification. - * - * @param nip05Address The `nip05` field from a kind-0 event - * @param expectedPubkeyHex The hex pubkey from the same event - * @return `true` if verified via Namecoin, `false` if Namecoin says - * the mapping is wrong, or `null` if this identifier is not - * a Namecoin identifier (caller should fall through to HTTP NIP-05). - */ - suspend fun tryVerify( - nip05Address: String, - expectedPubkeyHex: String, - ): Boolean? { - if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) { - return null // Not a Namecoin identifier → let caller handle via HTTP - } - return NamecoinNameService.getInstance().verifyNip05(nip05Address, expectedPubkeyHex) - } - - /** - * Attempt to resolve a Namecoin identifier from the search bar. - * - * Called from the search/discovery flow when the user types an - * identifier. Returns the hex pubkey if found, null otherwise. - * - * @param query The user's search input - * @return Pair of (pubkey, relayList) or null - */ - suspend fun tryResolveSearch(query: String): Pair>? { - if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null - val result = NamecoinNameService.getInstance().resolve(query) ?: return null - return Pair(result.pubkey, result.relays) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt deleted file mode 100644 index 2942b091f0..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * 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.ui.note.namecoin - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService -import com.vitorpamplona.amethyst.service.namecoin.NamecoinResolveState - -/** - * Display a Namecoin-verified identity badge. - * - * Shows the Namecoin name with a blockchain icon when verified. - * Renders nothing if verification fails or is still loading. - * - * @param nip05 The nip05 field value ending in .bit or starting with id/ - * @param expectedPubkeyHex The profile's pubkey to verify against - * @param modifier Standard compose modifier - */ -@Composable -fun NamecoinVerificationDisplay( - nip05: String, - expectedPubkeyHex: String, - modifier: Modifier = Modifier, -) { - var isVerified by remember(nip05, expectedPubkeyHex) { mutableStateOf(null) } - - LaunchedEffect(nip05, expectedPubkeyHex) { - isVerified = NamecoinNameService.getInstance().verifyNip05(nip05, expectedPubkeyHex) - } - - if (isVerified == true) { - NamecoinBadge( - displayName = formatDisplayName(nip05), - namespace = inferNamespace(nip05), - modifier = modifier, - ) - } -} - -/** - * The visual badge component. - * - * Shows: [chain icon] [display name] - * With namespace-appropriate styling. - */ -@Composable -private fun NamecoinBadge( - displayName: String, - namespace: String, // "d/" or "id/" - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.padding(top = 1.dp, bottom = 1.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - // Blockchain chain-link icon - // In production, replace with a proper vector drawable. - // For now, use a text glyph as placeholder. - Text( - text = "\u26D3", // ⛓ chain link emoji - fontSize = 12.sp, - color = NamecoinColors.chainIcon, - modifier = Modifier.padding(end = 2.dp), - ) - - // Namespace indicator - Text( - text = displayName, - fontSize = 14.sp, - color = NamecoinColors.verifiedText, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** - * Composable for the search result when a Namecoin name resolves. - * - * Shows the resolved Namecoin name, the Nostr pubkey, and relay hints. - * Used in the search results list. - */ -@Composable -fun NamecoinSearchResult( - identifier: String, - onProfileClick: (pubkeyHex: String) -> Unit, - modifier: Modifier = Modifier, -) { - val resolveState by NamecoinNameService - .getInstance() - .resolveLive(identifier) - .collectAsState() - - when (val state = resolveState) { - is NamecoinResolveState.Loading -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\u26D3", // ⛓ - fontSize = 16.sp, - color = NamecoinColors.chainIcon, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Resolving $identifier via Namecoin…", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.Resolved -> { - Row( - modifier = - modifier - .clickable { onProfileClick(state.result.pubkey) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\u26D3", // ⛓ - fontSize = 16.sp, - color = NamecoinColors.verified, - ) - Spacer(Modifier.width(8.dp)) - // The resolved identity - Text( - text = formatDisplayName(identifier), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.width(4.dp)) - Text( - text = "(${state.result.pubkey.take(8)}…)", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.NotFound -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "No Nostr identity found for $identifier", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.Error -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Namecoin lookup failed: ${state.message}", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.error, - ) - } - } - } -} - -// ── Display Helpers ──────────────────────────────────────────────────── - -/** - * Format a raw identifier for user-friendly display. - * - * "alice@example.bit" → "alice@example.bit" - * "_@example.bit" → "example.bit" (NIP-05 root convention) - * "d/example" → "example.bit" - * "id/alice" → "id/alice" - */ -private fun formatDisplayName(identifier: String): String { - val input = identifier.trim() - - // _@domain.bit → show just domain.bit - if (input.startsWith("_@")) { - return input.removePrefix("_@") - } - - // d/name → name.bit - if (input.startsWith("d/", ignoreCase = true)) { - return input.removePrefix("d/").removePrefix("D/") + ".bit" - } - - // id/name stays as-is - if (input.startsWith("id/", ignoreCase = true)) { - return input - } - - return input -} - -private fun inferNamespace(identifier: String): String { - val input = identifier.trim().lowercase() - return when { - input.startsWith("id/") -> "id/" - else -> "d/" - } -} - -/** - * Color palette for Namecoin verification UI. - */ -private object NamecoinColors { - val chainIcon = Color(0xFF4A90D9) // Namecoin blue - val verified = Color(0xFF2E8B57) // Sea green — distinct from NIP-05 blue - val verifiedText = Color(0xFF4A90D9) // Namecoin blue for the text -} diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md index 8289ac9d20..557e88ff96 100644 --- a/docs/namecoin-nip05-design.md +++ b/docs/namecoin-nip05-design.md @@ -17,43 +17,80 @@ This is censorship-resistant identity verification: no web server to seize, no D │ └── Namecoin path (new) ── NamecoinNameResolver │ │ │ │ │ NamecoinNameService (singleton) │ │ -│ ├── NamecoinLookupCache │ │ -│ └── Nip05NamecoinAdapter │ │ +│ └── NamecoinLookupCache │ │ │ │ │ -│ UI: NamecoinVerificationDisplay │ │ -│ NamecoinSearchResult │ │ +│ RoleBasedHttpClientBuilder │ │ +│ └── socketFactoryForNip05() ───┤ (Tor-aware sockets) │ +│ │ │ +│ ProxiedSocketFactory │ │ +│ └── SOCKS5 proxy routing ───┘ │ ├────────────────────────────────────┼────────────────────────┤ │ Quartz Library │ ├────────────────────────────────────┼────────────────────────┤ │ NamecoinNameResolver │ │ │ ├── parseIdentifier() │ │ │ ├── extractFromDomainValue() │ (d/ namespace) │ -│ └── extractFromIdentityValue() │ (id/ namespace) │ +│ ├── extractFromIdentityValue() │ (id/ namespace) │ +│ └── serverListProvider() │ (Tor/clearnet routing) │ │ │ │ │ ElectrumxClient │ │ │ ├── buildNameIndexScript() │ │ │ ├── electrumScriptHash() │ │ -│ └── parseNameScript() │ │ +│ ├── parseNameScript() │ │ +│ └── socketFactory() ───┤ (injected, proxy-aware)│ │ ▼ │ -│ ┌──────────────────┐ │ -│ │ ElectrumX Server │ │ -│ │ (Namecoin node) │ │ -│ └──────────────────┘ │ +│ ┌───────────────────────────┐ │ +│ │ ElectrumX Server │ │ +│ │ (clearnet or .onion) │ │ +│ └───────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### Layer Separation - **`quartz/` (library)** — Protocol-level logic. No Android dependencies. - - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing - - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping + - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support. + - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection. - `NamecoinLookupCache` — LRU cache with TTL - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction -- **`amethyst/` (app)** — Android integration and UI. - - `NamecoinNameService` — Application singleton, lifecycle management - - `Nip05NamecoinAdapter` — Static bridge for NIP-05 verification hooks - - `NamecoinVerificationDisplay` — Compose UI for verified badge + search results +- **`amethyst/` (app)** — Android integration and Tor-aware wiring. + - `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient` + - `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor) + - `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings + +## Tor & Proxy Integration + +The ElectrumX connection respects the user's Tor settings to prevent IP leaks: + +### Problem + +The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic. + +### Solution + +1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction) +2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5) +3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy +4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel + +### Server Selection + +When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing: + +| Setting | Primary server | Fallback | +|---|---|---| +| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` | +| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) | + +The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app. + +### Dynamic Evaluation + +Both the socket factory and server list are provided as lambdas, not captured values. This means: +- Toggling Tor on/off in settings takes effect on the next Namecoin lookup +- The proxy port is read from `DualHttpClientManager`'s live `StateFlow` +- No app restart or singleton reconstruction needed ## ElectrumX Protocol — How Name Resolution Works @@ -152,7 +189,7 @@ The integration is minimal and non-invasive: 1. **`Nip05Client`** gains an optional `namecoinResolver` parameter 2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher 3. Non-Namecoin identifiers are completely unaffected -4. **`AppModules`** wires up the resolver at construction time +4. **`AppModules`** wires up the resolver with Tor-aware socket factory and dynamic server selection ## Search Integration @@ -165,15 +202,20 @@ The search bar resolves Namecoin identifiers in real-time via `SearchBarViewMode This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results. -## Default ElectrumX Server +## Default ElectrumX Servers -``` -electrumx.testls.space:50002 (TLS, self-signed certificate) -``` +### Clearnet (Tor off) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary | +| `ulrichard.ch` | 50006 | Yes | Fallback | +| `nmc2.lelux.fi` | 50006 | Yes | Fallback | -- ElectrumX 1.16.0, Namecoin chain, protocol 1.4–1.4.3 -- Also available via Tor: `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002` -- Fallback servers: `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` (currently offline) +### Tor (Tor on for NIP-05) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` | +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) | The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`. @@ -184,38 +226,31 @@ The `trustAllCerts` flag is set for servers with self-signed certificates. Users - Both positive and negative results are cached - Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available -## UI Components - -### NamecoinVerificationDisplay -Shows a ⛓ chain-link badge next to profiles verified via Namecoin. Distinct from the standard NIP-05 checkmark — uses Namecoin blue (#4A90D9) and sea green (#2E8B57). - -### NamecoinSearchResult -Search bar integration. When a user types a `.bit` identifier, shows a loading state during resolution, then the resolved pubkey with a clickable profile link. - ## Security Considerations +- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing. - **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations. - **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height. - **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future. +- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse. ## Files Changed ### New files (quartz/) -- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client, scripthash-based name resolution -- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction +- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support +- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection - `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL - `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests ### New files (amethyst/) -- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, coroutine scope -- `amethyst/.../service/namecoin/Nip05NamecoinAdapter.kt` — Static bridge for NIP-05 hooks -- `amethyst/.../ui/note/namecoin/NamecoinVerificationDisplay.kt` — Compose UI components +- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient +- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor) ### Modified files -- `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider +- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation - `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution - `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver -- `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) ## Testing @@ -241,15 +276,20 @@ adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-deb | `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace | | `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace | +**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on: +1. Search for `m@testls.bit` — should resolve via onion server +2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`) +3. Toggle Tor off — next search should use clearnet servers + **Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP. **Network verification** — to confirm ElectrumX calls are being made: ```bash -# Monitor traffic to ElectrumX ports on the emulator adb root adb shell tcpdump -i any -nn port 50002 or port 50006 ``` -You should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space) when searching for `.bit` identifiers. +With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space). +With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections). ### Live test data The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 346e7bb8b8..4f61a1fb06 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -42,6 +42,7 @@ import java.net.InetSocketAddress import java.net.Socket import java.security.MessageDigest import java.util.concurrent.atomic.AtomicInteger +import javax.net.SocketFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager @@ -97,6 +98,7 @@ data class ElectrumxServer( class ElectrumxClient( private val connectTimeoutMs: Long = 10_000L, private val readTimeoutMs: Long = 15_000L, + private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() }, ) { private val json = Json { @@ -107,7 +109,7 @@ class ElectrumxClient( private val mutex = Mutex() companion object { - /** Well-known public Namecoin ElectrumX servers. */ + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_SERVERS = listOf( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), @@ -115,6 +117,18 @@ class ElectrumxClient( ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), ) + /** Tor-preferred server list: onion primary, clearnet fallback. */ + val TOR_SERVERS = + listOf( + ElectrumxServer( + "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", + 50002, + useSsl = true, + trustAllCerts = true, + ), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ) + private const val PROTOCOL_VERSION = "1.4" // Namecoin script opcodes @@ -155,10 +169,16 @@ class ElectrumxClient( } /** - * Try each default server in order until one succeeds. + * Try each server in order until one succeeds. + * + * @param identifier Full Namecoin name, e.g. "d/example" + * @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS] */ - suspend fun nameShowWithFallback(identifier: String): NameShowResult? { - for (server in DEFAULT_SERVERS) { + suspend fun nameShowWithFallback( + identifier: String, + servers: List = DEFAULT_SERVERS, + ): NameShowResult? { + for (server in servers) { val result = nameShow(identifier, server) if (result != null) return result } @@ -412,22 +432,25 @@ class ElectrumxClient( return data } - private fun createSocket(server: ElectrumxServer): Socket = - if (server.useSsl) { - val factory = - if (server.trustAllCerts) { - trustAllSslFactory() - } else { - SSLSocketFactory.getDefault() as SSLSocketFactory - } - factory.createSocket().apply { + private fun createSocket(server: ElectrumxServer): Socket { + // Create the base socket through the injected factory, which + // may route through a SOCKS proxy (e.g. Tor) if configured. + val baseSocket = + socketFactory().createSocket().apply { connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) } - } else { - Socket().apply { - connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + + if (!server.useSsl) return baseSocket + + // Upgrade to TLS over the already-connected (possibly proxied) socket. + val sslFactory = + if (server.trustAllCerts) { + trustAllSslFactory() + } else { + SSLSocketFactory.getDefault() as SSLSocketFactory } - } + return sslFactory.createSocket(baseSocket, server.host, server.port, true) + } /** * Create an SSLSocketFactory that accepts any certificate. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt index 7f1edbf92b..f0f7549c1d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt @@ -52,6 +52,7 @@ data class NamecoinNostrResult( class NamecoinNameResolver( private val electrumxClient: ElectrumxClient = ElectrumxClient(), private val lookupTimeoutMs: Long = 20_000L, + private val serverListProvider: () -> List = { ElectrumxClient.DEFAULT_SERVERS }, ) { private val json = Json { @@ -165,7 +166,7 @@ class NamecoinNameResolver( // ── Lookup & Value Parsing ───────────────────────────────────────── private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? { - val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null + val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null val valueJson = tryParseJson(nameResult.value) ?: return null return when (parsed.namespace) {