fix: harden TLS for GrapheneOS and security-conscious Android ROMs

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.
This commit is contained in:
M
2026-03-26 06:26:18 +11:00
parent a4953f628d
commit 7f451ee7c7
4 changed files with 131 additions and 16 deletions
@@ -77,10 +77,12 @@ 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 // All custom servers set trustAllCerts=true, which (despite the
// almost universally use self-signed certificates. The actual // legacy name) means "use the pinned trust store" rather than
// trust is handled by pinned certs (hardcoded defaults + any // "trust all certificates". ElectrumX servers almost universally
// certs the user has accepted via Test Connection TOFU). // 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( return ElectrumxServer(
host = host, host = host,
port = port, port = port,
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -188,6 +189,15 @@ fun NamecoinSettingsSection(
// ── Test Connection ──────────────────────────────────────────────────── // ── 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 @Composable
private fun TestConnectionSection( private fun TestConnectionSection(
settings: NamecoinSettings, settings: NamecoinSettings,
@@ -198,9 +208,63 @@ private fun TestConnectionSection(
var isTesting by remember { mutableStateOf(false) } var isTesting by remember { mutableStateOf(false) }
var testResults by remember { mutableStateOf<List<ServerTestResult>>(emptyList()) } var testResults by remember { mutableStateOf<List<ServerTestResult>>(emptyList()) }
var lastTestTimestamp by remember { mutableStateOf<Long?>(null) } var lastTestTimestamp by remember { mutableStateOf<Long?>(null) }
// Certs discovered during testing that need user confirmation
var pendingCerts by remember { mutableStateOf<List<PendingCertPin>>(emptyList()) }
// Which cert is currently shown in the confirmation dialog
var confirmingCert by remember { mutableStateOf<PendingCertPin?>(null) }
val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS 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 { Column {
// ── Test button ──────────────────────────────────────── // ── Test button ────────────────────────────────────────
Button( Button(
@@ -208,20 +272,34 @@ private fun TestConnectionSection(
if (!isTesting) { if (!isTesting) {
isTesting = true isTesting = true
testResults = emptyList() testResults = emptyList()
pendingCerts = emptyList()
scope.launch { scope.launch {
val results = mutableListOf<ServerTestResult>() val results = mutableListOf<ServerTestResult>()
val newCerts = mutableListOf<PendingCertPin>()
for (server in servers) { for (server in servers) {
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 // Collect certs for user confirmation (not auto-pinned)
val pem = result.serverCertPem val pem = result.serverCertPem
if (result.success && pem != null) { val fp = result.certFingerprint
onPinCert(pem) if (result.success && pem != null && fp != null) {
newCerts.add(
PendingCertPin(
serverHost = "${server.host}:${server.port}",
fingerprint = fp,
pem = pem,
),
)
} }
} }
lastTestTimestamp = System.currentTimeMillis() lastTestTimestamp = System.currentTimeMillis()
isTesting = false isTesting = false
// Show confirmation dialog for each new cert
if (newCerts.isNotEmpty()) {
pendingCerts = newCerts
confirmingCert = newCerts.first()
}
} }
} }
}, },
+4
View File
@@ -1853,6 +1853,10 @@
<string name="namecoin_tls_info">TLS Info</string> <string name="namecoin_tls_info">TLS Info</string>
<string name="namecoin_no_test_yet">No test run yet</string> <string name="namecoin_no_test_yet">No test run yet</string>
<string name="namecoin_response_time">%dms</string> <string name="namecoin_response_time">%dms</string>
<string name="namecoin_pin_cert_title">Trust Server Certificate?</string>
<string name="namecoin_pin_cert_body">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.</string>
<string name="namecoin_pin_cert_accept">Trust</string>
<string name="namecoin_pin_cert_reject">Reject</string>
<string name="event_sync_title">Relay Sync</string> <string name="event_sync_title">Relay Sync</string>
<string name="event_sync_section">Relay Sync</string> <string name="event_sync_section">Relay Sync</string>
<string name="event_sync_section_explainer">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.</string> <string name="event_sync_section_explainer">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.</string>
@@ -611,13 +611,39 @@ class ElectrumXClient(
// via the onion address, making TLS cert verification redundant. // via the onion address, making TLS cert verification redundant.
val sslFactory = val sslFactory =
if (server.host.endsWith(".onion")) { 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) { } else if (server.trustAllCerts) {
cachedPinnedSslFactory() cachedPinnedSslFactory()
} else { } else {
SSLSocketFactory.getDefault() as SSLSocketFactory 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) // Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS)
// may negotiate TLS 1.0/1.1 by default for raw socket upgrades. // 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 * Only used as a last resort after cachedPinnedSslFactory() throws
* public key hash), so TLS certificate verification is redundant. * SSLHandshakeException. This is safe because:
* We use a trust-all factory here — this is safe because:
* 1. The connection is already end-to-end encrypted by Tor. * 1. The connection is already end-to-end encrypted by Tor.
* 2. The onion address IS the server's identity proof. * 2. The onion address IS the server's identity proof (public key hash).
* 3. Samsung Knox's trust-all rejection doesn't apply to proxied * 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all
* sockets routed through Tor's SOCKS interface. * 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 { private fun onionSslFactory(): SSLSocketFactory {
val trustAll = val trustAll =