implement tofu certificate pinning for tls bitcoin core connections
What changed, and why it matters
This commit adds certificate pinning for secure (TLS) connections to Bitcoin Core in the Sparrow Wallet. Previously, Sparrow trusted any certificate when talking to a user's own Bitcoin Core node over HTTPS, which could allow a network attacker to impersonate that node and intercept or alter wallet traffic. Now Sparrow records the first certificate it sees (a 'trust on first use' or TOFU model) and checks that future connections present the same certificate. It also keeps Electrum server certificates separate from Bitcoin Core ones. This is a security improvement, not a vulnerability being introduced.
No immediate user action required; this is a defensive improvement. Users connecting to Bitcoin Core over TLS should verify the first-seen certificate matches their node's certificate if they have not previously connected. Review whether disabling hostname verification is acceptable for the intended deployment model.
Security signals we found
Replaces trust-all X509TrustManager in BitcoindTransport with pinned-certificate trust manager
Adds TOFU certificate saving on first successful HTTPS connection to Bitcoin Core
Separates Bitcoin Core and Electrum TLS certificate storage by host cert filename suffix
Adds SSLHandshakeException handling and TlsServerException reporting for Bitcoin Core TLS failures
Fixes unclosed FileInputStream when loading pinned certificate
Evidence from the diff
The patch replaces BitcoindTransport’s trust-all hostname-verifier TLS setup with certificate pinning using Storage.getCertificateFile()/saveCertificate() and TcpOverTlsTransport.getTrustManagers(). It introduces a server-type suffix (.bitcoind) for saved Bitcoin Core certificates so they do not collide with Electrum certificates for the same host. It also adds a try-with-resources fix for FileInputStream and propagates SSLHandshakeException as TlsServerException in ElectrumServer. The hostname verifier still returns true for all hostnames, so hostname validation is not enforced; security relies entirely on the pinned certificate.
Changed components
Bitcoin Core TLS transport (BitcoindTransport)Electrum TLS transport (TcpOverTlsTransport)Certificate storage (Storage)ElectrumServer Bitcoin Core integrationInspect captured patch +58 / −28
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java
index 51147fa..3691766 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
+import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.SparrowWallet;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
@@ -504,7 +505,7 @@ public class Storage {
public static File getCertificateFile(String host) {
File certsDir = getCertsDir();
- File[] certs = certsDir.listFiles((dir, name) -> name.equals(host));
+ File[] certs = certsDir.listFiles((dir, name) -> name.equals(getCertName(host)));
if(certs != null && certs.length > 0) {
return certs[0];
}
@@ -513,7 +514,7 @@ public class Storage {
}
public static void saveCertificate(String host, Certificate cert) {
- try(FileWriter writer = new FileWriter(new File(getCertsDir(), host))) {
+ try(FileWriter writer = new FileWriter(new File(getCertsDir(), getCertName(host)))) {
writer.write("-----BEGIN CERTIFICATE-----\n");
writer.write(Base64.getEncoder().encodeToString(cert.getEncoded()).replaceAll("(.{64})", "$1\n"));
writer.write("\n-----END CERTIFICATE-----\n");
@@ -524,6 +525,14 @@ public class Storage {
}
}
+ private static String getCertName(String host) {
+ if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
+ return host + ".bitcoind";
+ }
+
+ return host;
+ }
+
static File getCertsDir() {
File certsDir = new File(getSparrowDir(), CERTS_DIR);
if(!certsDir.exists()) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
index 9112d12..d6fc103 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -31,6 +31,7 @@ import javafx.util.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.net.ssl.SSLHandshakeException;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
@@ -1435,6 +1436,12 @@ public class ElectrumServer {
bwtStartLock.unlock();
}
}
+ } catch(IllegalStateException e) {
+ if(e.getCause() instanceof SSLHandshakeException) {
+ throw new TlsServerException(Config.get().getCoreServer().getHostAndPort(), e.getCause());
+ } else {
+ throw e;
+ }
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java b/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java
index 9554ae2..6d6fdfd 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/TcpOverTlsTransport.java
@@ -23,7 +23,7 @@ public class TcpOverTlsTransport extends TcpTransport {
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException, CertificateException, KeyStoreException, IOException {
super(server);
- TrustManager[] trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()));
+ TrustManager[] trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()), server.getHost());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
@@ -34,7 +34,7 @@ public class TcpOverTlsTransport extends TcpTransport {
public TcpOverTlsTransport(HostAndPort server, File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
super(server);
- TrustManager[] trustManagers = getTrustManagers(crtFile);
+ TrustManager[] trustManagers = getTrustManagers(crtFile, server.getHost());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, null);
@@ -60,7 +60,7 @@ public class TcpOverTlsTransport extends TcpTransport {
}
}
- private TrustManager[] getTrustManagers(File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
+ public static TrustManager[] getTrustManagers(File crtFile, String host) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
if(crtFile == null) {
return new TrustManager[] {
new X509TrustManager() {
@@ -79,7 +79,7 @@ public class TcpOverTlsTransport extends TcpTransport {
try {
certs[0].checkValidity();
} catch(CertificateExpiredException e) {
- if(Storage.getCertificateFile(server.getHost()) == null) {
+ if(Storage.getCertificateFile(host) == null) {
throw new UnknownCertificateExpiredException(e.getMessage(), certs[0]);
}
}
@@ -88,7 +88,10 @@ public class TcpOverTlsTransport extends TcpTransport {
};
}
- Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
+ Certificate certificate;
+ try(FileInputStream fis = new FileInputStream(crtFile)) {
+ certificate = CertificateFactory.getInstance("X.509").generateCertificate(fis);
+ }
if(certificate instanceof X509Certificate) {
try {
X509Certificate x509Certificate = (X509Certificate)certificate;
@@ -98,7 +101,7 @@ public class TcpOverTlsTransport extends TcpTransport {
//These will usually be self-signed certificates that users may not have the expertise to renew
} catch(CertificateException e) {
crtFile.delete();
- return getTrustManagers(null);
+ return getTrustManagers(null, host);
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java
index 0ceda59..b7e9f8b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindTransport.java
@@ -4,7 +4,9 @@ import com.github.arteam.simplejsonrpc.client.Transport;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Server;
+import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Protocol;
+import com.sparrowwallet.sparrow.net.TcpOverTlsTransport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -14,8 +16,7 @@ import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.security.cert.CertificateException;
-import java.security.cert.X509Certificate;
+import java.security.cert.Certificate;
import java.util.Base64;
public class BitcoindTransport implements Transport {
@@ -27,6 +28,7 @@ public class BitcoindTransport implements Transport {
private File cookieFile;
private Long cookieFileTimestamp;
private String bitcoindAuthEncoded;
+ private SSLSocketFactory sslSocketFactory;
public BitcoindTransport(Server bitcoindServer, String bitcoindWallet, String bitcoindAuth) {
this(bitcoindServer, bitcoindWallet);
@@ -57,9 +59,10 @@ public class BitcoindTransport implements Transport {
HttpURLConnection connection = proxy != null && Protocol.isOnionAddress(bitcoindServer) ? (HttpURLConnection)bitcoindUrl.openConnection(proxy) : (HttpURLConnection)bitcoindUrl.openConnection();
if(connection instanceof HttpsURLConnection httpsURLConnection) {
- SSLSocketFactory sslSocketFactory = getTrustAllSocketFactory();
+ SSLSocketFactory sslSocketFactory = getSSLSocketFactory();
if(sslSocketFactory != null) {
httpsURLConnection.setSSLSocketFactory(sslSocketFactory);
+ httpsURLConnection.setHostnameVerifier((_, _) -> true);
}
}
@@ -81,6 +84,19 @@ public class BitcoindTransport implements Transport {
}
int statusCode = connection.getResponseCode();
+
+ if(connection instanceof HttpsURLConnection httpsConn && Storage.getCertificateFile(bitcoindServer.getHost()) == null) {
+ try {
+ Certificate[] certs = httpsConn.getServerCertificates();
+ if(certs.length > 0) {
+ Storage.saveCertificate(bitcoindServer.getHost(), certs[0]);
+ sslSocketFactory = null;
+ }
+ } catch(SSLPeerUnverifiedException e) {
+ log.warn("Could not retrieve certificate for saving", e);
+ }
+ }
+
if(statusCode == 401) {
throw new IOException((cookieFile == null ? "User/pass" : "Cookie file") + " authentication failed");
}
@@ -138,29 +154,24 @@ public class BitcoindTransport implements Transport {
return bitcoindDir;
}
- private SSLSocketFactory getTrustAllSocketFactory() {
- TrustManager[] trustAllCerts = new TrustManager[] {
- new X509TrustManager() {
- public X509Certificate[] getAcceptedIssuers() {
- return new X509Certificate[0];
- }
-
- public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
- }
+ private SSLSocketFactory getSSLSocketFactory() {
+ if(sslSocketFactory == null) {
+ sslSocketFactory = createSSLSocketFactory();
+ }
- public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
- }
- }
- };
+ return sslSocketFactory;
+ }
+ private SSLSocketFactory createSSLSocketFactory() {
try {
+ String host = bitcoindServer.getHost();
+ TrustManager[] trustManagers = TcpOverTlsTransport.getTrustManagers(Storage.getCertificateFile(host), host);
SSLContext sslContext = SSLContext.getInstance("TLS");
- sslContext.init(null, trustAllCerts, null);
+ sslContext.init(null, trustManagers, null);
return sslContext.getSocketFactory();
- } catch (Exception e) {
+ } catch(Exception e) {
log.error("Error creating SSL socket factory", e);
+ return null;
}
-
- return null;
}
}
Why this scored 59/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.