use ca validation or tofu pinning for public servers depending on certificate type
What changed, and why it matters
This commit changes how Sparrow Wallet validates TLS certificates when connecting to public Electrum servers. Previously, the app avoided saving certificates for public servers and relied on user-approved certificate 'pinning' (TOFU). Now it tries to use normal certificate authority (CA) validation for public servers that have CA-signed certificates, while still using pinning for self-signed certificates. The change also deletes a saved pinned certificate if it expires on a public server, so the app can fetch a fresh, CA-validated one. This is a security-hardening change, not an obvious vulnerability fix, though the prior behavior could have made man-in-the-middle attacks against public servers easier if users routinely accepted changed certificates.
Review the new CA validation path for completeness: ensure hostname verification is enforced, that the isCaSigned helper correctly handles full chains and does not mask validation errors, and that fallback from expired pinned certificates to CA validation cannot be abused by an attacker who can delete or corrupt local cert files. Consider whether public-server TOFU pinning should be deprecated entirely in favor of CA validation.
Security signals we found
Change in TLS certificate validation strategy for public servers
Addition of CA trust manager path alongside existing pinned-certificate trust manager
New certificate storage helpers for CA-signed certificates (.cacert)
Deletion of expired pinned certificates for public servers to enable CA re-validation
UI error messages updated to mention CA validation failure
No explicit CVE, advisory, or vendor security disclosure in commit or references
Evidence from the diff
The patch modifies TcpOverTlsTransport to choose between CA trust managers and custom pinned-certificate trust managers based on whether a CA-signed certificate has been previously saved for the host. It adds Storage helpers for .cacert files, updates certificate-saving logic to distinguish CA-signed vs self-signed chains using the default TrustManagerFactory, and changes expiration handling so public servers delete stale pinned certs and fall back to CA validation. UI/UX paths in AppServices, ServerSettingsController, and ServerTestDialog now also look up the CA certificate file when warning users about certificate changes. The commit hardens TLS validation for public servers but is partial: it does not fully remove TOFU pinning and still allows expired pinned certificates for private servers.
Changed components
com.sparrowwallet.sparrow.net.TcpOverTlsTransportcom.sparrowwallet.sparrow.io.Storagecom.sparrowwallet.sparrow.AppServicescom.sparrowwallet.sparrow.settings.ServerSettingsControllercom.sparrowwallet.sparrow.terminal.settings.ServerTestDialogcom.sparrowwallet.sparrow.net.TlsServerExceptionInspect captured patch +80 / −13
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
index 3f54cb2..ec4fafb 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -328,6 +328,9 @@ public class AppServices {
"\n\nChange the configured server certificate if you would like to proceed.");
} else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
+ if(crtFile == null) {
+ crtFile = Storage.getCaCertificateFile(tlsServerException.getServer().getHost());
+ }
if(crtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java
index 3691766..b17b073 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java
@@ -504,8 +504,23 @@ public class Storage {
}
public static File getCertificateFile(String host) {
- File certsDir = getCertsDir();
- File[] certs = certsDir.listFiles((dir, name) -> name.equals(getCertName(host)));
+ return findCertFile(getCertName(host));
+ }
+
+ public static void saveCertificate(String host, Certificate cert) {
+ writeCertPem(getCertName(host), cert);
+ }
+
+ public static File getCaCertificateFile(String host) {
+ return findCertFile(host + ".cacert");
+ }
+
+ public static void saveCaCertificate(String host, Certificate cert) {
+ writeCertPem(host + ".cacert", cert);
+ }
+
+ private static File findCertFile(String filename) {
+ File[] certs = getCertsDir().listFiles((dir, name) -> name.equals(filename));
if(certs != null && certs.length > 0) {
return certs[0];
}
@@ -513,8 +528,8 @@ public class Storage {
return null;
}
- public static void saveCertificate(String host, Certificate cert) {
- try(FileWriter writer = new FileWriter(new File(getCertsDir(), getCertName(host)))) {
+ private static void writeCertPem(String filename, Certificate cert) {
+ try(FileWriter writer = new FileWriter(new File(getCertsDir(), filename))) {
writer.write("-----BEGIN CERTIFICATE-----\n");
writer.write(Base64.getEncoder().encodeToString(cert.getEncoded()).replaceAll("(.{64})", "$1\n"));
writer.write("\n-----END CERTIFICATE-----\n");
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java b/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java
index 6d6fdfd..06cd3da 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
+import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,7 +24,12 @@ public class TcpOverTlsTransport extends TcpTransport {
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException, CertificateException, KeyStoreException, IOException {
super(server);
- TrustManager[] trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()), server.getHost());
+ TrustManager[] trustManagers;
+ if(Storage.getCaCertificateFile(server.getHost()) != null) {
+ trustManagers = getCaTrustManagers();
+ } else {
+ trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()), server.getHost());
+ }
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
@@ -97,8 +103,11 @@ public class TcpOverTlsTransport extends TcpTransport {
X509Certificate x509Certificate = (X509Certificate)certificate;
x509Certificate.checkValidity();
} catch(CertificateExpiredException e) {
- //Allow expired certificates so long as they have been previously used or explicitly approved
- //These will usually be self-signed certificates that users may not have the expertise to renew
+ if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
+ crtFile.delete();
+ return getTrustManagers(null, host);
+ }
+ //Allow expired certificates for private servers where users may not have the expertise to renew
} catch(CertificateException e) {
crtFile.delete();
return getTrustManagers(null, host);
@@ -127,7 +136,11 @@ public class TcpOverTlsTransport extends TcpTransport {
try {
Certificate[] certs = event.getPeerCertificates();
if(certs.length > 0) {
- Storage.saveCertificate(server.getHost(), certs[0]);
+ if(isCaSigned(certs)) {
+ Storage.saveCaCertificate(server.getHost(), certs[0]);
+ } else {
+ Storage.saveCertificate(server.getHost(), certs[0]);
+ }
}
} catch(SSLPeerUnverifiedException e) {
log.warn("Attempting to retrieve certificate for unverified peer", e);
@@ -139,14 +152,42 @@ public class TcpOverTlsTransport extends TcpTransport {
}
protected boolean shouldSaveCertificate() {
- //Avoid saving the certificates for public servers - they change often, encourage approval complacency, and there is little a user can do to check
- for(PublicElectrumServer publicElectrumServer : PublicElectrumServer.getServers()) {
- if(publicElectrumServer.getServer().getHost().equals(server.getHost())) {
+ return Storage.getCertificateFile(server.getHost()) == null && Storage.getCaCertificateFile(server.getHost()) == null;
+ }
+
+ private static TrustManager[] getCaTrustManagers() throws NoSuchAlgorithmException, KeyStoreException {
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init((KeyStore)null);
+ return tmf.getTrustManagers();
+ }
+
+ private static boolean isCaSigned(Certificate[] certs) {
+ try {
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init((KeyStore)null);
+
+ X509TrustManager defaultTm = null;
+ for(TrustManager tm : tmf.getTrustManagers()) {
+ if(tm instanceof X509TrustManager) {
+ defaultTm = (X509TrustManager)tm;
+ break;
+ }
+ }
+
+ if(defaultTm == null) {
return false;
}
- }
- return Storage.getCertificateFile(server.getHost()) == null;
+ X509Certificate[] x509Certs = new X509Certificate[certs.length];
+ for(int i = 0; i < certs.length; i++) {
+ x509Certs[i] = (X509Certificate)certs[i];
+ }
+
+ defaultTm.checkServerTrusted(x509Certs, "RSA");
+ return true;
+ } catch(Exception e) {
+ return false;
+ }
}
@Override
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/TlsServerException.java b/src/main/java/com/sparrowwallet/sparrow/net/TlsServerException.java
index 08a4729..0a08168 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/TlsServerException.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/TlsServerException.java
@@ -42,6 +42,8 @@ public class TlsServerException extends ServerException {
return "Provided server certificate from " + server.getHost() + " did not match configured certificate at " + configCrtFile.getAbsolutePath();
} else if(savedCrtFile != null) {
return "Provided server certificate from " + server.getHost() + " did not match previously saved certificate at " + savedCrtFile.getAbsolutePath();
+ } else if(Storage.getCaCertificateFile(server.getHost()) != null) {
+ return "Provided server certificate from " + server.getHost() + " failed CA validation";
}
return "Provided server certificate from " + server.getHost() + " was invalid: " + (cause.getCause() != null ? cause.getCause().getMessage() : cause.getMessage());
diff --git a/src/main/java/com/sparrowwallet/sparrow/settings/ServerSettingsController.java b/src/main/java/com/sparrowwallet/sparrow/settings/ServerSettingsController.java
index 06a932d..fbc3c17 100644
--- a/src/main/java/com/sparrowwallet/sparrow/settings/ServerSettingsController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/settings/ServerSettingsController.java
@@ -670,6 +670,9 @@ public class ServerSettingsController extends SettingsDetailController {
if(exception.getCause().getMessage().contains("PKIX path building failed")) {
File configCrtFile = Config.get().getElectrumServerCert();
File savedCrtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
+ if(savedCrtFile == null) {
+ savedCrtFile = Storage.getCaCertificateFile(tlsServerException.getServer().getHost());
+ }
if(configCrtFile == null && savedCrtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/ServerTestDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/ServerTestDialog.java
index 3b39fc7..80bf289 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/ServerTestDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/ServerTestDialog.java
@@ -185,6 +185,9 @@ public class ServerTestDialog extends DialogWindow {
if(exception.getCause().getMessage().contains("PKIX path building failed")) {
File configCrtFile = Config.get().getElectrumServerCert();
File savedCrtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
+ if(savedCrtFile == null) {
+ savedCrtFile = Storage.getCaCertificateFile(tlsServerException.getServer().getHost());
+ }
if(configCrtFile == null && savedCrtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
Why this scored 52/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.