feat: TOFU cert pinning for custom ElectrumX servers

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.
This commit is contained in:
M
2026-03-25 12:05:05 +11:00
parent 49698de99c
commit b8b51db0b5
7 changed files with 145 additions and 8 deletions
@@ -406,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) {
@@ -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) {
@@ -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 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( return ElectrumxServer(
host = host, host = host,
port = port, port = port,
useSsl = useSsl, useSsl = useSsl,
trustAllCerts = isOnion || !useSsl, trustAllCerts = true,
) )
} }
@@ -78,6 +78,12 @@ fun NamecoinSettingsScreen(
scope.launch { namecoinPrefs.reset() } scope.launch { namecoinPrefs.reset() }
}, },
onTestServer = { server -> electrumXClient.testServer(server) }, onTestServer = { server -> electrumXClient.testServer(server) },
onPinCert = { pem ->
scope.launch {
namecoinPrefs.addPinnedCert(pem)
electrumXClient.addPinnedCert(pem)
}
},
) )
} }
} }
@@ -94,6 +94,7 @@ import java.util.Locale
* @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 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(
@@ -103,6 +104,7 @@ fun NamecoinSettingsSection(
onRemoveServer: (String) -> Unit, onRemoveServer: (String) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onTestServer: suspend (ElectrumxServer) -> ServerTestResult, 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)) {
@@ -177,6 +179,7 @@ fun NamecoinSettingsSection(
TestConnectionSection( TestConnectionSection(
settings = settings, settings = settings,
onTestServer = onTestServer, onTestServer = onTestServer,
onPinCert = onPinCert,
) )
} }
} }
@@ -189,6 +192,7 @@ fun NamecoinSettingsSection(
private fun TestConnectionSection( private fun TestConnectionSection(
settings: NamecoinSettings, settings: NamecoinSettings,
onTestServer: suspend (ElectrumxServer) -> ServerTestResult, onTestServer: suspend (ElectrumxServer) -> ServerTestResult,
onPinCert: (String) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var isTesting by remember { mutableStateOf(false) } var isTesting by remember { mutableStateOf(false) }
@@ -210,6 +214,11 @@ private fun TestConnectionSection(
val result = onTestServer(server) val result = onTestServer(server)
results.add(result) results.add(result)
testResults = results.toList() testResults = results.toList()
// TOFU: auto-pin cert from successful connections
val pem = result.serverCertPem
if (result.success && pem != null) {
onPinCert(pem)
}
} }
lastTestTimestamp = System.currentTimeMillis() lastTestTimestamp = System.currentTimeMillis()
isTesting = false isTesting = false
@@ -319,6 +328,17 @@ private fun ServerTestResultRow(result: ServerTestResult) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Color(0xFF2E8B57), 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 { } else {
val errorText = result.error val errorText = result.error
if (errorText != null) { if (errorText != null) {
@@ -81,6 +81,10 @@ data class ServerTestResult(
val responseTimeMs: Long, val responseTimeMs: Long,
val error: String? = null, val error: String? = null,
val tlsVersion: 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). */
@@ -171,12 +171,31 @@ class ElectrumXClient(
val socket = createSocket(server) val socket = createSocket(server)
socket.soTimeout = readTimeoutMs.toInt() socket.soTimeout = readTimeoutMs.toInt()
val tlsVersion = var tlsVersion: String? = null
if (socket is javax.net.ssl.SSLSocket) { var serverCertPem: String? = null
socket.session.protocol var certFingerprint: String? = null
} else {
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 writer = PrintWriter(socket.getOutputStream(), true)
val reader = BufferedReader(InputStreamReader(socket.getInputStream())) val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
@@ -219,6 +238,8 @@ class ElectrumXClient(
success = true, success = true,
responseTimeMs = elapsed, responseTimeMs = elapsed,
tlsVersion = tlsVersion, tlsVersion = tlsVersion,
serverCertPem = serverCertPem,
certFingerprint = certFingerprint,
) )
} finally { } finally {
runCatching { writer.close() } runCatching { writer.close() }
@@ -604,6 +625,9 @@ class ElectrumXClient(
return sslSocket return sslSocket
} }
/** 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. */ /** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */
@Volatile @Volatile
private var pinnedFactory: SSLSocketFactory? = null 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<String>) {
synchronized(this) {
dynamicCerts.clear()
dynamicCerts.addAll(pems)
pinnedFactory = null
}
}
/** /**
* Build an SSLSocketFactory that trusts the pinned ElectrumX server * Build an SSLSocketFactory that trusts the pinned ElectrumX server
* certificates plus the system CA store. * certificates plus the system CA store.
@@ -643,8 +692,9 @@ class ElectrumXClient(
val cf = CertificateFactory.getInstance("X.509") val cf = CertificateFactory.getInstance("X.509")
// Load each pinned certificate into the keystore // Load hardcoded + dynamic pinned certificates into the keystore
for ((index, pem) in PINNED_ELECTRUMX_CERTS.withIndex()) { val allCerts = PINNED_ELECTRUMX_CERTS + dynamicCerts
for ((index, pem) in allCerts.withIndex()) {
try { try {
val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII))) val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII)))
ks.setCertificateEntry("electrumx_$index", cert) ks.setCertificateEntry("electrumx_$index", cert)