update external tor socks proxy control port authentication
What changed, and why it matters
This commit hardens how Sparrow Wallet talks to an external Tor proxy's control port. Previously, when asking Tor for a new identity, the app used an older authentication method that could send the contents of a secret cookie file over the local connection. The update switches to Tor's 'SAFECOOKIE' challenge-response authentication, verifies the server before revealing anything, and refuses to open the control connection unless the target address is on the local computer (loopback). This reduces the risk that a malicious or misconfigured remote proxy could steal the Tor cookie or abuse the control port.
Users relying on external Tor proxies should upgrade to the version containing this commit. Review any custom Tor proxy setups to ensure the ControlPort is bound to 127.0.0.1, since the new code will refuse remote control-port connections. No immediate incident response is indicated, but treat this as a worthwhile security hardening update.
Security signals we found
Switches Tor ControlPort authentication from legacy COOKIE to SAFECOOKIE HMAC challenge-response
Adds loopback-only restriction for external Tor ControlPort connections
Validates cookie file length and uses constant-time hash comparison
Verifies server hash before transmitting client hash to prevent leakage to unauthenticated peers
Adds explicit security-relevant comments describing the trust boundary
Evidence from the diff
The patch modifies TorUtils.java’s changeIdentity() and authenticate() flows for an external Tor SOCKS proxy. Key changes: (1) resolves the control host and rejects non-loopback addresses, preventing plaintext control/cookie access to remote hosts; (2) replaces raw COOKIE authentication with SAFECOOKIE HMAC challenge-response using HmacSHA256; (3) verifies the server’s hash before sending the client hash, mitigating oracle/leakage risks from a peer that cannot read the cookie file; (4) validates cookie file size (32 bytes) and uses MessageDigest.isEqual() for constant-time comparison. The commit is a defensive hardening fix rather than a confirmed vulnerability patch, but it clearly addresses a trust-boundary and authentication weakness.
Changed components
src/main/java/com/sparrowwallet/sparrow/net/TorUtils.javaExternal Tor SOCKS proxy control port authentication flowchangeIdentity() and authenticate() methodsInspect captured patch +83 / −8
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java b/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java
index 47e48cb..89cdcd8 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java
@@ -6,12 +6,19 @@ import com.sparrowwallet.sparrow.AppServices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
import java.io.*;
+import java.net.InetAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -19,13 +26,33 @@ public class TorUtils {
private static final Logger log = LoggerFactory.getLogger(TorUtils.class);
private static final Pattern TOR_OK = Pattern.compile("^2\\d{2}[ -]OK$");
private static final Pattern TOR_AUTH_METHODS = Pattern.compile("^2\\d{2}[ -]AUTH METHODS=(\\S+)\\s?(COOKIEFILE=\"?(.+?)\"?)?$");
+ private static final Pattern TOR_AUTH_CHALLENGE = Pattern.compile("^2\\d{2}[ -]AUTHCHALLENGE SERVERHASH=([0-9A-Fa-f]{64}) SERVERNONCE=([0-9A-Fa-f]{64})$");
+
+ private static final int COOKIE_LENGTH = 32;
+ private static final int CLIENT_NONCE_LENGTH = 32;
+ private static final byte[] SERVER_HASH_KEY = "Tor safe cookie authentication server-to-controller hash".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] CLIENT_HASH_KEY = "Tor safe cookie authentication controller-to-server hash".getBytes(StandardCharsets.US_ASCII);
public static void changeIdentity(HostAndPort proxy) {
if(AppServices.isTorRunning()) {
Tor.getDefault().changeIdentity();
} else {
HostAndPort control = HostAndPort.fromParts(proxy.getHost(), proxy.getPort() + 1);
- try(Socket socket = new Socket(control.getHost(), control.getPort())) {
+ InetAddress controlAddress;
+ try {
+ controlAddress = InetAddress.getByName(control.getHost());
+ } catch(IOException e) {
+ log.warn("Cannot resolve Tor ControlPort host " + control.getHost());
+ return;
+ }
+
+ //A Tor ControlPort is a loopback-only trust boundary; never open a plaintext control connection (and never read a cookie file) for a remote proxy
+ if(!controlAddress.isLoopbackAddress()) {
+ log.warn("Refusing to connect to non-loopback Tor ControlPort at " + control);
+ return;
+ }
+
+ try(Socket socket = new Socket(controlAddress, control.getPort())) {
socket.setSoTimeout(1500);
if(authenticate(socket)) {
writeNewNym(socket);
@@ -48,12 +75,13 @@ public class TorUtils {
socket.getOutputStream().write("PROTOCOLINFO\r\n".getBytes());
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String line;
+ String methods = "";
File cookieFile = null;
while((line = reader.readLine()) != null) {
Matcher authMatcher = TOR_AUTH_METHODS.matcher(line);
if(authMatcher.matches()) {
- String methods = authMatcher.group(1);
- if(methods.contains("COOKIE") && !authMatcher.group(3).isEmpty()) {
+ methods = authMatcher.group(1);
+ if(methods.contains("COOKIE") && authMatcher.group(3) != null && !authMatcher.group(3).isEmpty()) {
cookieFile = new File(authMatcher.group(3));
}
}
@@ -62,22 +90,69 @@ public class TorUtils {
}
}
- if(cookieFile != null && cookieFile.exists()) {
- byte[] cookieBytes = Files.readAllBytes(cookieFile.toPath());
- String authentication = "AUTHENTICATE " + Utils.bytesToHex(cookieBytes) + "\r\n";
- socket.getOutputStream().write(authentication.getBytes());
+ //Only use SAFECOOKIE, which authenticates via an HMAC challenge-response and never transmits the cookie contents. The deprecated COOKIE method is deliberately not supported.
+ if(methods.contains("SAFECOOKIE") && cookieFile != null) {
+ authenticateSafeCookie(socket, reader, cookieFile);
} else {
socket.getOutputStream().write("AUTHENTICATE \"\"\r\n".getBytes());
}
line = reader.readLine();
- if(TOR_OK.matcher(line).matches()) {
+ if(line != null && TOR_OK.matcher(line).matches()) {
return true;
} else {
throw new TorAuthenticationException(line);
}
}
+ private static void authenticateSafeCookie(Socket socket, BufferedReader reader, File cookieFile) throws IOException, TorAuthenticationException {
+ if(!cookieFile.exists()) {
+ throw new TorAuthenticationException("Cookie file " + cookieFile + " does not exist");
+ }
+ if(Files.size(cookieFile.toPath()) != COOKIE_LENGTH) {
+ throw new TorAuthenticationException("Cookie file " + cookieFile + " is not a valid Tor cookie");
+ }
+
+ byte[] cookieBytes = Files.readAllBytes(cookieFile.toPath());
+ if(cookieBytes.length != COOKIE_LENGTH) {
+ throw new TorAuthenticationException("Cookie file " + cookieFile + " is not a valid Tor cookie");
+ }
+
+ byte[] clientNonce = new byte[CLIENT_NONCE_LENGTH];
+ new SecureRandom().nextBytes(clientNonce);
+
+ socket.getOutputStream().write(("AUTHCHALLENGE SAFECOOKIE " + Utils.bytesToHex(clientNonce) + "\r\n").getBytes());
+ String line = reader.readLine();
+ Matcher challengeMatcher = line == null ? null : TOR_AUTH_CHALLENGE.matcher(line);
+ if(challengeMatcher == null || !challengeMatcher.matches()) {
+ throw new TorAuthenticationException(line);
+ }
+
+ byte[] serverHash = Utils.hexToBytes(challengeMatcher.group(1));
+ byte[] serverNonce = Utils.hexToBytes(challengeMatcher.group(2));
+ byte[] hmacMessage = Utils.concat(Utils.concat(cookieBytes, clientNonce), serverNonce);
+
+ //Verify the server proves it already knows the cookie before sending our own hash, so a peer that cannot read the cookie file learns nothing derived from it.
+ byte[] expectedServerHash = hmacSha256(SERVER_HASH_KEY, hmacMessage);
+ if(!MessageDigest.isEqual(expectedServerHash, serverHash)) {
+ throw new TorAuthenticationException("Tor ControlPort failed SAFECOOKIE server hash verification");
+ }
+
+ byte[] clientHash = hmacSha256(CLIENT_HASH_KEY, hmacMessage);
+ socket.getOutputStream().write(("AUTHENTICATE " + Utils.bytesToHex(clientHash) + "\r\n").getBytes());
+ }
+
+ private static byte[] hmacSha256(byte[] key, byte[] message) throws IOException {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+
+ return mac.doFinal(message);
+ } catch(GeneralSecurityException e) {
+ throw new IOException("Could not compute SAFECOOKIE HMAC", e);
+ }
+ }
+
private static void writeNewNym(Socket socket) throws IOException {
log.debug("Sending NEWNYM to " + socket);
socket.getOutputStream().write("SIGNAL NEWNYM\r\n".getBytes());
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.