use ca validation or tofu pinning for public servers depending on certificate type

This commit is contained in:
Craig Raw 2026-02-27 13:30:46 +02:00
parent 46d444615c
commit a478edfad7
6 changed files with 80 additions and 13 deletions

View File

@ -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." +

View File

@ -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");

View File

@ -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

View File

@ -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());

View File

@ -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!" +

View File

@ -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!" +