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
|
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,
|
||||||
|
|||||||
+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.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+40
-9
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user