diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cf9256dc4..7bbcb490f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -161,12 +161,14 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val electrumXClient = + ElectrumXClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) + val namecoinResolver = NamecoinNameResolver( - electrumxClient = - ElectrumXClient( - socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, - ), + electrumxClient = electrumXClient, serverListProvider = { // User-configured custom servers take priority namecoinPrefs.customServersOrNull @@ -404,6 +406,18 @@ class AppModules( // Eagerly initialize OtsSharedPreferences off the main thread otsPrefs } + + // Load user-pinned ElectrumX certs from preferences into the client + applicationIOScope.launch { + try { + val pinnedCerts = namecoinPrefs.loadPinnedCerts() + if (pinnedCerts.isNotEmpty()) { + electrumXClient.setDynamicCerts(pinnedCerts) + } + } catch (_: Exception) { + // Non-fatal — defaults will still work + } + } } fun terminate(appContext: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 600cecaa9..7319aa78e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -58,6 +58,7 @@ class NamecoinSharedPreferences( companion object { val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts") } /** @@ -99,8 +100,48 @@ class NamecoinSharedPreferences( suspend fun reset() { persist(NamecoinSettings.DEFAULT) + clearPinnedCerts() } + /** + * Store a PEM-encoded certificate that the user accepted via Test Connection. + * The cert is appended to the existing list and synced to the ElectrumXClient. + */ + suspend fun addPinnedCert(pem: String) { + val existing = loadPinnedCertsFromDisk() + val updated = (existing + pem).distinct() + savePinnedCerts(updated) + } + + /** Load all user-pinned certs from disk (for startup sync). */ + suspend fun loadPinnedCerts(): List = loadPinnedCertsFromDisk() + + private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList()) + + private suspend fun savePinnedCerts(certs: List) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing pinned certs: ${e.message}") + } + } + + private suspend fun loadPinnedCertsFromDisk(): List = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val certsJson = prefs[KEY_PINNED_CERTS] + if (certsJson != null) { + json.decodeFromString>(certsJson) + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + // ── Internal ─────────────────────────────────────────────────────── private suspend fun persist(settings: NamecoinSettings) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 4855b493f..1a859848c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -66,7 +66,7 @@ data class NamecoinSettings( * TLS is the default protocol. Append `:tcp` for plaintext * (useful for `.onion` addresses and local servers). * - * `.onion` addresses automatically get `trustAllCerts = true` + * `.onion` addresses automatically get `usePinnedTrustStore = true` * since certificate verification is meaningless over Tor. */ fun parseServerString(s: String): ElectrumxServer? { @@ -77,11 +77,15 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") + // All custom servers use the pinned trust store. ElectrumX + // servers almost universally use self-signed certs, so we + // route them through our pinned SSLSocketFactory (hardcoded + // defaults + TOFU-pinned certs + system CAs). return ElectrumxServer( host = host, port = port, useSsl = useSsl, - trustAllCerts = isOnion || !useSsl, + usePinnedTrustStore = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 926bce501..51f7ee312 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -198,7 +198,7 @@ fun AppNavigation( composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } - composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } + composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, Amethyst.instance.electrumXClient, nav) } composableFromEnd { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4fc579daa..4adc385d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -82,20 +82,27 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { - if (prefix.contains('@')) { + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (prefix.contains('@')) { + Nip05Id.parse(prefix) + } else if (prefix.endsWith(".bit", ignoreCase = true)) { + Nip05Id("_", prefix.lowercase()) + } else { + null + } + if (nip05 != null) { runCatching { - Nip05Id.parse(prefix)?.let { nip05 -> - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - user } + user } }.getOrNull() } else if (prefix.startsWithAny(userUriPrefixes)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index d29bb7037..02045f705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -95,20 +95,28 @@ class SearchBarViewModel( searchTerm .debounce(400) .mapLatest { term -> - if (term.contains('@')) { + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (term.contains('@')) { + Nip05Id.parse(term) + } else if (term.endsWith(".bit", ignoreCase = true)) { + // Bare .bit domain → synthesize _@domain.bit + Nip05Id("_", term.lowercase()) + } else { + null + } + if (nip05 != null) { runCatching { - Nip05Id.parse(term)?.let { nip05 -> - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - user } + user } }.getOrNull() } else if (term.startsWithAny(userUriPrefixes)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 870570194..d5ca59881 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -38,12 +38,14 @@ import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun NamecoinSettingsScreen( namecoinPrefs: NamecoinSharedPreferences, + electrumXClient: ElectrumXClient, nav: INav, ) { val namecoinSettings by namecoinPrefs.settings.collectAsState() @@ -75,6 +77,13 @@ fun NamecoinSettingsScreen( onReset = { scope.launch { namecoinPrefs.reset() } }, + onTestServer = { server -> electrumXClient.testServer(server) }, + onPinCert = { pem -> + scope.launch { + namecoinPrefs.addPinnedCert(pem) + electrumXClient.addPinnedCert(pem) + } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index db747e138..5db51d692 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -41,6 +42,11 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,20 +59,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable 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.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -79,6 +94,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S * @param onAddServer Called with `host:port[:tcp]` when user adds a server * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults + * @param onTestServer Suspend function to test a single server + * @param onPinCert Called with PEM string to persist a TOFU-pinned cert */ @Composable fun NamecoinSettingsSection( @@ -87,6 +104,8 @@ fun NamecoinSettingsSection( onAddServer: (String) -> Unit, onRemoveServer: (String) -> Unit, onReset: () -> Unit, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier.padding(16.dp)) { @@ -150,12 +169,366 @@ fun NamecoinSettingsSection( } } } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + + // ── Test Connection ──────────────────────────────── + TestConnectionSection( + settings = settings, + onTestServer = onTestServer, + onPinCert = onPinCert, + ) } } } } -// ── Sub-composables ──────────────────────────────────────────────────── +// ── Test Connection ──────────────────────────────────────────────────── + +/** + * Holds a cert pending user confirmation before pinning (TOFU). + */ +private data class PendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + +@Composable +private fun TestConnectionSection( + settings: NamecoinSettings, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var isTesting by remember { mutableStateOf(false) } + var testResults by remember { mutableStateOf>(emptyList()) } + var lastTestTimestamp by remember { mutableStateOf(null) } + // Certs discovered during testing that need user confirmation + var pendingCerts by remember { mutableStateOf>(emptyList()) } + // Which cert is currently shown in the confirmation dialog + var confirmingCert by remember { mutableStateOf(null) } + + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + + // ── Cert confirmation dialog ─────────────────────────────── + confirmingCert?.let { pending -> + AlertDialog( + onDismissRequest = { + // Remove from pending list and move to next (or close) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }, + title = { Text(stringResource(R.string.namecoin_pin_cert_title)) }, + text = { + Column { + Text( + stringResource(R.string.namecoin_pin_cert_body, pending.serverHost), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pending.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pending.pem) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_accept)) + } + }, + dismissButton = { + TextButton(onClick = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_reject)) + } + }, + ) + } + + Column { + // ── Test button ──────────────────────────────────────── + Button( + onClick = { + if (!isTesting) { + isTesting = true + testResults = emptyList() + pendingCerts = emptyList() + scope.launch { + val results = mutableListOf() + val newCerts = mutableListOf() + for (server in servers) { + val result = onTestServer(server) + results.add(result) + testResults = results.toList() + // Collect certs for user confirmation (not auto-pinned) + val pem = result.serverCertPem + val fp = result.certFingerprint + if (result.success && pem != null && fp != null) { + newCerts.add( + PendingCertPin( + serverHost = "${server.host}:${server.port}", + fingerprint = fp, + pem = pem, + ), + ) + } + } + lastTestTimestamp = System.currentTimeMillis() + isTesting = false + // Show confirmation dialog for each new cert + if (newCerts.isNotEmpty()) { + pendingCerts = newCerts + confirmingCert = newCerts.first() + } + } + } + }, + enabled = !isTesting, + modifier = Modifier.fillMaxWidth(), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.namecoin_testing)) + } else { + Text(stringResource(R.string.namecoin_test_connection)) + } + } + + // ── Per-server results ───────────────────────────────── + if (testResults.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + + Text( + stringResource(R.string.namecoin_test_results), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + + testResults.forEach { result -> + ServerTestResultRow(result) + } + + if (isTesting && testResults.size < servers.size) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Testing next server…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // ── Diagnostic card ──────────────────────────────────── + if (testResults.isNotEmpty() || lastTestTimestamp != null) { + Spacer(Modifier.height(16.dp)) + DiagnosticCard( + testResults = testResults, + lastTestTimestamp = lastTestTimestamp, + ) + } + } +} + +@Composable +private fun ServerTestResultRow(result: ServerTestResult) { + val serverLabel = "${result.server.host}:${result.server.port}" + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (result.success) "✅" else "❌", + fontSize = 14.sp, + modifier = Modifier.padding(end = 6.dp, top = 1.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = serverLabel, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.namecoin_response_time, result.responseTimeMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (result.success) { + Text( + text = stringResource(R.string.namecoin_test_success), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF2E8B57), + ) + val fp = result.certFingerprint + if (fp != null) { + Text( + text = "Cert: ${fp.take(23)}…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + val errorText = result.error + if (errorText != null) { + Text( + text = errorText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── Diagnostic Card ──────────────────────────────────────────────────── + +@Composable +private fun DiagnosticCard( + testResults: List, + lastTestTimestamp: Long?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + stringResource(R.string.namecoin_diagnostics), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + + // Last test timestamp + if (lastTestTimestamp != null) { + val formatted = + remember(lastTestTimestamp) { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + .format(Date(lastTestTimestamp)) + } + val successCount = testResults.count { it.success } + val totalCount = testResults.size + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = "$formatted ($successCount/$totalCount OK)", + ) + } else { + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = stringResource(R.string.namecoin_no_test_yet), + ) + } + + Spacer(Modifier.height(4.dp)) + + // Device info + DiagnosticRow( + label = stringResource(R.string.namecoin_device_info), + value = "${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + ) + + Spacer(Modifier.height(4.dp)) + + // TLS info from test results + val tlsVersions = + testResults + .mapNotNull { it.tlsVersion } + .distinct() + val tlsDisplay = + if (tlsVersions.isNotEmpty()) { + tlsVersions.joinToString(", ") + } else { + "—" + } + DiagnosticRow( + label = stringResource(R.string.namecoin_tls_info), + value = tlsDisplay, + ) + } + } +} + +@Composable +private fun DiagnosticRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.35f), + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(0.65f), + ) + } +} + +// ── Original Sub-composables ─────────────────────────────────────────── @Composable private fun SectionHeader( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 03022e303..7c4e50619 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1827,7 +1827,7 @@ Select Users to Follow Profile to import from search, npub1…, alice@example.com - Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/) + Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit) Look Up Follow List Tip %1$d accounts found @@ -1849,6 +1849,21 @@ Select All %1$d%% uptime Namecoin Settings + Test Connection + Testing servers… + Connected + Failed + Test Results + Diagnostics + Last test + Device Info + TLS Info + No test run yet + %dms + Trust Server Certificate? + The server %1$s presented a certificate not yet in your trust store. Verify the fingerprint below matches what the server operator published, then choose whether to trust it for future connections. + Trust + Reject Relay Sync Relay Sync Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt index 4eb495af6..661caca6e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -56,7 +56,7 @@ class NamecoinSettingsTest { assertEquals("abc123def.onion", s!!.host) assertEquals(50001, s.port) assertFalse(s.useSsl) - assertTrue(s.trustAllCerts) + assertTrue(s.usePinnedTrustStore) } @Test @@ -131,7 +131,7 @@ class NamecoinSettingsTest { assertTrue(servers[0].useSsl) assertEquals("server2.onion", servers[1].host) assertFalse(servers[1].useSsl) - assertTrue(servers[1].trustAllCerts) + assertTrue(servers[1].usePinnedTrustStore) } @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index f31edfe7d..4c6c96937 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -44,8 +44,14 @@ data class ElectrumxServer( val host: String, val port: Int, val useSsl: Boolean = true, - /** If true, accept any certificate (self-signed, expired, etc.) */ - val trustAllCerts: Boolean = false, + /** + * If true, use the pinned trust store (hardcoded + TOFU-pinned certs + * plus system CAs) instead of the default system-only trust store. + * + * Required for ElectrumX servers that use self-signed certificates, + * which is the norm for the Namecoin ElectrumX ecosystem. + */ + val usePinnedTrustStore: Boolean = false, ) /** @@ -72,12 +78,27 @@ sealed class NamecoinLookupException( ) : NamecoinLookupException("All ElectrumX servers unreachable", lastError) } +/** + * Result of testing connectivity to a single ElectrumX server. + */ +data class ServerTestResult( + val server: ElectrumxServer, + val success: Boolean, + val responseTimeMs: Long, + val error: String? = null, + val tlsVersion: String? = null, + /** PEM-encoded server certificate, captured during test for TOFU pinning. */ + val serverCertPem: String? = null, + /** SHA-256 fingerprint of the server certificate. */ + val certFingerprint: String? = null, +) + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_ELECTRUMX_SERVERS = listOf( - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), - ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true), ) /** Tor-preferred server list: onion primary, clearnet fallback. */ @@ -87,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS = "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", 50002, useSsl = true, - trustAllCerts = true, + usePinnedTrustStore = true, ), - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), ) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index b0ba1d6b7..f8e0e3152 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -38,19 +38,22 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import java.io.BufferedReader +import java.io.ByteArrayInputStream import java.io.InputStreamReader import java.io.PrintWriter import java.net.InetSocketAddress import java.net.Socket +import java.security.KeyStore import java.security.MessageDigest import java.security.SecureRandom -import java.security.cert.X509Certificate +import java.security.cert.CertificateFactory import java.util.concurrent.ConcurrentHashMap 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 +import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager /** @@ -87,25 +90,6 @@ class ElectrumXClient( private val requestId = AtomicInteger(0) private val serverMutexes = ConcurrentHashMap() - companion object { - private const val PROTOCOL_VERSION = "1.4" - - /** - * Namecoin names expire this many blocks after their last update. - * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 - * (~250 days at ~10 min/block). - */ - const val NAME_EXPIRE_DEPTH = 36_000 - - // Namecoin script opcodes - private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin - private const val OP_2DROP: Byte = 0x6d - private const val OP_DROP: Byte = 0x75 - private const val OP_RETURN: Byte = 0x6a - private const val OP_PUSHDATA1: Byte = 0x4c - private const val OP_PUSHDATA2: Byte = 0x4d - } - /** * Perform a name_show lookup against the given ElectrumX server. * @@ -167,6 +151,162 @@ class ElectrumXClient( throw NamecoinLookupException.ServersUnreachable(lastError) } + /** + * Test connectivity to a single ElectrumX server. + * + * Connects, negotiates protocol version, and optionally resolves a test + * name. Returns detailed results including response time, TLS version, + * and human-readable error messages. + * + * @param server The server to test + * @param testName Optional name to resolve (e.g. "d/testls") + * @return [ServerTestResult] with success/failure details + */ + suspend fun testServer( + server: ElectrumxServer, + testName: String? = "d/testls", + ): ServerTestResult = + withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + try { + val socket = createSocket(server) + socket.soTimeout = readTimeoutMs.toInt() + + var tlsVersion: String? = null + var serverCertPem: String? = null + var certFingerprint: String? = null + + if (socket is javax.net.ssl.SSLSocket) { + tlsVersion = socket.session.protocol + // Capture the server's leaf certificate for TOFU pinning + try { + val peerCerts = socket.session.peerCertificates + if (peerCerts.isNotEmpty() && peerCerts[0] is java.security.cert.X509Certificate) { + val x509 = peerCerts[0] as java.security.cert.X509Certificate + // PEM encode + val encoded = + java.util.Base64 + .getMimeEncoder(76, "\n".toByteArray()) + .encodeToString(x509.encoded) + serverCertPem = "-----BEGIN CERTIFICATE-----\n$encoded-----END CERTIFICATE-----" + // SHA-256 fingerprint + val digest = MessageDigest.getInstance("SHA-256").digest(x509.encoded) + certFingerprint = digest.joinToString(":") { "%02X".format(it) } + } + } catch (_: Exception) { + // Non-fatal — cert capture is best-effort + } + } + + val writer = PrintWriter(socket.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + + try { + // Negotiate protocol version + val versionReq = + buildRpcRequest( + "server.version", + listOf("AmethystNMC/0.1", PROTOCOL_VERSION), + ) + writer.println(versionReq) + val versionResponse = + reader.readLine() + ?: return@withContext ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Server returned empty response", + tlsVersion = tlsVersion, + ) + + // If a test name is provided, try to resolve it + if (testName != null) { + val nameScript = + buildNameIndexScript(testName.toByteArray(Charsets.US_ASCII)) + val scriptHash = electrumScriptHash(nameScript) + val historyReq = + buildRpcRequest( + "blockchain.scripthash.get_history", + listOf(scriptHash), + ) + writer.println(historyReq) + reader.readLine() // consume response + } + + val elapsed = System.currentTimeMillis() - startTime + ServerTestResult( + server = server, + success = true, + responseTimeMs = elapsed, + tlsVersion = tlsVersion, + serverCertPem = serverCertPem, + certFingerprint = certFingerprint, + ) + } finally { + runCatching { writer.close() } + runCatching { reader.close() } + runCatching { socket.close() } + } + } catch (e: java.net.ConnectException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Connection refused", + ) + } catch (e: java.net.SocketTimeoutException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Connection timed out after ${connectTimeoutMs / 1000}s", + ) + } catch (e: java.net.UnknownHostException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Server unreachable (DNS resolution failed)", + ) + } catch (e: javax.net.ssl.SSLHandshakeException) { + val detail = + if (e.message?.contains("self-signed", ignoreCase = true) == true || + e.message?.contains("anchor", ignoreCase = true) == true + ) { + "TLS handshake failed (self-signed certificate rejected)" + } else { + "TLS handshake failed: ${e.message?.take(100) ?: "unknown error"}" + } + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = detail, + ) + } catch (e: javax.net.ssl.SSLException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "TLS error: ${e.message?.take(100) ?: "unknown"}", + ) + } catch (e: java.io.IOException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "I/O error: ${e.message?.take(100) ?: "unknown"}", + ) + } catch (e: Exception) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = e.message?.take(150) ?: "Unknown error", + ) + } + } + // ── internals ────────────────────────────────────────────────────── private fun connectAndQuery( @@ -461,41 +601,298 @@ class ElectrumXClient( if (!server.useSsl) return baseSocket // Upgrade to TLS over the already-connected (possibly proxied) socket. + // When usePinnedTrustStore is set, we use a pinned trust store that + // contains the known ElectrumX server certs plus system CAs. This is + // required because Samsung One UI 7 (Android 16) and GrapheneOS + // reject connections that use a no-op "trust-all" X509TrustManager. val sslFactory = - if (server.trustAllCerts) { - trustAllSslFactory() + if (server.host.endsWith(".onion")) { + // .onion addresses: prefer the pinned factory (the .onion server's + // cert is already in PINNED_ELECTRUMX_CERTS). Fall back to trust-all + // only if the pinned handshake fails — this keeps compatibility with + // .onion servers whose certs aren't pinned yet, while avoiding a + // blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung + // Knox) may reject at the Conscrypt/BoringSSL layer. + cachedPinnedSslFactory() + } else if (server.usePinnedTrustStore) { + cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory } - return sslFactory.createSocket(baseSocket, server.host, server.port, true) + val sslSocket: Socket + try { + sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + } catch (e: javax.net.ssl.SSLHandshakeException) { + if (server.host.endsWith(".onion")) { + // Pinned factory failed for .onion — fall back to trust-all. + // This is safe: Tor provides E2E authentication via the onion + // address, and the proxied socket bypasses Knox/GrapheneOS + // trust-all rejection in practice. + val fallbackSocket = onionSslFactory().createSocket(baseSocket, server.host, server.port, true) + if (fallbackSocket is javax.net.ssl.SSLSocket) { + val supported = fallbackSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + fallbackSocket.enabledProtocols = modern.toTypedArray() + } + } + return fallbackSocket + } + throw e + } + + // Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS) + // may negotiate TLS 1.0/1.1 by default for raw socket upgrades. + if (sslSocket is javax.net.ssl.SSLSocket) { + val supported = sslSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + sslSocket.enabledProtocols = modern.toTypedArray() + } + } + + return sslSocket } /** - * Create an SSLSocketFactory that accepts any certificate. - * Used for servers with self-signed certificates. + * Fallback SSLSocketFactory for .onion addresses when the pinned + * factory fails (e.g. cert rotated, unknown .onion server). + * + * Only used as a last resort after cachedPinnedSslFactory() throws + * SSLHandshakeException. This is safe because: + * 1. The connection is already end-to-end encrypted by Tor. + * 2. The onion address IS the server's identity proof (public key hash). + * 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all + * rejection (Samsung Knox, GrapheneOS hardened Conscrypt). + * + * Note: GrapheneOS or future Android versions may reject trust-all + * TrustManagers even for proxied sockets. If this fallback stops + * working, the .onion server's cert should be added to + * PINNED_ELECTRUMX_CERTS (it's already there for the known server). */ - private fun trustAllSslFactory(): SSLSocketFactory { - val trustAllCerts = + private fun onionSslFactory(): SSLSocketFactory { + val trustAll = arrayOf( object : X509TrustManager { override fun checkClientTrusted( - chain: Array, + chain: Array, authType: String, ) {} override fun checkServerTrusted( - chain: Array, + chain: Array, authType: String, ) {} - override fun getAcceptedIssuers(): Array = arrayOf() + override fun getAcceptedIssuers(): Array = arrayOf() }, ) - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, trustAllCerts, SecureRandom()) + val ctx = + try { + SSLContext.getInstance("TLSv1.2") + } catch (_: Exception) { + SSLContext.getInstance("TLS") + } + ctx.init(null, trustAll, SecureRandom()) + return ctx.socketFactory + } + + /** User-supplied PEM certificates for custom servers (TOFU-pinned). */ + private val dynamicCerts = mutableListOf() + + /** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */ + @Volatile + private var pinnedFactory: SSLSocketFactory? = null + + private fun cachedPinnedSslFactory(): SSLSocketFactory { + pinnedFactory?.let { return it } + synchronized(this) { + pinnedFactory?.let { return it } + return buildPinnedSslFactory().also { pinnedFactory = it } + } + } + + /** + * Add a PEM-encoded certificate to the dynamic trust store. + * Typically called after the user confirms a cert fingerprint via + * the "Test Connection" flow in settings. + * + * Invalidates the cached factory so the next connection picks it up. + */ + fun addPinnedCert(pem: String) { + synchronized(this) { + dynamicCerts.add(pem) + pinnedFactory = null // force rebuild + } + } + + /** + * Replace all dynamic certs (e.g. loaded from preferences on startup). + */ + fun setDynamicCerts(pems: List) { + synchronized(this) { + dynamicCerts.clear() + dynamicCerts.addAll(pems) + pinnedFactory = null + } + } + + /** + * Build an SSLSocketFactory that trusts the pinned ElectrumX server + * certificates plus the system CA store. + * + * Previous versions used a "trust-all" TrustManager, but Samsung + * devices running One UI 7 (Android 16) silently reject connections + * that use a no-op X509TrustManager. Pinning the known self-signed + * certs avoids this while maintaining security. + * + * Also handles OEM-specific quirks: + * - Xiaomi MIUI/HyperOS: KeyStore.getDefaultType() may return unexpected + * types; we try the default first, then fall back to "PKCS12". + * - OnePlus ColorOS: some versions require explicit TLSv1.2 protocol. + * - All OEMs: SSLContext("TLSv1.2") is preferred over ("TLS") which may + * resolve to TLS 1.0 on older Conscrypt forks. + */ + private fun buildPinnedSslFactory(): SSLSocketFactory { + val ks = + try { + KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } + } catch (_: Exception) { + // Fallback for Xiaomi devices where getDefaultType() returns an unsupported type + KeyStore.getInstance("PKCS12").apply { load(null, null) } + } + + val cf = CertificateFactory.getInstance("X.509") + + // Load hardcoded + dynamic pinned certificates into the keystore + val allCerts = PINNED_ELECTRUMX_CERTS + dynamicCerts + for ((index, pem) in allCerts.withIndex()) { + try { + val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII))) + ks.setCertificateEntry("electrumx_$index", cert) + } catch (_: Exception) { + // Skip malformed certs — the remaining ones may still work + } + } + + // Also load system CA certificates so that servers with real certs work too + val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + systemTmf.init(null as KeyStore?) // null = system default + val systemTm = systemTmf.trustManagers.filterIsInstance().firstOrNull() + if (systemTm != null) { + for ((index, issuer) in systemTm.acceptedIssuers.withIndex()) { + try { + ks.setCertificateEntry("system_$index", issuer) + } catch (_: Exception) { + // Some OEMs return certs that can't be re-inserted; skip + } + } + } + + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(ks) + + // Prefer TLSv1.2 explicitly — SSLContext.getInstance("TLS") can resolve + // to TLS 1.0 on some OEM Conscrypt forks (Xiaomi, OnePlus). + val sslContext = + try { + SSLContext.getInstance("TLSv1.2") + } catch (_: Exception) { + SSLContext.getInstance("TLS") + } + sslContext.init(null, tmf.trustManagers, SecureRandom()) return sslContext.socketFactory } + companion object { + private const val PROTOCOL_VERSION = "1.4" + + /** + * Namecoin names expire this many blocks after their last update. + * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 + * (~250 days at ~10 min/block). + */ + const val NAME_EXPIRE_DEPTH = 36_000 + + // Namecoin script opcodes + private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin + private const val OP_2DROP: Byte = 0x6d + private const val OP_DROP: Byte = 0x75 + private const val OP_RETURN: Byte = 0x6a + private const val OP_PUSHDATA1: Byte = 0x4c + private const val OP_PUSHDATA2: Byte = 0x4d + + /** + * PEM-encoded certificates for the well-known Namecoin ElectrumX servers. + * + * These are self-signed certificates that cannot be verified by the + * system CA store. We pin them explicitly so that connections succeed + * on devices with strict TLS enforcement (e.g. Samsung One UI 7). + * + * To update: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM` + * For .onion: `python3 -c "import socks,ssl,socket,base64; s=socks.socksocket(); s.set_proxy(socks.SOCKS5,'127.0.0.1',9050); s.connect(('HOST',PORT)); ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE; ss=ctx.wrap_socket(s); print(base64.encodebytes(ss.getpeercert(True)).decode())"` + */ + private val PINNED_ELECTRUMX_CERTS = + listOf( + // electrumx.testls.space:50002 — expires 2027-05-04 + // Also covers the .onion hidden service (same operator, same cert): + // i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002 + // SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:A0:EA:7A:99:2D:F7:0F:05:7E:9B:CD:50:36:C7:79:9C + """ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H ++PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G +1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp +P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG +PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t +YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp +yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb +nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K +fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc +MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI +sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+ +HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O +2ZIVwmtnyA== +-----END CERTIFICATE----- + """.trimIndent(), + // nmc2.bitcoins.sk:57002 / 46.229.238.187:57002 — expires 2030-10-22 + """ +-----BEGIN CERTIFICATE----- +MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL +BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK +QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu +Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn +MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL +MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE +CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq +hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE +c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1 +vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW +zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn +cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y +6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E +FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe +WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX +wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ +NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc +QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083 +VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73 +bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf +5W7ix41hRvdT6cTAJA== +-----END CERTIFICATE----- + """.trimIndent(), + ) + } + private fun buildRpcRequest( method: String, params: List,