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:
@@ -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) {
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+5
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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) {
|
||||||
|
|||||||
+4
@@ -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). */
|
||||||
|
|||||||
+57
-7
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user