From b8b51db0b5603ee6b1f249eabfcd7fa2aacc0866 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 25 Mar 2026 12:05:05 +1100 Subject: [PATCH] feat: TOFU cert pinning for custom ElectrumX servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user adds a custom ElectrumX server and runs Test Connection, the server's TLS certificate is automatically captured and pinned (Trust On First Use). This allows custom servers with self-signed certificates to work on Samsung/Xiaomi/OnePlus devices. Changes: - ElectrumXClient: addPinnedCert(), setDynamicCerts() for runtime cert management; testServer() now captures server cert PEM and SHA-256 fingerprint from SSL session - NamecoinSharedPreferences: persist pinned certs to DataStore - AppModules: load pinned certs on startup, sync to ElectrumXClient - NamecoinSettings: custom servers always set trustAllCerts=true (ElectrumX servers almost universally use self-signed certs) - UI: shows cert fingerprint in test results, auto-pins on success - ServerTestResult: new serverCertPem + certFingerprint fields Flow: Add server → Test Connection → cert auto-captured and pinned → future connections trust that cert even on Samsung Knox devices. --- .../com/vitorpamplona/amethyst/AppModules.kt | 12 ++++ .../preferences/NamecoinSharedPreferences.kt | 41 ++++++++++++ .../service/namecoin/NamecoinSettings.kt | 6 +- .../settings/NamecoinSettingsScreen.kt | 6 ++ .../settings/NamecoinSettingsSection.kt | 20 ++++++ .../namecoin/ElectrumXServer.kt | 4 ++ .../namecoin/ElectrumXClient.kt | 64 +++++++++++++++++-- 7 files changed, 145 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8a7b5f379..7bbcb490f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -406,6 +406,18 @@ class AppModules( // Eagerly initialize OtsSharedPreferences off the main thread otsPrefs } + + // Load user-pinned ElectrumX certs from preferences into the client + applicationIOScope.launch { + try { + val pinnedCerts = namecoinPrefs.loadPinnedCerts() + if (pinnedCerts.isNotEmpty()) { + electrumXClient.setDynamicCerts(pinnedCerts) + } + } catch (_: Exception) { + // Non-fatal — defaults will still work + } + } } fun terminate(appContext: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 600cecaa9..7319aa78e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -58,6 +58,7 @@ class NamecoinSharedPreferences( companion object { val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts") } /** @@ -99,8 +100,48 @@ class NamecoinSharedPreferences( suspend fun reset() { persist(NamecoinSettings.DEFAULT) + clearPinnedCerts() } + /** + * Store a PEM-encoded certificate that the user accepted via Test Connection. + * The cert is appended to the existing list and synced to the ElectrumXClient. + */ + suspend fun addPinnedCert(pem: String) { + val existing = loadPinnedCertsFromDisk() + val updated = (existing + pem).distinct() + savePinnedCerts(updated) + } + + /** Load all user-pinned certs from disk (for startup sync). */ + suspend fun loadPinnedCerts(): List = loadPinnedCertsFromDisk() + + private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList()) + + private suspend fun savePinnedCerts(certs: List) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing pinned certs: ${e.message}") + } + } + + private suspend fun loadPinnedCertsFromDisk(): List = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val certsJson = prefs[KEY_PINNED_CERTS] + if (certsJson != null) { + json.decodeFromString>(certsJson) + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + // ── Internal ─────────────────────────────────────────────────────── private suspend fun persist(settings: NamecoinSettings) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 4855b493f..1962997dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -77,11 +77,15 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") + // All custom servers use trustAllCerts — ElectrumX servers + // almost universally use self-signed certificates. The actual + // trust is handled by pinned certs (hardcoded defaults + any + // certs the user has accepted via Test Connection TOFU). return ElectrumxServer( host = host, port = port, useSsl = useSsl, - trustAllCerts = isOnion || !useSsl, + trustAllCerts = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 0f32a6499..d5ca59881 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -78,6 +78,12 @@ fun NamecoinSettingsScreen( scope.launch { namecoinPrefs.reset() } }, onTestServer = { server -> electrumXClient.testServer(server) }, + onPinCert = { pem -> + scope.launch { + namecoinPrefs.addPinnedCert(pem) + electrumXClient.addPinnedCert(pem) + } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index 86949a86b..5d0c0af4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -94,6 +94,7 @@ import java.util.Locale * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults * @param onTestServer Suspend function to test a single server + * @param onPinCert Called with PEM string to persist a TOFU-pinned cert */ @Composable fun NamecoinSettingsSection( @@ -103,6 +104,7 @@ fun NamecoinSettingsSection( onRemoveServer: (String) -> Unit, onReset: () -> Unit, onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier.padding(16.dp)) { @@ -177,6 +179,7 @@ fun NamecoinSettingsSection( TestConnectionSection( settings = settings, onTestServer = onTestServer, + onPinCert = onPinCert, ) } } @@ -189,6 +192,7 @@ fun NamecoinSettingsSection( private fun TestConnectionSection( settings: NamecoinSettings, onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit, ) { val scope = rememberCoroutineScope() var isTesting by remember { mutableStateOf(false) } @@ -210,6 +214,11 @@ private fun TestConnectionSection( val result = onTestServer(server) results.add(result) testResults = results.toList() + // TOFU: auto-pin cert from successful connections + val pem = result.serverCertPem + if (result.success && pem != null) { + onPinCert(pem) + } } lastTestTimestamp = System.currentTimeMillis() isTesting = false @@ -319,6 +328,17 @@ private fun ServerTestResultRow(result: ServerTestResult) { 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) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index cc5a172d4..f683894f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -81,6 +81,10 @@ data class ServerTestResult( 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). */ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 6a4db6688..65a211ccd 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -171,12 +171,31 @@ class ElectrumXClient( val socket = createSocket(server) socket.soTimeout = readTimeoutMs.toInt() - val tlsVersion = - if (socket is javax.net.ssl.SSLSocket) { - socket.session.protocol - } else { - null + 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())) @@ -219,6 +238,8 @@ class ElectrumXClient( success = true, responseTimeMs = elapsed, tlsVersion = tlsVersion, + serverCertPem = serverCertPem, + certFingerprint = certFingerprint, ) } finally { runCatching { writer.close() } @@ -604,6 +625,9 @@ class ElectrumXClient( return sslSocket } + /** User-supplied PEM certificates for custom servers (TOFU-pinned). */ + private val dynamicCerts = mutableListOf() + /** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */ @Volatile private var pinnedFactory: SSLSocketFactory? = null @@ -616,6 +640,31 @@ class ElectrumXClient( } } + /** + * Add a PEM-encoded certificate to the dynamic trust store. + * Typically called after the user confirms a cert fingerprint via + * the "Test Connection" flow in settings. + * + * Invalidates the cached factory so the next connection picks it up. + */ + fun addPinnedCert(pem: String) { + synchronized(this) { + dynamicCerts.add(pem) + pinnedFactory = null // force rebuild + } + } + + /** + * Replace all dynamic certs (e.g. loaded from preferences on startup). + */ + fun setDynamicCerts(pems: List) { + synchronized(this) { + dynamicCerts.clear() + dynamicCerts.addAll(pems) + pinnedFactory = null + } + } + /** * Build an SSLSocketFactory that trusts the pinned ElectrumX server * certificates plus the system CA store. @@ -643,8 +692,9 @@ class ElectrumXClient( val cf = CertificateFactory.getInstance("X.509") - // Load each pinned certificate into the keystore - for ((index, pem) in PINNED_ELECTRUMX_CERTS.withIndex()) { + // 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)