Merge pull request #1937 from mstrofnone/fix/samsung-oneui7-namecoin-tls
fix: pin ElectrumX server certs for Samsung One UI 7 (Android 16) compatibility
This commit is contained in:
@@ -161,12 +161,14 @@ class AppModules(
|
|||||||
// Custom fetcher that considers tor settings and avoids forwarding.
|
// Custom fetcher that considers tor settings and avoids forwarding.
|
||||||
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
||||||
|
|
||||||
|
val electrumXClient =
|
||||||
|
ElectrumXClient(
|
||||||
|
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||||
|
)
|
||||||
|
|
||||||
val namecoinResolver =
|
val namecoinResolver =
|
||||||
NamecoinNameResolver(
|
NamecoinNameResolver(
|
||||||
electrumxClient =
|
electrumxClient = electrumXClient,
|
||||||
ElectrumXClient(
|
|
||||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
|
||||||
),
|
|
||||||
serverListProvider = {
|
serverListProvider = {
|
||||||
// User-configured custom servers take priority
|
// User-configured custom servers take priority
|
||||||
namecoinPrefs.customServersOrNull
|
namecoinPrefs.customServersOrNull
|
||||||
@@ -404,6 +406,18 @@ class AppModules(
|
|||||||
// Eagerly initialize OtsSharedPreferences off the main thread
|
// Eagerly initialize OtsSharedPreferences off the main thread
|
||||||
otsPrefs
|
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) {
|
fun terminate(appContext: Context) {
|
||||||
|
|||||||
+41
@@ -58,6 +58,7 @@ class NamecoinSharedPreferences(
|
|||||||
companion object {
|
companion object {
|
||||||
val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled")
|
val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled")
|
||||||
val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers")
|
val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers")
|
||||||
|
val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,8 +100,48 @@ class NamecoinSharedPreferences(
|
|||||||
|
|
||||||
suspend fun reset() {
|
suspend fun reset() {
|
||||||
persist(NamecoinSettings.DEFAULT)
|
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<String> = loadPinnedCertsFromDisk()
|
||||||
|
|
||||||
|
private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList())
|
||||||
|
|
||||||
|
private suspend fun savePinnedCerts(certs: List<String>) {
|
||||||
|
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<String> =
|
||||||
|
try {
|
||||||
|
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||||
|
val certsJson = prefs[KEY_PINNED_CERTS]
|
||||||
|
if (certsJson != null) {
|
||||||
|
json.decodeFromString<List<String>>(certsJson)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ───────────────────────────────────────────────────────
|
// ── Internal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private suspend fun persist(settings: NamecoinSettings) {
|
private suspend fun persist(settings: NamecoinSettings) {
|
||||||
|
|||||||
+6
-2
@@ -66,7 +66,7 @@ data class NamecoinSettings(
|
|||||||
* TLS is the default protocol. Append `:tcp` for plaintext
|
* TLS is the default protocol. Append `:tcp` for plaintext
|
||||||
* (useful for `.onion` addresses and local servers).
|
* (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.
|
* since certificate verification is meaningless over Tor.
|
||||||
*/
|
*/
|
||||||
fun parseServerString(s: String): ElectrumxServer? {
|
fun parseServerString(s: String): ElectrumxServer? {
|
||||||
@@ -77,11 +77,15 @@ data class NamecoinSettings(
|
|||||||
if (host.isEmpty() || port <= 0 || port > 65535) return null
|
if (host.isEmpty() || port <= 0 || port > 65535) return null
|
||||||
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
|
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
|
||||||
val isOnion = host.endsWith(".onion")
|
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(
|
return ElectrumxServer(
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
useSsl = useSsl,
|
useSsl = useSsl,
|
||||||
trustAllCerts = isOnion || !useSsl,
|
usePinnedTrustStore = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ fun AppNavigation(
|
|||||||
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
|
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
|
||||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
|
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, Amethyst.instance.electrumXClient, nav) }
|
||||||
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
|
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
|
||||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||||
|
|||||||
+17
-10
@@ -82,20 +82,27 @@ class UserSuggestionState(
|
|||||||
.map(::userSearchTermOrNull)
|
.map(::userSearchTermOrNull)
|
||||||
.map { prefix ->
|
.map { prefix ->
|
||||||
if (prefix != null) {
|
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 {
|
runCatching {
|
||||||
Nip05Id.parse(prefix)?.let { nip05 ->
|
nip05Client.get(nip05)?.let { info ->
|
||||||
nip05Client.get(nip05)?.let { info ->
|
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
||||||
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
if (user != null) {
|
||||||
if (user != null) {
|
info.relays.forEach {
|
||||||
info.relays.forEach {
|
it.normalizeRelayUrlOrNull()?.let { relay ->
|
||||||
it.normalizeRelayUrlOrNull()?.let { relay ->
|
account.cache.relayHints.addKey(user.pubkey(), relay)
|
||||||
account.cache.relayHints.addKey(user.pubkey(), relay)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user
|
|
||||||
}
|
}
|
||||||
|
user
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
} else if (prefix.startsWithAny(userUriPrefixes)) {
|
} else if (prefix.startsWithAny(userUriPrefixes)) {
|
||||||
|
|||||||
+18
-10
@@ -95,20 +95,28 @@ class SearchBarViewModel(
|
|||||||
searchTerm
|
searchTerm
|
||||||
.debounce(400)
|
.debounce(400)
|
||||||
.mapLatest { term ->
|
.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 {
|
runCatching {
|
||||||
Nip05Id.parse(term)?.let { nip05 ->
|
nip05Client.get(nip05)?.let { info ->
|
||||||
nip05Client.get(nip05)?.let { info ->
|
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
||||||
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
if (user != null) {
|
||||||
if (user != null) {
|
info.relays.forEach {
|
||||||
info.relays.forEach {
|
it.normalizeRelayUrlOrNull()?.let { relay ->
|
||||||
it.normalizeRelayUrlOrNull()?.let { relay ->
|
account.cache.relayHints.addKey(user.pubkey(), relay)
|
||||||
account.cache.relayHints.addKey(user.pubkey(), relay)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user
|
|
||||||
}
|
}
|
||||||
|
user
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
} else if (term.startsWithAny(userUriPrefixes)) {
|
} else if (term.startsWithAny(userUriPrefixes)) {
|
||||||
|
|||||||
+9
@@ -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.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NamecoinSettingsScreen(
|
fun NamecoinSettingsScreen(
|
||||||
namecoinPrefs: NamecoinSharedPreferences,
|
namecoinPrefs: NamecoinSharedPreferences,
|
||||||
|
electrumXClient: ElectrumXClient,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val namecoinSettings by namecoinPrefs.settings.collectAsState()
|
val namecoinSettings by namecoinPrefs.settings.collectAsState()
|
||||||
@@ -75,6 +77,13 @@ fun NamecoinSettingsScreen(
|
|||||||
onReset = {
|
onReset = {
|
||||||
scope.launch { namecoinPrefs.reset() }
|
scope.launch { namecoinPrefs.reset() }
|
||||||
},
|
},
|
||||||
|
onTestServer = { server -> electrumXClient.testServer(server) },
|
||||||
|
onPinCert = { pem ->
|
||||||
|
scope.launch {
|
||||||
|
namecoinPrefs.addPinnedCert(pem)
|
||||||
|
electrumXClient.addPinnedCert(pem)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+374
-1
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
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.Close
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -53,20 +59,29 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
|
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
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.
|
* 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 onAddServer Called with `host:port[:tcp]` when user adds a server
|
||||||
* @param onRemoveServer Called with the server string to remove
|
* @param onRemoveServer Called with the server string to remove
|
||||||
* @param onReset Called when user resets to defaults
|
* @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
|
@Composable
|
||||||
fun NamecoinSettingsSection(
|
fun NamecoinSettingsSection(
|
||||||
@@ -87,6 +104,8 @@ fun NamecoinSettingsSection(
|
|||||||
onAddServer: (String) -> Unit,
|
onAddServer: (String) -> Unit,
|
||||||
onRemoveServer: (String) -> Unit,
|
onRemoveServer: (String) -> Unit,
|
||||||
onReset: () -> Unit,
|
onReset: () -> Unit,
|
||||||
|
onTestServer: suspend (ElectrumxServer) -> ServerTestResult,
|
||||||
|
onPinCert: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.padding(16.dp)) {
|
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<List<ServerTestResult>>(emptyList()) }
|
||||||
|
var lastTestTimestamp by remember { mutableStateOf<Long?>(null) }
|
||||||
|
// Certs discovered during testing that need user confirmation
|
||||||
|
var pendingCerts by remember { mutableStateOf<List<PendingCertPin>>(emptyList()) }
|
||||||
|
// Which cert is currently shown in the confirmation dialog
|
||||||
|
var confirmingCert by remember { mutableStateOf<PendingCertPin?>(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<ServerTestResult>()
|
||||||
|
val newCerts = mutableListOf<PendingCertPin>()
|
||||||
|
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<ServerTestResult>,
|
||||||
|
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
|
@Composable
|
||||||
private fun SectionHeader(
|
private fun SectionHeader(
|
||||||
|
|||||||
@@ -1827,7 +1827,7 @@
|
|||||||
<string name="select_users_to_follow">Select Users to Follow</string>
|
<string name="select_users_to_follow">Select Users to Follow</string>
|
||||||
<string name="profile_to_import_from">Profile to import from</string>
|
<string name="profile_to_import_from">Profile to import from</string>
|
||||||
<string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string>
|
<string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string>
|
||||||
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/)</string>
|
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit)</string>
|
||||||
<string name="look_up_follow_list">Look Up Follow List</string>
|
<string name="look_up_follow_list">Look Up Follow List</string>
|
||||||
<string name="tip">Tip</string>
|
<string name="tip">Tip</string>
|
||||||
<string name="accounts_found">%1$d accounts found</string>
|
<string name="accounts_found">%1$d accounts found</string>
|
||||||
@@ -1849,6 +1849,21 @@
|
|||||||
<string name="select_all">Select All</string>
|
<string name="select_all">Select All</string>
|
||||||
<string name="uptime">%1$d%% uptime</string>
|
<string name="uptime">%1$d%% uptime</string>
|
||||||
<string name="namecoin_settings">Namecoin Settings</string>
|
<string name="namecoin_settings">Namecoin Settings</string>
|
||||||
|
<string name="namecoin_test_connection">Test Connection</string>
|
||||||
|
<string name="namecoin_testing">Testing servers…</string>
|
||||||
|
<string name="namecoin_test_success">Connected</string>
|
||||||
|
<string name="namecoin_test_failed">Failed</string>
|
||||||
|
<string name="namecoin_test_results">Test Results</string>
|
||||||
|
<string name="namecoin_diagnostics">Diagnostics</string>
|
||||||
|
<string name="namecoin_last_test">Last test</string>
|
||||||
|
<string name="namecoin_device_info">Device Info</string>
|
||||||
|
<string name="namecoin_tls_info">TLS Info</string>
|
||||||
|
<string name="namecoin_no_test_yet">No test run yet</string>
|
||||||
|
<string name="namecoin_response_time">%dms</string>
|
||||||
|
<string name="namecoin_pin_cert_title">Trust Server Certificate?</string>
|
||||||
|
<string name="namecoin_pin_cert_body">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.</string>
|
||||||
|
<string name="namecoin_pin_cert_accept">Trust</string>
|
||||||
|
<string name="namecoin_pin_cert_reject">Reject</string>
|
||||||
<string name="event_sync_title">Relay Sync</string>
|
<string name="event_sync_title">Relay Sync</string>
|
||||||
<string name="event_sync_section">Relay Sync</string>
|
<string name="event_sync_section">Relay Sync</string>
|
||||||
<string name="event_sync_section_explainer">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.</string>
|
<string name="event_sync_section_explainer">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.</string>
|
||||||
|
|||||||
+2
-2
@@ -56,7 +56,7 @@ class NamecoinSettingsTest {
|
|||||||
assertEquals("abc123def.onion", s!!.host)
|
assertEquals("abc123def.onion", s!!.host)
|
||||||
assertEquals(50001, s.port)
|
assertEquals(50001, s.port)
|
||||||
assertFalse(s.useSsl)
|
assertFalse(s.useSsl)
|
||||||
assertTrue(s.trustAllCerts)
|
assertTrue(s.usePinnedTrustStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -131,7 +131,7 @@ class NamecoinSettingsTest {
|
|||||||
assertTrue(servers[0].useSsl)
|
assertTrue(servers[0].useSsl)
|
||||||
assertEquals("server2.onion", servers[1].host)
|
assertEquals("server2.onion", servers[1].host)
|
||||||
assertFalse(servers[1].useSsl)
|
assertFalse(servers[1].useSsl)
|
||||||
assertTrue(servers[1].trustAllCerts)
|
assertTrue(servers[1].usePinnedTrustStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+29
-8
@@ -44,8 +44,14 @@ data class ElectrumxServer(
|
|||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
val useSsl: Boolean = true,
|
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)
|
) : 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). */
|
/** Well-known public Namecoin ElectrumX servers (clearnet). */
|
||||||
val DEFAULT_ELECTRUMX_SERVERS =
|
val DEFAULT_ELECTRUMX_SERVERS =
|
||||||
listOf(
|
listOf(
|
||||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true),
|
||||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true),
|
||||||
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
|
ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Tor-preferred server list: onion primary, clearnet fallback. */
|
/** Tor-preferred server list: onion primary, clearnet fallback. */
|
||||||
@@ -87,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS =
|
|||||||
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
|
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
|
||||||
50002,
|
50002,
|
||||||
useSsl = true,
|
useSsl = true,
|
||||||
trustAllCerts = true,
|
usePinnedTrustStore = true,
|
||||||
),
|
),
|
||||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true),
|
||||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true),
|
||||||
)
|
)
|
||||||
|
|||||||
+429
-32
@@ -38,19 +38,22 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
|
import java.security.KeyStore
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.CertificateFactory
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.net.SocketFactory
|
import javax.net.SocketFactory
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.SSLSocketFactory
|
import javax.net.ssl.SSLSocketFactory
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,25 +90,6 @@ class ElectrumXClient(
|
|||||||
private val requestId = AtomicInteger(0)
|
private val requestId = AtomicInteger(0)
|
||||||
private val serverMutexes = ConcurrentHashMap<String, Mutex>()
|
private val serverMutexes = ConcurrentHashMap<String, Mutex>()
|
||||||
|
|
||||||
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.
|
* Perform a name_show lookup against the given ElectrumX server.
|
||||||
*
|
*
|
||||||
@@ -167,6 +151,162 @@ class ElectrumXClient(
|
|||||||
throw NamecoinLookupException.ServersUnreachable(lastError)
|
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 ──────────────────────────────────────────────────────
|
// ── internals ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun connectAndQuery(
|
private fun connectAndQuery(
|
||||||
@@ -461,41 +601,298 @@ class ElectrumXClient(
|
|||||||
if (!server.useSsl) return baseSocket
|
if (!server.useSsl) return baseSocket
|
||||||
|
|
||||||
// Upgrade to TLS over the already-connected (possibly proxied) socket.
|
// 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 =
|
val sslFactory =
|
||||||
if (server.trustAllCerts) {
|
if (server.host.endsWith(".onion")) {
|
||||||
trustAllSslFactory()
|
// .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 {
|
} else {
|
||||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
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.
|
* Fallback SSLSocketFactory for .onion addresses when the pinned
|
||||||
* Used for servers with self-signed certificates.
|
* 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 {
|
private fun onionSslFactory(): SSLSocketFactory {
|
||||||
val trustAllCerts =
|
val trustAll =
|
||||||
arrayOf<TrustManager>(
|
arrayOf<TrustManager>(
|
||||||
object : X509TrustManager {
|
object : X509TrustManager {
|
||||||
override fun checkClientTrusted(
|
override fun checkClientTrusted(
|
||||||
chain: Array<X509Certificate>,
|
chain: Array<java.security.cert.X509Certificate>,
|
||||||
authType: String,
|
authType: String,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
override fun checkServerTrusted(
|
override fun checkServerTrusted(
|
||||||
chain: Array<X509Certificate>,
|
chain: Array<java.security.cert.X509Certificate>,
|
||||||
authType: String,
|
authType: String,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val sslContext = SSLContext.getInstance("TLS")
|
val ctx =
|
||||||
sslContext.init(null, trustAllCerts, SecureRandom())
|
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<String>()
|
||||||
|
|
||||||
|
/** 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<String>) {
|
||||||
|
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<X509TrustManager>().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
|
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(
|
private fun buildRpcRequest(
|
||||||
method: String,
|
method: String,
|
||||||
params: List<Any>,
|
params: List<Any>,
|
||||||
|
|||||||
Reference in New Issue
Block a user