From 7f451ee7c70b5ba15d3a72c03668b6a8ab5a7b86 Mon Sep 17 00:00:00 2001 From: M Date: Thu, 26 Mar 2026 06:26:18 +1100 Subject: [PATCH] fix: harden TLS for GrapheneOS and security-conscious Android ROMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address compatibility and security issues identified by reviewing GrapheneOS source (hardened Conscrypt, strict TLS enforcement): 1. .onion: prefer pinned factory, fall back to trust-all GrapheneOS patches Conscrypt with stricter TLS enforcement that may reject no-op X509TrustManagers even for proxied sockets. The .onion server's cert is already in PINNED_ELECTRUMX_CERTS (same operator as electrumx.testls.space), so we now use cachedPinnedSslFactory() as the primary path. onionSslFactory() (trust-all) is kept as a fallback on SSLHandshakeException only — this handles cert rotation or unknown .onion servers gracefully. 2. TOFU: require explicit user confirmation before pinning Previously, Test Connection auto-pinned every cert on success. An attacker performing MITM during first test would get their cert permanently trusted. Now each new cert triggers an AlertDialog showing the full SHA-256 fingerprint. Users must explicitly accept ('Trust') or reject each cert — aligning with GrapheneOS's philosophy of explicit trust decisions. 3. Clarify trustAllCerts semantics The field name is misleading post-refactor: it now means 'use pinned trust store' not 'trust all certificates'. Added TODO to rename to usePinnedTrustStore and updated inline comments to prevent future misinterpretation. Note: hostname verification on raw SSLSocket is a pre-existing gap (not introduced by the Samsung fix PR) — SSLSocket.createSocket() uses the host parameter for SNI only, not hostname verification. A follow-up should add endpointIdentificationAlgorithm='HTTPS' or fingerprint-based verification for pinned certs. --- .../service/namecoin/NamecoinSettings.kt | 10 ++- .../settings/NamecoinSettingsSection.kt | 84 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 4 + .../namecoin/ElectrumXClient.kt | 49 +++++++++-- 4 files changed, 131 insertions(+), 16 deletions(-) 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 1962997dd..0f372e577 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,10 +77,12 @@ 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). + // All custom servers set trustAllCerts=true, which (despite the + // legacy name) means "use the pinned trust store" rather than + // "trust all certificates". ElectrumX servers almost universally + // use self-signed certs, so we route them through our pinned + // SSLSocketFactory (hardcoded defaults + TOFU-pinned certs). + // TODO: rename trustAllCerts → usePinnedTrustStore for clarity. return ElectrumxServer( host = host, port = port, 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 5d0c0af4b..5db51d692 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 @@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -188,6 +189,15 @@ fun NamecoinSettingsSection( // ── Test Connection ──────────────────────────────────────────────────── +/** + * Holds a cert pending user confirmation before pinning (TOFU). + */ +private data class PendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + @Composable private fun TestConnectionSection( settings: NamecoinSettings, @@ -198,9 +208,63 @@ private fun TestConnectionSection( var isTesting by remember { mutableStateOf(false) } var testResults by remember { mutableStateOf>(emptyList()) } var lastTestTimestamp by remember { mutableStateOf(null) } + // Certs discovered during testing that need user confirmation + var pendingCerts by remember { mutableStateOf>(emptyList()) } + // Which cert is currently shown in the confirmation dialog + var confirmingCert by remember { mutableStateOf(null) } val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + // ── Cert confirmation dialog ─────────────────────────────── + confirmingCert?.let { pending -> + AlertDialog( + onDismissRequest = { + // Remove from pending list and move to next (or close) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }, + title = { Text(stringResource(R.string.namecoin_pin_cert_title)) }, + text = { + Column { + Text( + stringResource(R.string.namecoin_pin_cert_body, pending.serverHost), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pending.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pending.pem) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_accept)) + } + }, + dismissButton = { + TextButton(onClick = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_reject)) + } + }, + ) + } + Column { // ── Test button ──────────────────────────────────────── Button( @@ -208,20 +272,34 @@ private fun TestConnectionSection( if (!isTesting) { isTesting = true testResults = emptyList() + pendingCerts = emptyList() scope.launch { val results = mutableListOf() + val newCerts = mutableListOf() for (server in servers) { val result = onTestServer(server) results.add(result) testResults = results.toList() - // TOFU: auto-pin cert from successful connections + // Collect certs for user confirmation (not auto-pinned) val pem = result.serverCertPem - if (result.success && pem != null) { - onPinCert(pem) + val fp = result.certFingerprint + if (result.success && pem != null && fp != null) { + newCerts.add( + PendingCertPin( + serverHost = "${server.host}:${server.port}", + fingerprint = fp, + pem = pem, + ), + ) } } lastTestTimestamp = System.currentTimeMillis() isTesting = false + // Show confirmation dialog for each new cert + if (newCerts.isNotEmpty()) { + pendingCerts = newCerts + confirmingCert = newCerts.first() + } } } }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1f10ced90..38c3c142f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1853,6 +1853,10 @@ TLS Info No test run yet %dms + Trust Server Certificate? + The server %1$s presented a certificate not yet in your trust store. Verify the fingerprint below matches what the server operator published, then choose whether to trust it for future connections. + Trust + Reject Relay Sync Relay Sync Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. 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 fc831d0e2..5119a6eb7 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 @@ -611,13 +611,39 @@ class ElectrumXClient( // via the onion address, making TLS cert verification redundant. val sslFactory = if (server.host.endsWith(".onion")) { - onionSslFactory() + // .onion addresses: prefer the pinned factory (the .onion server's + // cert is already in PINNED_ELECTRUMX_CERTS). Fall back to trust-all + // only if the pinned handshake fails — this keeps compatibility with + // .onion servers whose certs aren't pinned yet, while avoiding a + // blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung + // Knox) may reject at the Conscrypt/BoringSSL layer. + cachedPinnedSslFactory() } else if (server.trustAllCerts) { cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory } - val sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + val sslSocket: Socket + try { + sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + } catch (e: javax.net.ssl.SSLHandshakeException) { + if (server.host.endsWith(".onion")) { + // Pinned factory failed for .onion — fall back to trust-all. + // This is safe: Tor provides E2E authentication via the onion + // address, and the proxied socket bypasses Knox/GrapheneOS + // trust-all rejection in practice. + val fallbackSocket = onionSslFactory().createSocket(baseSocket, server.host, server.port, true) + if (fallbackSocket is javax.net.ssl.SSLSocket) { + val supported = fallbackSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + fallbackSocket.enabledProtocols = modern.toTypedArray() + } + } + return fallbackSocket + } + throw e + } // Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS) // may negotiate TLS 1.0/1.1 by default for raw socket upgrades. @@ -633,15 +659,20 @@ class ElectrumXClient( } /** - * SSLSocketFactory for .onion addresses. + * Fallback SSLSocketFactory for .onion addresses when the pinned + * factory fails (e.g. cert rotated, unknown .onion server). * - * Tor hidden services are authenticated by their onion address (the - * public key hash), so TLS certificate verification is redundant. - * We use a trust-all factory here — this is safe because: + * Only used as a last resort after cachedPinnedSslFactory() throws + * SSLHandshakeException. This is safe because: * 1. The connection is already end-to-end encrypted by Tor. - * 2. The onion address IS the server's identity proof. - * 3. Samsung Knox's trust-all rejection doesn't apply to proxied - * sockets routed through Tor's SOCKS interface. + * 2. The onion address IS the server's identity proof (public key hash). + * 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all + * rejection (Samsung Knox, GrapheneOS hardened Conscrypt). + * + * Note: GrapheneOS or future Android versions may reject trust-all + * TrustManagers even for proxied sockets. If this fallback stops + * working, the .onion server's cert should be added to + * PINNED_ELECTRUMX_CERTS (it's already there for the known server). */ private fun onionSslFactory(): SSLSocketFactory { val trustAll =