mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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
|
||||
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) {
|
||||
|
||||
+41
@@ -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<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 ───────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun persist(settings: NamecoinSettings) {
|
||||
|
||||
+5
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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) {
|
||||
|
||||
+4
@@ -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). */
|
||||
|
||||
+57
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user