diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt index 4a1f551c6e..63204787e1 100644 --- a/app/src/main/java/to/bitkit/di/HttpModule.kt +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -22,6 +22,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.UrlValidator import javax.inject.Singleton +import javax.net.ssl.SSLSocketFactory import io.ktor.client.plugins.logging.Logger as KtorLogger @Module @@ -47,6 +48,10 @@ object HttpModule { } } + @Provides + @Singleton + fun provideSslSocketFactory(): SSLSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory + @Provides @Singleton fun provideUrlValidator(httpClient: HttpClient) = UrlValidator { url -> diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dd04781e59..c0e3cd3796 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -924,7 +924,11 @@ class LightningRepo @Inject constructor( Logger.warn("Failed ldk-node config change, recovering in background…", context = TAG) scope.launch { restartWithPreviousConfig() } }.onSuccess { - settingsStore.update { it.copy(electrumServer = newServerUrl) } + runSuspendCatching { settingsStore.update { it.copy(electrumServer = newServerUrl) } } + .onFailure { + Logger.error("Failed to persist electrum server '$newServerUrl'", it, context = TAG) + return@withContext Result.failure(it) + } Logger.info("Successfully changed electrum server", context = TAG) } diff --git a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt index d13f821d1e..3e1e5d456b 100644 --- a/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt +++ b/app/src/main/java/to/bitkit/services/ElectrumProbeService.kt @@ -22,8 +22,11 @@ import java.io.InputStream import java.io.Writer import java.net.InetSocketAddress import java.net.Socket +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException import javax.inject.Inject import javax.inject.Singleton +import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import kotlin.time.Duration.Companion.seconds @@ -38,6 +41,7 @@ import kotlin.time.Duration.Companion.seconds @Singleton class ElectrumProbeService @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val sslSocketFactory: SSLSocketFactory, ) { companion object { private const val TAG = "ElectrumProbeService" @@ -59,6 +63,9 @@ class ElectrumProbeService @Inject constructor( /** JSON-RPC id of the `server.features` request. */ private const val FEATURES_REQUEST_ID = 1 + + /** JSSE endpoint identification that checks the certificate's name, not only its chain. */ + private const val HOSTNAME_VERIFICATION = "HTTPS" } // Deliberately not the injected Json: that one sets prettyPrint, and electrum is line-delimited, @@ -110,14 +117,17 @@ class ElectrumProbeService @Inject constructor( // A TLS handshake against a plain-TCP server hangs without a read timeout, which is the // misconfiguration that wedges the node's release when it is left to node.start(). return runCatching { - val factory = SSLSocketFactory.getDefault() as SSLSocketFactory - val ssl = factory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket + val ssl = sslSocketFactory.createSocket(plain, server.host, server.getPort(), true) as SSLSocket ssl.soTimeout = CONNECT_TIMEOUT.inWholeMilliseconds.toInt() + // A raw SSLSocket validates the chain but not the name the certificate was issued for, so + // a CA-valid certificate for another host probes clean and is only rejected afterwards by + // the node's own electrum client — after the restart this probe exists to avoid. + ssl.sslParameters = ssl.sslParameters.apply { endpointIdentificationAlgorithm = HOSTNAME_VERIFICATION } ssl.startHandshake() ssl }.getOrElse { plain.runCatching { close() } - throw ElectrumProbeError.ProtocolMismatch(server, it) + throw it.toTlsProbeError(server) } } @@ -216,6 +226,32 @@ private data class RpcResponse( private fun JsonElement?.isNullOrJsonNull() = this == null || this is JsonNull +/** Depth the handshake failure's cause chain is walked to, enough for JSSE's wrapping and cycle-proof. */ +private const val MAX_CAUSE_DEPTH = 8 + +// A failed TLS handshake is only evidence of the wrong protocol or port when the peer did not speak +// TLS at all. A server that presented a certificate Bitkit does not trust — self-signed Fulcrum or +// electrs on its SSL port — has the protocol right, so it must not be told to check it. +internal fun Throwable.toTlsProbeError(server: ElectrumServer): ElectrumProbeError = + if (isCertificateFailure()) { + ElectrumProbeError.UntrustedCertificate(server, this) + } else { + ElectrumProbeError.ProtocolMismatch(server, this) + } + +private fun Throwable.isCertificateFailure(): Boolean { + var cause: Throwable? = this + var depth = 0 + while (cause != null && depth < MAX_CAUSE_DEPTH) { + when (cause) { + is CertificateException, is CertPathValidatorException, is SSLPeerUnverifiedException -> return true + else -> cause = cause.cause + } + depth++ + } + return false +} + sealed class ElectrumProbeError(message: String, cause: Throwable? = null) : AppError(message, cause) { class Unreachable(server: ElectrumServer, cause: Throwable) : ElectrumProbeError("Could not reach electrum server '$server'", cause) @@ -223,6 +259,9 @@ sealed class ElectrumProbeError(message: String, cause: Throwable? = null) : App class ProtocolMismatch(server: ElectrumServer, cause: Throwable) : ElectrumProbeError("Failed TLS handshake with electrum server '$server', check the protocol", cause) + class UntrustedCertificate(server: ElectrumServer, cause: Throwable) : + ElectrumProbeError("Rejected untrusted certificate of electrum server '$server'", cause) + class NotElectrum(server: ElectrumServer, cause: Throwable? = null) : ElectrumProbeError("Received no electrum response from '$server'", cause) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt index 1fb2e584f5..903702d00f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt @@ -12,10 +12,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -29,7 +27,6 @@ import androidx.navigation.NavController import to.bitkit.R import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServerPeer -import to.bitkit.models.Toast import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -53,31 +50,6 @@ fun ElectrumConfigScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val app = appViewModel ?: return - val context = LocalContext.current - - // Monitor connection results - LaunchedEffect(uiState.connectionResult) { - uiState.connectionResult?.let { result -> - if (result.isSuccess) { - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.settings__es__server_updated_title), - description = context.getString(R.string.settings__es__server_updated_message) - .replace("{host}", uiState.host) - .replace("{port}", uiState.port), - testTag = "ElectrumUpdatedToast", - ) - } else { - app.toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__es__server_error), - description = context.getString(R.string.settings__es__server_error_description), - testTag = "ElectrumErrorToast", - ) - } - viewModel.clearConnectionResult() - } - } Content( uiState = uiState, diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt index 5ebac472d5..6ca3dd4b1a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.settings.advanced import android.content.Context +import androidx.annotation.StringRes import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +27,7 @@ import to.bitkit.models.MAX_VALID_PORT import to.bitkit.models.Toast import to.bitkit.models.getDefaultPort import to.bitkit.repositories.LightningRepo +import to.bitkit.services.ElectrumProbeError import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject @@ -150,33 +152,33 @@ class ElectrumConfigViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch(bgDispatcher) { - runCatching { - val electrumServer = ElectrumServer.fromUserInput( - host = currentState.host, - port = port, - protocol = protocol, - ) - val serverUrl = electrumServer.toString() - - lightningRepo.restartWithElectrumServer(serverUrl) - .onSuccess { - _uiState.update { - it.copy( - isLoading = false, - connectionResult = Result.success(Unit), - hasEdited = false, - ) - } - } - .onFailure { error -> throw error } - }.onFailure { e -> - _uiState.update { - it.copy( - isLoading = false, - connectionResult = Result.failure(e), + val serverUrl = ElectrumServer.fromUserInput( + host = currentState.host, + port = port, + protocol = protocol, + ).toString() + + lightningRepo.restartWithElectrumServer(serverUrl) + .onSuccess { + _uiState.update { it.copy(isLoading = false, hasEdited = false) } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.settings__es__server_updated_title), + description = context.getString(R.string.settings__es__server_updated_message) + .replace("{host}", currentState.host) + .replace("{port}", currentState.port), + testTag = "ElectrumUpdatedToast", + ) + } + .onFailure { error -> + _uiState.update { it.copy(isLoading = false) } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.settings__es__server_error), + description = context.getString(error.toServerErrorDescriptionRes()), + testTag = "ElectrumErrorToast", ) } - } } } @@ -248,10 +250,6 @@ class ElectrumConfigViewModel @Inject constructor( return uiPeer != state.connectedPeer } - fun clearConnectionResult() { - _uiState.update { it.copy(connectionResult = null) } - } - fun onClickConnect() { viewModelScope.launch(bgDispatcher) { val validationError = validateInput() @@ -357,6 +355,13 @@ data class ElectrumConfigUiState( val port: String = "", val protocol: ElectrumProtocol? = null, val isLoading: Boolean = false, - val connectionResult: Result? = null, val hasEdited: Boolean = false, ) + +@StringRes +private fun Throwable.toServerErrorDescriptionRes(): Int = when (this) { + is ElectrumProbeError.NetworkMismatch -> R.string.settings__es__server_error_network + is ElectrumProbeError.ProtocolMismatch -> R.string.settings__es__server_error_protocol + is ElectrumProbeError.UntrustedCertificate -> R.string.settings__es__server_error_certificate + else -> R.string.settings__es__server_error_description +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2eaaeb38cf..a90efa0ca5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -929,7 +929,10 @@ Port Protocol Electrum Connection Failed + This server\'s certificate is not trusted. Use a server with a trusted certificate, or connect to its TCP port. Bitkit could not establish a connection to Electrum. + This server is on a different Bitcoin network. Choose a server for the network Bitkit is using. + Secure connection failed. Check that the protocol (TCP or TLS) matches the server port. Successfully connected to {host}:{port} Electrum Server Updated Depends on fee diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 36d7b9a845..469cf63bd3 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1356,6 +1356,22 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } + // Regression: the settings write after a successful restart must surface as a failed Result. + // Thrown out of the returned Result it escapes the caller's coroutine, which leaves the + // Electrum config screen spinning with no toast. + @Test + fun `restartWithElectrumServer returns failure when persisting the server fails`() = test { + startNodeForTesting() + val customServerUrl = "ssl://test.example.com:50002" + whenever(lightningService.node).thenReturn(null) + whenever(lightningService.stop()).thenReturn(Unit) + whenever(settingsStore.update(any())).thenThrow(RuntimeException("write failed")) + + val result = sut.restartWithElectrumServer(customServerUrl) + + assertTrue(result.isFailure) + } + @Test fun `restartWithElectrumServer should handle stop failure`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt index b3a53b4909..6660c8abbc 100644 --- a/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ElectrumProbeServiceTest.kt @@ -7,27 +7,75 @@ import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.x509.X509V3CertificateGenerator import org.junit.After import org.junit.Test import org.lightningdevkit.ldknode.Network +import to.bitkit.ext.nowMillis import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServer import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import java.io.BufferedReader +import java.math.BigInteger import java.net.InetAddress import java.net.ServerSocket import java.net.Socket +import java.net.SocketTimeoutException +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.cert.CertPathValidatorException +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Date +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.security.auth.x500.X500Principal import kotlin.concurrent.thread import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days private const val REGTEST_GENESIS = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" private const val MAINNET_GENESIS = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +private const val VERSION_REPLY = """{"id":0,"jsonrpc":"2.0","result":["fake-electrs","1.4"]}""" + +private fun featuresReplyWith(genesisHash: String) = + """{"id":1,"jsonrpc":"2.0","result":{"genesis_hash":"$genesisHash"}}""" + +private const val LOOPBACK = "127.0.0.1" +private const val RSA_KEY_SIZE = 2048 +private val CERTIFICATE_VALIDITY = 1.days +private val KEY_PASSWORD = "probe".toCharArray() + +/** + * The JDK's own JSSE provider, named rather than taken from the head of the provider list. + * + * Robolectric installs Conscrypt as the JVM's first security provider and never removes it, so every + * test class running after one of those would otherwise get Conscrypt here. A Conscrypt server socket + * cannot finish a handshake on a current JDK — it reflects into `java.net`, which the module system + * refuses — which made these TLS tests depend on which class ran before them. + */ +private const val JSSE_PROVIDER = "SunJSSE" + @OptIn(ExperimentalCoroutinesApi::class) class ElectrumProbeServiceTest : BaseUnitTest() { - private val sut = ElectrumProbeService(ioDispatcher = Dispatchers.IO) + private var socketFactory: SSLSocketFactory = + SSLContext.getInstance("TLS", JSSE_PROVIDER).apply { init(null, null, null) }.socketFactory + + private val sut get() = ElectrumProbeService(ioDispatcher = Dispatchers.IO, sslSocketFactory = socketFactory) private var server: ServerSocket? = null @@ -175,6 +223,30 @@ class ElectrumProbeServiceTest : BaseUnitTest() { assertIs(result.exceptionOrNull()) } + // Regression: TLS to a host named by an IP or an alias, answered by a server whose certificate + // chain is valid but was issued for another name. A raw SSLSocket checks the chain and not the + // name, so this probed clean and only the node's own electrum client rejected it — after the + // node restart the probe exists to avoid. + @Test + fun `probe rejects a certificate issued for another host`() = test { + val port = startTlsElectrum(certificateFor = GeneralName(GeneralName.dNSName, "wrong.example")) + + val result = sut.probe(serverAt(port, ElectrumProtocol.SSL), network = Network.REGTEST) + + assertIs(result.exceptionOrNull()) + } + + // The other half of the pair: verifying the name must not reject a certificate that does name + // the host, otherwise the probe would refuse servers the node itself accepts. + @Test + fun `probe accepts a certificate issued for the host it connected to`() = test { + val port = startTlsElectrum(certificateFor = GeneralName(GeneralName.iPAddress, LOOPBACK)) + + val result = sut.probe(serverAt(port, ElectrumProtocol.SSL), network = Network.REGTEST) + + assertTrue(result.isSuccess, "probe rejected a matching certificate: '${result.exceptionOrNull()}'") + } + @Test fun `probe reports the requested server in its error`() = test { val port = startSilentServer() @@ -210,8 +282,45 @@ class ElectrumProbeServiceTest : BaseUnitTest() { assertTrue(features.getValue("params").jsonArray.isEmpty()) } + // A self-signed Fulcrum or electrs on its SSL port fails the handshake with the protocol and the + // port already correct, so it must not be reported as a protocol mismatch. + @Test + fun `toTlsProbeError reports an untrusted certificate chain`() { + val handshake = SSLHandshakeException("PKIX path building failed").apply { + initCause(AppError("validator failed", CertPathValidatorException("no trusted path"))) + } + + val error = handshake.toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + + @Test + fun `toTlsProbeError reports an unverified peer`() { + val error = SSLPeerUnverifiedException("hostname mismatch") + .toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + + @Test + fun `toTlsProbeError reports a handshake timeout as a protocol mismatch`() { + val error = SocketTimeoutException("Read timed out") + .toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + + @Test + fun `toTlsProbeError reports a non tls reply as a protocol mismatch`() { + val error = SSLException("Unsupported or unrecognized SSL message") + .toTlsProbeError(serverAt(50002, ElectrumProtocol.SSL)) + + assertIs(error) + } + private fun serverAt(port: Int, protocol: ElectrumProtocol = ElectrumProtocol.TCP) = ElectrumServer( - host = "127.0.0.1", + host = LOOPBACK, tcp = port, ssl = port, protocol = protocol, @@ -223,12 +332,12 @@ class ElectrumProbeServiceTest : BaseUnitTest() { */ private fun startFakeElectrum( genesisHash: String? = null, - versionReply: String = """{"id":0,"jsonrpc":"2.0","result":["fake-electrs","1.4"]}""", + versionReply: String = VERSION_REPLY, featuresReply: String = genesisHash - ?.let { """{"id":1,"jsonrpc":"2.0","result":{"genesis_hash":"$it"}}""" } + ?.let { featuresReplyWith(it) } ?: """{"id":1,"error":{"code":-32601,"message":"unknown method"}}""", ): Int { - val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + val socket = ServerSocket(0, 1, InetAddress.getByName(LOOPBACK)).also { server = it } thread(isDaemon = true) { runCatching { socket.accept().use { client -> serveElectrum(client, versionReply, featuresReply) } @@ -256,7 +365,7 @@ class ElectrumProbeServiceTest : BaseUnitTest() { } private fun startOversizedLineServer(): Int { - val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + val socket = ServerSocket(0, 1, InetAddress.getByName(LOOPBACK)).also { server = it } thread(isDaemon = true) { runCatching { socket.accept().use { client -> @@ -272,9 +381,72 @@ class ElectrumProbeServiceTest : BaseUnitTest() { return socket.localPort } + /** + * Serves electrum over TLS behind a self-signed certificate naming [certificateFor], the only + * certificate the probe is given to trust. The chain is therefore valid and only the name can + * fail, so what the probe reports is decided by whether it asks JSSE to check the name at all. + */ + private fun startTlsElectrum(certificateFor: GeneralName, genesisHash: String = REGTEST_GENESIS): Int { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(RSA_KEY_SIZE) }.generateKeyPair() + val certificate = selfSignedCertificate(keyPair, certificateFor) + val tls = tlsContext(keyPair, certificate) + socketFactory = tls.socketFactory + + val socket = tls.serverSocketFactory + .createServerSocket(0, 1, InetAddress.getByName(LOOPBACK)) + .also { server = it } + thread(isDaemon = true) { + runCatching { + socket.accept().use { client -> + serveElectrum(client, VERSION_REPLY, featuresReplyWith(genesisHash)) + } + } + } + return socket.localPort + } + + @Suppress("DEPRECATION") // bcprov carries no other certificate generator, and bcpkix is not a dependency + private fun selfSignedCertificate(keyPair: KeyPair, name: GeneralName): X509Certificate { + val generated = X509V3CertificateGenerator().apply { + setSerialNumber(BigInteger.ONE) + setIssuerDN(X500Principal("CN=electrum-probe-test")) + setSubjectDN(X500Principal("CN=electrum-probe-test")) + setNotBefore(Date(nowMillis() - CERTIFICATE_VALIDITY.inWholeMilliseconds)) + setNotAfter(Date(nowMillis() + CERTIFICATE_VALIDITY.inWholeMilliseconds)) + setPublicKey(keyPair.public) + setSignatureAlgorithm("SHA256withRSA") + addExtension(Extension.subjectAlternativeName, false, GeneralNames(name)) + }.generate(keyPair.private) + + // Re-read through the platform factory so the certificate exposes a usable public key. + return CertificateFactory.getInstance("X.509") + .generateCertificate(generated.encoded.inputStream()) as X509Certificate + } + + /** Serves [certificate] to the probe and is the only certificate the probe is told to trust. */ + private fun tlsContext(keyPair: KeyPair, certificate: X509Certificate): SSLContext { + val keyStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry("probe", keyPair.private, KEY_PASSWORD, arrayOf(certificate)) + } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm(), JSSE_PROVIDER) + .apply { init(keyStore, KEY_PASSWORD) } + .keyManagers + + val trustStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setCertificateEntry("probe", certificate) + } + val trustManagers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm(), JSSE_PROVIDER) + .apply { init(trustStore) } + .trustManagers + + return SSLContext.getInstance("TLS", JSSE_PROVIDER).apply { init(keyManagers, trustManagers, null) } + } + /** Accepts the connection but never speaks electrum, like a non-electrum service on the port. */ private fun startSilentServer(): Int { - val socket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).also { server = it } + val socket = ServerSocket(0, 1, InetAddress.getByName(LOOPBACK)).also { server = it } thread(isDaemon = true) { runCatching { socket.accept().use { it.getInputStream().read() } } } diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt index 5eab60bc1e..1dc451e34b 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModelTest.kt @@ -4,6 +4,8 @@ import android.content.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.withTimeout import org.junit.Before @@ -19,9 +21,17 @@ import org.robolectric.annotation.Config import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.models.ElectrumProtocol +import to.bitkit.models.ElectrumServer +import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.services.ElectrumProbeError import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.time.Duration.Companion.seconds @@ -42,6 +52,14 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { private val errorPortInvalid = "Please specify a valid port." private val errorInvalidHttp = "Please specify a valid url." private val errorPeer = "Electrum Error" + private val serverError = "Electrum Connection Failed" + private val serverErrorDescription = "Bitkit could not establish a connection to Electrum." + private val serverErrorNetwork = "This server is on a different Bitcoin network." + private val serverErrorProtocol = "Secure connection failed." + private val serverErrorCertificate = "This server's certificate is not trusted." + private val serverUpdatedTitle = "Electrum Server Updated" + private val serverUpdatedMessage = "Successfully connected to {host}:{port}" + private val server = ElectrumServer(host = "example.com", tcp = 50001, ssl = 50002, protocol = ElectrumProtocol.SSL) @Before fun setUp() { @@ -51,6 +69,14 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.settings__es__error_port_invalid)).thenReturn(errorPortInvalid) whenever(context.getString(R.string.settings__es__error_invalid_http)).thenReturn(errorInvalidHttp) whenever(context.getString(R.string.settings__es__error_peer)).thenReturn(errorPeer) + whenever(context.getString(R.string.settings__es__server_error)).thenReturn(serverError) + whenever(context.getString(R.string.settings__es__server_error_description)).thenReturn(serverErrorDescription) + whenever(context.getString(R.string.settings__es__server_error_network)).thenReturn(serverErrorNetwork) + whenever(context.getString(R.string.settings__es__server_error_protocol)).thenReturn(serverErrorProtocol) + whenever(context.getString(R.string.settings__es__server_error_certificate)) + .thenReturn(serverErrorCertificate) + whenever(context.getString(R.string.settings__es__server_updated_title)).thenReturn(serverUpdatedTitle) + whenever(context.getString(R.string.settings__es__server_updated_message)).thenReturn(serverUpdatedMessage) whenever(settingsStore.data).thenReturn( flowOf(SettingsData(electrumServer = "ssl://electrum.blockstream.info:50002")) ) @@ -110,4 +136,94 @@ class ElectrumConfigViewModelTest : BaseUnitTest() { verify(lightningRepo, never()).restartWithElectrumServer(any()) } + + @Test + fun `connectToServer shows network toast on network mismatch`() = test { + val error = ElectrumProbeError.NetworkMismatch(server, expected = "regtest", actual = "bitcoin") + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorNetwork, toast) + } + + @Test + fun `connectToServer shows protocol toast on protocol mismatch`() = test { + val error = ElectrumProbeError.ProtocolMismatch(server, AppError("handshake")) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorProtocol, toast) + } + + @Test + fun `connectToServer shows certificate toast on untrusted certificate`() = test { + val error = ElectrumProbeError.UntrustedCertificate(server, AppError("PKIX path building failed")) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorCertificate, toast) + } + + @Test + fun `connectToServer shows generic toast when server is unreachable`() = test { + val error = ElectrumProbeError.Unreachable(server, AppError("timeout")) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorDescription, toast) + } + + @Test + fun `connectToServer shows generic toast when server is not electrum`() = test { + val error = ElectrumProbeError.NotElectrum(server) + + val toast = connectAndCollectToast(Result.failure(error)) + + assertErrorToast(serverErrorDescription, toast) + } + + @Test + fun `connectToServer shows generic toast on other failures`() = test { + val toast = connectAndCollectToast(Result.failure(AppError("node failed to start"))) + + assertErrorToast(serverErrorDescription, toast) + } + + @Test + fun `connectToServer shows updated toast on success`() = test { + val toast = connectAndCollectToast(Result.success(Unit)) + + assertEquals(Toast.ToastType.SUCCESS, toast.type) + assertEquals(serverUpdatedTitle, toast.title) + assertEquals("Successfully connected to example.com:50002", toast.description) + assertEquals("ElectrumUpdatedToast", toast.testTag) + assertFalse(sut.uiState.value.isLoading) + assertFalse(sut.uiState.value.hasEdited) + } + + private suspend fun TestScope.connectAndCollectToast(result: Result): Toast { + whenever(lightningRepo.restartWithElectrumServer("ssl://example.com:50002")).thenReturn(result) + sut = createSut() + advanceUntilIdle() + sut.setProtocol(ElectrumProtocol.SSL) + sut.setHost("example.com") + sut.setPort("50002") + + val toasts = mutableListOf() + val collectJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + advanceUntilIdle() + sut.connectToServer() + advanceUntilIdle() + collectJob.cancel() + + return toasts.single() + } + + private fun assertErrorToast(expectedDescription: String, toast: Toast) { + assertEquals(Toast.ToastType.WARNING, toast.type) + assertEquals(serverError, toast.title) + assertEquals(expectedDescription, toast.description) + assertEquals("ElectrumErrorToast", toast.testTag) + assertFalse(sut.uiState.value.isLoading) + } } diff --git a/changelog.d/next/1177.fixed.md b/changelog.d/next/1177.fixed.md new file mode 100644 index 0000000000..0d48affa73 --- /dev/null +++ b/changelog.d/next/1177.fixed.md @@ -0,0 +1 @@ +Connecting to a custom Electrum server now explains when it is on the wrong Bitcoin network, when the TCP/TLS protocol does not match the port, or when its TLS certificate is untrusted or issued for another host. diff --git a/journeys/README.md b/journeys/README.md index c711711637..36020539cb 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -129,6 +129,7 @@ fixtures, push notifications) live in each suite's README. | [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | | [security](security) | 1 | PIN result sheet layout at a long locale and font scale; no README | | [shop](shop) | 1 | Shop Discover category titles and web view handoff; needs Bitrefill reachable; no README | +| [settings](settings) | 1 | Electrum server error toasts; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | | [tags](tags) | 1 | Tag input length cap on an activity; no backend, no README | | [transfers](transfers) | 1 | Transfer to Spending settling after the LSP closes the channel; no README | @@ -155,6 +156,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | | `payment-requests/requested-resolution-failure.xml` | not ported | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | +| `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | diff --git a/journeys/settings/electrum-server-error-toasts.xml b/journeys/settings/electrum-server-error-toasts.xml new file mode 100644 index 0000000000..eb3431ba3c --- /dev/null +++ b/journeys/settings/electrum-server-error-toasts.xml @@ -0,0 +1,40 @@ + + + Verifies that a rejected custom Electrum server shows a warning toast whose description names the + cause: a wrong-network server and a TLS handshake against a plain TCP port get their own messages, + and an unreachable host keeps the generic one. The server is probed before the node stops, so a + rejected server is never persisted and the wallet stays on its current server. + + Precondition: onboarded dev wallet on the default staging regtest server (Settings shows Electrum + Server "Auto"). Start on the wallet home screen. Toasts never appear in `android layout` and last + about 2s, so assert them from a screen recording or a screenshot taken 2-6s after tapping connect. + Type hosts in short chunks and verify the field text; `adb shell input text` drops characters. + Do not tap "Reset To Default" unless the starting server was the default. Tapping a protocol row + rewrites the port whenever it is empty or still one of 51002/50002/51001/50001, to that + protocol's default for the build's network (60002 for TLS on the regtest dev build), so always + set the protocol before typing the port and never re-tap a protocol row afterwards. + + + Tap the menu icon (testTag "HeaderMenu") + Tap Settings (testTag "DrawerSettings") + Tap the Advanced tab (testTag "Tab-advanced") + Tap Electrum Server (testTag "ElectrumConfig") + Verify "Currently connected to" is visible and note the connected server (testTag "Connected") + Select "TLS" under protocol (testTag "ElectrumProtocol") + Replace the host (testTag "HostInput") with "electrum.blockstream.info" and the port (testTag "PortInput") with "50002", then verify the port field still reads "50002" + Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") + Verify a warning toast "Electrum Connection Failed" appears with the description "This server is on a different Bitcoin network. Choose a server for the network Bitkit is using." + Verify the connected server (testTag "Connected") is unchanged + Replace the port (testTag "PortInput") with "50001" without tapping a protocol row, keeping "TLS" selected, and verify the port field still reads "50001" + Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") + Verify a warning toast "Electrum Connection Failed" appears with the description "Secure connection failed. Check that the protocol (TCP or TLS) matches the server port." + Replace the host (testTag "HostInput") with "10.255.255.1" and the port (testTag "PortInput") with "9999" + Hide the keyboard and tap Connect To Host (testTag "ConnectToHost") + Verify a warning toast "Electrum Connection Failed" appears with the description "Bitkit could not establish a connection to Electrum." after the ~5s connect timeout + Verify the connected server (testTag "Connected") is unchanged + Tap Reset To Default (testTag "ResetToDefault") + Verify the host and port fields show the default server, which is the connected one, and Connect To Host (testTag "ConnectToHost") is disabled + Tap back (testTag "NavigationBack") + Verify the Electrum Server row (testTag "ElectrumConfig") still shows "Auto" + +