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:
+6
-4
@@ -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,
|
||||
|
||||
+81
-3
@@ -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<List<ServerTestResult>>(emptyList()) }
|
||||
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
|
||||
|
||||
// ── 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<ServerTestResult>()
|
||||
val newCerts = mutableListOf<PendingCertPin>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1853,6 +1853,10 @@
|
||||
<string name="namecoin_tls_info">TLS Info</string>
|
||||
<string name="namecoin_no_test_yet">No test run yet</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_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>
|
||||
|
||||
+40
-9
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user