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
@@ -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<String>()
/** 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<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.
@@ -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)