update external tor socks proxy control port authentication

This commit is contained in:
Craig Raw
2026-07-20 10:12:51 +02:00
parent d8e7b54cae
commit b3d51bce34

View File

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