[Git][java-team/orchid][upstream] 2 commits: Check host names in SSLEngineSSLSocket; pin the Gradle distribution; small hardening (2.5.2)

Masayuki Hatta (@mhatta) gitlab at salsa.debian.org
Sat Nov 7 12:10:02 GMT 2026



Masayuki Hatta pushed to branch upstream at Debian Java Maintainers / orchid


Commits:
ddc092c3 by Masayuki Hatta at 2026-11-07T20:44:44+09:00
Check host names in SSLEngineSSLSocket; pin the Gradle distribution; small hardening (2.5.2)

- - - - -
1f3d9435 by Masayuki Hatta at 2026-11-07T20:59:54+09:00
New upstream version 2.5.2+ds
- - - - -


20 changed files:

- .git-archival.txt
- .github/workflows/android.yml
- .github/workflows/ci.yml
- README.md
- gradle/wrapper/gradle-wrapper.properties
- samples/android/app/src/androidTest/java/org/mhatta/orchid/sample/TorConnectionTest.java
- samples/android/app/src/main/java/org/mhatta/orchid/sample/TorCheck.java
- samples/android/gradle/wrapper/gradle-wrapper.properties
- src/main/java/com/subgraph/orchid/circuits/CircuitManagerImpl.java
- src/main/java/com/subgraph/orchid/circuits/hs/HSV3DescriptorParser.java
- src/main/java/com/subgraph/orchid/crypto/Ed25519.java
- src/main/java/com/subgraph/orchid/crypto/TorNTorKeyAgreement.java
- src/main/java/com/subgraph/orchid/sockets/AndroidSSLSocketFactory.java
- + src/main/java/com/subgraph/orchid/sockets/sslengine/HostnameChecker.java
- src/main/java/com/subgraph/orchid/sockets/sslengine/SSLEngineManager.java
- src/main/java/com/subgraph/orchid/sockets/sslengine/SSLEngineSSLSocket.java
- test/com/subgraph/orchid/crypto/Ed25519Test.java
- + test/com/subgraph/orchid/crypto/TorNTorKeyAgreementTest.java
- + test/com/subgraph/orchid/sockets/sslengine/HostnameCheckerTest.java
- test/com/subgraph/orchid/sockets/sslengine/SSLEngineSSLSocketTest.java


Changes:

=====================================
.git-archival.txt
=====================================
@@ -1 +1 @@
-describe: v2.5.1
+describe: v2.5.2


=====================================
.github/workflows/android.yml
=====================================
@@ -42,6 +42,10 @@ jobs:
           java-version: '17'
 
       - uses: gradle/actions/setup-gradle at 748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4
+        with:
+          # Check gradle/wrapper/gradle-wrapper.jar against Gradle's published
+          # checksums (the default in v4, stated so that it stays on).
+          validate-wrappers: true
 
       # Hardware acceleration for the emulator
       - name: Enable KVM


=====================================
.github/workflows/ci.yml
=====================================
@@ -33,6 +33,10 @@ jobs:
           java-version: ${{ matrix.java }}
 
       - uses: gradle/actions/setup-gradle at 748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4
+        with:
+          # Check gradle/wrapper/gradle-wrapper.jar against Gradle's published
+          # checksums (the default in v4, stated so that it stays on).
+          validate-wrappers: true
 
       - name: Build and run tests
         run: ./gradlew build fatJar --no-daemon
@@ -65,6 +69,10 @@ jobs:
           java-version: '21'
 
       - uses: gradle/actions/setup-gradle at 748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4
+        with:
+          # Check gradle/wrapper/gradle-wrapper.jar against Gradle's published
+          # checksums (the default in v4, stated so that it stays on).
+          validate-wrappers: true
 
       # Build the same commit twice, in two directories and at two times,
       # and compare the jars byte for byte (see doc/RELEASING.md).


=====================================
README.md
=====================================
@@ -164,6 +164,8 @@ is a small app that does all of the following.  An app that embeds it should:
   `sslSocketFactory(factory, trustManager)`).  The platform's SSLSocket needs
   a socket with a file descriptor, which a Tor stream does not have; on older
   releases such as Android 7.0 the handshake fails with "Socket closed".
+  It checks the server's certificate against the host name itself, after the
+  handshake, since not every platform's SSLEngine does.
 * **Trust the Let's Encrypt roots on Android 7.0** if the app talks to sites
   with Let's Encrypt certificates: 7.0 (API 24) does not ship ISRG Root X1,
   so the handshake fails there whether or not Tor is used.  The sample adds


=====================================
gradle/wrapper/gradle-wrapper.properties
=====================================
@@ -1,5 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
+distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
 distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true


=====================================
samples/android/app/src/androidTest/java/org/mhatta/orchid/sample/TorConnectionTest.java
=====================================
@@ -2,17 +2,27 @@ package org.mhatta.orchid.sample;
 
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
+import java.io.IOException;
+import java.net.Socket;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.TrustManager;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import android.content.Context;
 import android.util.Log;
 
+import com.subgraph.orchid.TorClient;
+import com.subgraph.orchid.sockets.AndroidSSLSocketFactory;
+
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.platform.app.InstrumentationRegistry;
 
@@ -67,5 +77,35 @@ public class TorConnectionTest {
         }
         Log.i(TorCheck.TAG, "check.torproject.org: " + answer);
         assertTrue("not through Tor: " + answer, TorCheck.isTor(answer));
+
+        refusesCertificateForAnotherName(orchid.client());
+    }
+
+    /*
+     * AndroidSSLSocketFactory on its own, without OkHttp's host name check:
+     * wrong.host.badssl.com serves a valid certificate for another name, and
+     * the handshake must fail.  Skipped if the site cannot be reached.
+     */
+    private static void refusesCertificateForAnotherName(TorClient tor) throws Exception {
+        final String host = "wrong.host.badssl.com";
+        final Socket raw;
+        try {
+            raw = tor.getSocketFactory("orchid-sample-wrong-host").createSocket(host, 443);
+        } catch (IOException e) {
+            Log.w(TorCheck.TAG, "cannot reach " + host + "; host name check not tested", e);
+            return;
+        }
+        final SSLContext tls = SSLContext.getInstance("TLS");
+        tls.init(null, new TrustManager[] { TorCheck.platformTrustManager() }, null);
+        final javax.net.ssl.SSLSocket s =
+                (javax.net.ssl.SSLSocket) new AndroidSSLSocketFactory(tls).createSocket(raw, host, 443, true);
+        try {
+            s.startHandshake();
+            fail("certificate of " + host + " accepted: " + s.getSession().getPeerPrincipal());
+        } catch (SSLException e) {
+            Log.i(TorCheck.TAG, host + " refused as it should be: " + e);
+        } finally {
+            s.close();
+        }
     }
 }


=====================================
samples/android/app/src/main/java/org/mhatta/orchid/sample/TorCheck.java
=====================================
@@ -72,7 +72,7 @@ final class TorCheck {
     }
 
     /** The trust manager of the device's trusted CAs. */
-    private static X509TrustManager platformTrustManager() throws IOException {
+    static X509TrustManager platformTrustManager() throws IOException {
         try {
             final TrustManagerFactory factory =
                     TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());


=====================================
samples/android/gradle/wrapper/gradle-wrapper.properties
=====================================
@@ -1,5 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
+distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
 distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true


=====================================
src/main/java/com/subgraph/orchid/circuits/CircuitManagerImpl.java
=====================================
@@ -335,7 +335,7 @@ public class CircuitManagerImpl implements CircuitManager, DashboardRenderable {
 		}
 		final String lower = hostname.toLowerCase(java.util.Locale.ROOT);
 		if(lower.equals("localhost") || lower.endsWith(".localhost")) {
-			throw new OpenFailedException("Rejecting stream target with internal address: "+ hostname);
+			throw new OpenFailedException("Rejecting stream target with internal address: "+ forLogging(hostname));
 		}
 		if(IPv4Address.isValidIPv4AddressString(hostname)) {
 			maybeRejectInternalAddress(IPv4Address.createFromString(hostname));
@@ -349,10 +349,18 @@ public class CircuitManagerImpl implements CircuitManager, DashboardRenderable {
 	 */
 	private void maybeRejectInternalAddress(IPv4Address address) throws OpenFailedException {
 		if(config.getClientRejectInternalAddress() && isInternalAddress(address)) {
-			throw new OpenFailedException("Rejecting stream target with internal address: "+ address);
+			throw new OpenFailedException("Rejecting stream target with internal address: "+ forLogging(address.toString()));
 		}
 	}
 
+	/*
+	 * The SOCKS listener and applications log these messages: with
+	 * SafeLogging on, they must not name the destination.
+	 */
+	private String forLogging(String target) {
+		return config.getSafeLogging() ? com.subgraph.orchid.logging.SafeLogging.SCRUBBED : target;
+	}
+
 	static boolean isInternalAddress(IPv4Address address) {
 		final int a = address.getAddressData();
 		final int first = (a >>> 24) & 0xFF;


=====================================
src/main/java/com/subgraph/orchid/circuits/hs/HSV3DescriptorParser.java
=====================================
@@ -279,7 +279,12 @@ public final class HSV3DescriptorParser {
 			if(!encKeyCert.isValid(Ed25519Certificate.CERT_TYPE_HS_IP_CC_SIGNING, descriptorSigningKey, nowSeconds)) {
 				throw new TorParsingException("Invalid introduction point enc-key-cert certificate");
 			}
-			final byte[] expectedSubject = Ed25519.fromCurve25519PublicKey(encKey, false);
+			final byte[] expectedSubject;
+			try {
+				expectedSubject = Ed25519.fromCurve25519PublicKey(encKey, false);
+			} catch (IllegalArgumentException e) {
+				throw new TorParsingException("Invalid introduction point enc-key: "+ e.getMessage());
+			}
 			if(!Arrays.equals(expectedSubject, encKeyCert.getCertifiedKey())) {
 				logger.debug("Introduction point enc-key-cert subject does not match the converted enc-key");
 			}


=====================================
src/main/java/com/subgraph/orchid/crypto/Ed25519.java
=====================================
@@ -292,11 +292,16 @@ public final class Ed25519 {
 	 * Convert a curve25519 (Montgomery u coordinate) public key into the
 	 * corresponding Ed25519 public key with the given sign bit, using
 	 * y = (u - 1) / (u + 1)  (see proposal 228 / rend-spec-v3 enc-key-cert).
+	 *
+	 * @throws IllegalArgumentException for u = -1, which has no Ed25519 point.
 	 */
 	public static byte[] fromCurve25519PublicKey(byte[] curve25519PublicKey, boolean signBit) {
 		final byte[] copy = Arrays.copyOf(curve25519PublicKey, 32);
 		copy[31] &= 0x7F;
 		final BigInteger u = decodeLittleEndian(copy).mod(P);
+		if(u.add(BigInteger.ONE).mod(P).signum() == 0) {
+			throw new IllegalArgumentException("curve25519 key u = -1 has no Ed25519 equivalent");
+		}
 		final BigInteger y = u.subtract(BigInteger.ONE).multiply(u.add(BigInteger.ONE).modInverse(P)).mod(P);
 		final byte[] out = encodeLittleEndian(y);
 		if(signBit) {


=====================================
src/main/java/com/subgraph/orchid/crypto/TorNTorKeyAgreement.java
=====================================
@@ -8,6 +8,7 @@ import java.security.NoSuchAlgorithmException;
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
 
+import com.subgraph.orchid.TorException;
 import com.subgraph.orchid.data.HexDigest;
 import com.subgraph.orchid.misc.Utils;
 
@@ -67,9 +68,19 @@ public class TorNTorKeyAgreement implements TorKeyAgreement {
 	
 	private boolean isBad;
 
+	/**
+	 * Derive the circuit keys from the server's reply in CREATED2 or
+	 * EXTENDED2 (SERVER_PK | AUTH, 64 bytes).  Returns false if AUTH does not
+	 * check out; throws TorException if the reply is not 64 bytes long.
+	 */
 	public boolean deriveKeysFromHandshakeResponse(byte[] handshakeResponse, byte[] keyMaterialOut, byte[] verifyHashOut) {
 		isBad = false;
-				
+
+		final int expected = CURVE25519_PUBKEY_LEN + DIGEST256_LEN;
+		if(handshakeResponse == null || handshakeResponse.length != expected) {
+			throw new TorException("ntor handshake reply is "+ ((handshakeResponse == null) ? 0 : handshakeResponse.length) +" bytes long, not "+ expected);
+		}
+
 		final ByteBuffer hr = ByteBuffer.wrap(handshakeResponse);
 		byte[] serverPub = new byte[CURVE25519_PUBKEY_LEN];
 		byte[] authCandidate = new byte[DIGEST256_LEN];


=====================================
src/main/java/com/subgraph/orchid/sockets/AndroidSSLSocketFactory.java
=====================================
@@ -34,11 +34,17 @@ public class AndroidSSLSocketFactory extends SSLSocketFactory {
 		
 	}
 
+	/**
+	 * TLS over s (a socket from Orchid) through an SSLEngine.  The server's
+	 * certificate must be valid for host: SSLEngineSSLSocket checks the
+	 * name itself after the handshake, whatever the platform's engine does.
+	 * With autoClose false, closing the TLS socket leaves s open.
+	 */
 	@Override
 	public Socket createSocket(Socket s, String host, int port,
 			boolean autoClose) throws IOException {
 
-		return new SSLEngineSSLSocket(s, sslContext, host, port);
+		return new SSLEngineSSLSocket(s, sslContext, host, port, autoClose);
 	}
 
 	@Override


=====================================
src/main/java/com/subgraph/orchid/sockets/sslengine/HostnameChecker.java
=====================================
@@ -0,0 +1,190 @@
+package com.subgraph.orchid.sockets.sslengine;
+
+import java.net.IDN;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+
+/**
+ * Checks that a server certificate was issued for the host name the client
+ * asked for (RFC 6125, as browsers and OkHttp apply it).
+ *
+ * SSLEngineSSLSocket also asks the SSLEngine for this check
+ * (setEndpointIdentificationAlgorithm("HTTPS")), but whether the engine
+ * does it depends on the platform and on the trust manager: Android's
+ * engines do not promise it.  Over Tor the exit relay sits between the
+ * client and the server, and a certificate that is trusted but issued for
+ * another name must not be accepted, so the check is made here as well.
+ *
+ * The rules:
+ * <ul>
+ * <li>A host name matches a dNSName subject alternative name, compared
+ * without regard to case and to a final dot.  Names that are not ASCII are
+ * compared in their IDNA (A-label) form.</li>
+ * <li>A wildcard matches only as the whole leftmost label
+ * ("*.example.com"), stands for exactly one label, and needs at least two
+ * labels after it, so "*.com" matches nothing.</li>
+ * <li>An IPv4 literal matches an iPAddress subject alternative name.  IPv6
+ * literals are refused: Orchid does not open streams to them.</li>
+ * <li>The subject's common name is not used.</li>
+ * </ul>
+ *
+ * Nothing here resolves names, so no host name reaches the device's DNS.
+ */
+public final class HostnameChecker {
+    private static final int SAN_DNS_NAME = 2;
+    private static final int SAN_IP_ADDRESS = 7;
+
+    private HostnameChecker() {}
+
+    /**
+     * Throw SSLPeerUnverifiedException unless the leaf certificate of
+     * session was issued for host.
+     */
+    public static void verify(String host, SSLSession session) throws SSLPeerUnverifiedException {
+        final Certificate[] chain = session.getPeerCertificates();
+        if(chain.length == 0 || !(chain[0] instanceof X509Certificate)) {
+            throw new SSLPeerUnverifiedException("No X.509 certificate from the server");
+        }
+        if(!matches(host, (X509Certificate) chain[0])) {
+            throw new SSLPeerUnverifiedException("The server's certificate was not issued for the host name asked for");
+        }
+    }
+
+    /** True if certificate was issued for host. */
+    public static boolean matches(String host, X509Certificate certificate) {
+        if(host == null || host.isEmpty()) {
+            return false;
+        }
+        if(host.startsWith("[") || host.indexOf(':') >= 0) {
+            return false;
+        }
+        final byte[] ipv4 = parseIPv4(host);
+        final Collection<List<?>> names;
+        try {
+            names = certificate.getSubjectAlternativeNames();
+        } catch (CertificateParsingException e) {
+            return false;
+        }
+        if(names == null) {
+            return false;
+        }
+        if(ipv4 != null) {
+            return matchesIPv4(ipv4, names);
+        }
+        final String name = normalize(host);
+        if(name == null) {
+            return false;
+        }
+        for(List<?> entry: names) {
+            if(entry.size() < 2 || !(entry.get(0) instanceof Integer) || !(entry.get(1) instanceof String)) {
+                continue;
+            }
+            if(((Integer) entry.get(0)) == SAN_DNS_NAME && matchesDnsName(name, (String) entry.get(1))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean matchesIPv4(byte[] address, Collection<List<?>> names) {
+        for(List<?> entry: names) {
+            if(entry.size() < 2 || !(entry.get(0) instanceof Integer) || !(entry.get(1) instanceof String)) {
+                continue;
+            }
+            if(((Integer) entry.get(0)) != SAN_IP_ADDRESS) {
+                continue;
+            }
+            final byte[] san = parseIPv4((String) entry.get(1));
+            if(san != null && java.util.Arrays.equals(address, san)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /* Lower case ASCII without a final dot, or null if the name is not usable. */
+    private static String normalize(String name) {
+        String s = name;
+        if(s.endsWith(".")) {
+            s = s.substring(0, s.length() - 1);
+        }
+        if(s.isEmpty() || s.startsWith(".") || s.contains("..")) {
+            return null;
+        }
+        if(!isAscii(s)) {
+            try {
+                s = IDN.toASCII(s);
+            } catch (IllegalArgumentException e) {
+                return null;
+            }
+        }
+        return s.toLowerCase(Locale.ROOT);
+    }
+
+    private static boolean matchesDnsName(String host, String pattern) {
+        final String p = normalize(pattern);
+        if(p == null) {
+            return false;
+        }
+        if(!p.contains("*")) {
+            return host.equals(p);
+        }
+        // Only "*.rest", with at least two labels in rest.
+        if(!p.startsWith("*.") || p.indexOf('*', 1) >= 0) {
+            return false;
+        }
+        final String rest = p.substring(1);    // ".example.com"
+        if(rest.indexOf('.', 1) < 0) {
+            return false;
+        }
+        if(!host.endsWith(rest)) {
+            return false;
+        }
+        final String first = host.substring(0, host.length() - rest.length());
+        return !first.isEmpty() && first.indexOf('.') < 0;
+    }
+
+    /* Four bytes for a dotted quad of decimal numbers 0 to 255, else null. */
+    private static byte[] parseIPv4(String s) {
+        final String[] parts = s.split("\\.", -1);
+        if(parts.length != 4) {
+            return null;
+        }
+        final byte[] out = new byte[4];
+        for(int i = 0; i < 4; i++) {
+            final String part = parts[i];
+            if(part.isEmpty() || part.length() > 3) {
+                return null;
+            }
+            int v = 0;
+            for(int j = 0; j < part.length(); j++) {
+                final char c = part.charAt(j);
+                if(c < '0' || c > '9') {
+                    return null;
+                }
+                v = v * 10 + (c - '0');
+            }
+            if(v > 255) {
+                return null;
+            }
+            out[i] = (byte) v;
+        }
+        return out;
+    }
+
+    private static boolean isAscii(String s) {
+        for(int i = 0; i < s.length(); i++) {
+            if(s.charAt(i) > 0x7F) {
+                return false;
+            }
+        }
+        return true;
+    }
+}


=====================================
src/main/java/com/subgraph/orchid/sockets/sslengine/SSLEngineManager.java
=====================================
@@ -58,6 +58,18 @@ public class SSLEngineManager {
 
     private final HandshakeCallbackHandler handshakeCallback;
 
+    /* The name the server's certificate must be valid for, or null for no check. */
+    private final String verifyHost;
+
+    /* Why the initial handshake failed, if it did; set before handshakeDone. */
+    private volatile IOException handshakeFailure;
+
+    /* The peer closed the connection without close_notify (see wasTruncated()). */
+    private volatile boolean truncated = false;
+
+    /* False after close(false): the underlying output stays open after close_notify. */
+    private volatile boolean closeUnderlying = true;
+
     /*
      * Serializes the initial handshake.  A thread waits here, not on
      * readLock, for a handshake in progress: a reader holds readLock while
@@ -71,8 +83,18 @@ public class SSLEngineManager {
 
 
     SSLEngineManager(SSLEngine engine, HandshakeCallbackHandler handshakeCallback, InputStream input, OutputStream output) {
+        this(engine, handshakeCallback, input, output, null);
+    }
+
+    /**
+     * @param verifyHost After the initial handshake, the server's
+     *  certificate must be valid for this name (see HostnameChecker), or
+     *  every read and write fails.  null for no check.
+     */
+    SSLEngineManager(SSLEngine engine, HandshakeCallbackHandler handshakeCallback, InputStream input, OutputStream output, String verifyHost) {
         this.engine = engine;
         this.handshakeCallback = handshakeCallback;
+        this.verifyHost = verifyHost;
         this.input = input;
         this.output = output;
         final SSLSession session = engine.getSession();
@@ -102,10 +124,15 @@ public class SSLEngineManager {
      * (see ensureHandshake()), so no other thread holds readLock or
      * writeLock here; both are taken because the handshake uses the
      * buffers they guard.
+     *
+     * If the handshake fails, or the server's certificate is not valid for
+     * verifyHost, the connection is closed and this call, and every later
+     * read, write and handshake, throws.
      */
     void startHandshake() throws IOException {
         synchronized(handshakeLock) {
             if(handshakeDone) {
+                throwIfHandshakeFailed();
                 return;
             }
             readLock.lock();
@@ -115,6 +142,15 @@ public class SSLEngineManager {
                     logger.debug("startHandshake()");
                     engine.beginHandshake();
                     runHandshake(true);
+                    if(verifyHost != null) {
+                        HostnameChecker.verify(verifyHost, engine.getSession());
+                    }
+                } catch (IOException e) {
+                    failHandshake(e);
+                    throw e;
+                } catch (RuntimeException e) {
+                    failHandshake(new SSLException("TLS handshake failed", e));
+                    throw e;
                 } finally {
                     writeLock.unlock();
                 }
@@ -125,10 +161,41 @@ public class SSLEngineManager {
         }
     }
 
+    /* Called with both locks held: remember why, and close the connection. */
+    private void failHandshake(IOException cause) {
+        handshakeFailure = cause;
+        engine.closeOutbound();
+        try {
+            output.close();
+        } catch (IOException e) {
+            logger.debug("Closing after a failed TLS handshake: "+ e);
+        }
+    }
+
+    private void throwIfHandshakeFailed() throws IOException {
+        final IOException failure = handshakeFailure;
+        if(failure != null) {
+            throw new SSLException("TLS handshake failed: "+ failure.getMessage(), failure);
+        }
+    }
+
+    /**
+     * True if the peer closed the connection without a close_notify.  read()
+     * reports that as the end of the stream, as many HTTP servers close this
+     * way; but over Tor the exit relay can also cut a stream short, so a
+     * protocol that does not carry its own length (HTTP/1.0 without
+     * Content-Length, for one) should check this before trusting the data.
+     */
+    boolean wasTruncated() {
+        return truncated;
+    }
+
     /* Must be called without holding readLock or writeLock. */
     private void ensureHandshake() throws IOException {
         if(!handshakeDone) {
             startHandshake();
+        } else {
+            throwIfHandshakeFailed();
         }
     }
 
@@ -311,7 +378,9 @@ public class SSLEngineManager {
                  * before, this is reported as the end of the stream (many
                  * HTTP servers close this way); SSLEngine#closeInbound() is
                  * not called because it would throw for the truncation.
+                 * wasTruncated() tells it apart.
                  */
+                truncated = true;
                 return -1;
             }
         }
@@ -327,6 +396,15 @@ public class SSLEngineManager {
      * unblocks the reader and the writer.
      */
     void close() throws IOException {
+        close(true);
+    }
+
+    /**
+     * As close(), but closeUnderlying false leaves the underlying output
+     * stream open (SSLSocketFactory#createSocket with autoClose false).
+     */
+    void close(boolean closeUnderlying) throws IOException {
+        this.closeUnderlying = closeUnderlying;
         boolean locked = false;
         try {
             locked = writeLock.tryLock(CLOSE_LOCK_TIMEOUT, TimeUnit.MILLISECONDS);
@@ -334,7 +412,7 @@ public class SSLEngineManager {
             Thread.currentThread().interrupt();
         }
         try {
-            if(locked && handshakeDone && !engine.isOutboundDone()) {
+            if(locked && handshakeDone && handshakeFailure == null && !engine.isOutboundDone()) {
                 flush();
                 engine.closeOutbound();
                 runHandshake(false);
@@ -343,7 +421,9 @@ public class SSLEngineManager {
             if(locked) {
                 writeLock.unlock();
             }
-            output.close();
+            if(closeUnderlying) {
+                output.close();
+            }
         }
     }
 
@@ -470,7 +550,7 @@ public class SSLEngineManager {
 
         switch(result.getStatus()) {
         case CLOSED:
-            if(engine.isOutboundDone()) {
+            if(engine.isOutboundDone() && closeUnderlying) {
                 output.close();
             }
             return false;


=====================================
src/main/java/com/subgraph/orchid/sockets/sslengine/SSLEngineSSLSocket.java
=====================================
@@ -27,6 +27,9 @@ public class SSLEngineSSLSocket extends SSLSocket implements HandshakeCallbackHa
 	private InputStream inputStream;
 	private OutputStream outputStream;
 	private final List<HandshakeCompletedListener> listenerList;
+	/* Whether close() also closes socket. */
+	private final boolean autoClose;
+	private volatile boolean closed = false;
 	/**
 	 * A TLS socket over <code>socket</code> that checks that the server's
 	 * certificate is valid for <code>host</code> (RFC 2818 / RFC 6125, as
@@ -35,11 +38,26 @@ public class SSLEngineSSLSocket extends SSLSocket implements HandshakeCallbackHa
 	 * the client and the server, so a certificate chain that is merely
 	 * trusted, but issued for another name, must be rejected.
 	 *
+	 * The name is checked by the SSLEngine (endpoint identification) and,
+	 * since not every platform's engine does that (Android's do not promise
+	 * it), once more after the handshake by {@link HostnameChecker}.  If
+	 * the check fails, the connection is closed and every read and write
+	 * throws.
+	 *
 	 * @param host The name of the server, as the application asked for it.
 	 * @param port The server's port.
 	 */
 	public SSLEngineSSLSocket(Socket socket, SSLContext context, String host, int port) throws IOException {
-		this(socket, createSSLEngine(context, host, port));
+		this(socket, context, host, port, true);
+	}
+
+	/**
+	 * As {@link #SSLEngineSSLSocket(Socket, SSLContext, String, int)}; with
+	 * autoClose false, close() sends close_notify but leaves socket open
+	 * (see {@link javax.net.ssl.SSLSocketFactory#createSocket(Socket, String, int, boolean)}).
+	 */
+	public SSLEngineSSLSocket(Socket socket, SSLContext context, String host, int port, boolean autoClose) throws IOException {
+		this(socket, createSSLEngine(context, host, port), host, autoClose);
 	}
 
 	/**
@@ -50,16 +68,27 @@ public class SSLEngineSSLSocket extends SSLSocket implements HandshakeCallbackHa
 	 */
 	@Deprecated
 	public SSLEngineSSLSocket(Socket socket, SSLContext context) throws IOException {
-		this(socket, createSSLEngine(context, null, -1));
+		this(socket, createSSLEngine(context, null, -1), null, true);
 	}
 
-	private SSLEngineSSLSocket(Socket socket, SSLEngine engine) throws IOException {
+	private SSLEngineSSLSocket(Socket socket, SSLEngine engine, String verifyHost, boolean autoClose) throws IOException {
 		this.engine = engine;
 		this.socket = socket;
-		this.manager = new SSLEngineManager(engine, this, socket.getInputStream(), socket.getOutputStream());
+		this.autoClose = autoClose;
+		this.manager = new SSLEngineManager(engine, this, socket.getInputStream(), socket.getOutputStream(), verifyHost);
 		this.listenerList = new CopyOnWriteArrayList<HandshakeCompletedListener>();
 	}
 
+	/**
+	 * True if the server closed the connection without a TLS close_notify.
+	 * Reads report that as the end of the stream; but over Tor the exit
+	 * relay can also cut a stream short, so a protocol that does not carry
+	 * its own length should check this before it trusts what it read.
+	 */
+	public boolean wasTruncated() {
+		return manager.wasTruncated();
+	}
+
 	private static SSLEngine createSSLEngine(SSLContext context, String host, int port) {
 		if(host == null) {
 			final SSLEngine engine = context.createSSLEngine();
@@ -295,21 +324,24 @@ public class SSLEngineSSLSocket extends SSLSocket implements HandshakeCallbackHa
 
 	/**
 	 * Send close_notify (unless a writer is stuck) and close the underlying
-	 * socket.  May be called from any thread, also while another thread is
-	 * blocked reading.
+	 * socket, unless this socket was made with autoClose false.  May be
+	 * called from any thread, also while another thread is blocked reading.
 	 */
 	@Override
 	public void close() throws IOException {
+		closed = true;
 		try {
-			manager.close();
+			manager.close(autoClose);
 		} finally {
-			socket.close();
+			if(autoClose) {
+				socket.close();
+			}
 		}
 	}
 
 	@Override
 	public boolean isClosed() {
-		return socket.isClosed();
+		return closed || socket.isClosed();
 	}
 
 	@Override


=====================================
test/com/subgraph/orchid/crypto/Ed25519Test.java
=====================================
@@ -112,4 +112,10 @@ public class Ed25519Test {
 		final BigInteger v = new BigInteger("123456789012345678901234567890123456789012345678901234567890");
 		assertEquals(v, Ed25519.decodeLittleEndian(Ed25519.encodeLittleEndian(v)));
 	}
+
+	/* u = -1 (p - 1) has no Ed25519 point; it used to throw ArithmeticException. */
+	@Test(expected = IllegalArgumentException.class)
+	public void testCurve25519MinusOneRefused() {
+		Ed25519.fromCurve25519PublicKey(unhex("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"), false);
+	}
 }


=====================================
test/com/subgraph/orchid/crypto/TorNTorKeyAgreementTest.java
=====================================
@@ -0,0 +1,49 @@
+package com.subgraph.orchid.crypto;
+
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+import com.subgraph.orchid.TorException;
+import com.subgraph.orchid.data.HexDigest;
+
+/**
+ * A CREATED2 or EXTENDED2 reply of the wrong length is refused with a
+ * TorException; it used to end in a BufferUnderflowException.
+ */
+public class TorNTorKeyAgreementTest {
+
+	private static TorNTorKeyAgreement agreement() {
+		final HexDigest identity = HexDigest.createFromDigestBytes(new byte[20]);
+		final byte[] onionKey = new byte[32];
+		onionKey[0] = 9;
+		final TorNTorKeyAgreement kex = new TorNTorKeyAgreement(identity, onionKey);
+		kex.createOnionSkin();
+		return kex;
+	}
+
+	private static void refused(byte[] reply) {
+		try {
+			agreement().deriveKeysFromHandshakeResponse(reply, new byte[72], new byte[20]);
+			fail("a reply of "+ ((reply == null) ? "null" : reply.length +" bytes") +" was accepted");
+		} catch(TorException e) {
+			// expected
+		}
+	}
+
+	@Test
+	public void testShortReplyRefused() {
+		refused(new byte[0]);
+		refused(new byte[63]);
+	}
+
+	@Test
+	public void testLongReplyRefused() {
+		refused(new byte[65]);
+	}
+
+	@Test
+	public void testNullReplyRefused() {
+		refused(null);
+	}
+}


=====================================
test/com/subgraph/orchid/sockets/sslengine/HostnameCheckerTest.java
=====================================
@@ -0,0 +1,110 @@
+package com.subgraph.orchid.sockets.sslengine;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * HostnameChecker against a certificate whose only subject common name is
+ * cn-only.example.net and whose subject alternative names are
+ *   DNS:example.com, DNS:*.example.org, DNS:*.com, DNS:f*o.example.net,
+ *   DNS:xn--bcher-kva.example (b\u00fccher.example), IP:192.0.2.1
+ * Made with:
+ *   openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes
+ *     -days 36500 -subj /CN=cn-only.example.net -addext subjectAltName=...
+ */
+public class HostnameCheckerTest {
+	private final static String CERTIFICATE =
+		"-----BEGIN CERTIFICATE-----\n" +
+		"MIIB8TCCAZegAwIBAgIUdZ4PRMhkk8GQJNzLJ6T3YfW8lqEwCgYIKoZIzj0EAwIw\n" +
+		"HjEcMBoGA1UEAwwTY24tb25seS5leGFtcGxlLm5ldDAgFw0yNjExMDYyMDE3NDda\n" +
+		"GA8yMTI2MTAxMzIwMTc0N1owHjEcMBoGA1UEAwwTY24tb25seS5leGFtcGxlLm5l\n" +
+		"dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABISdgRZ81ykrY1KRBuy/Z8pzDgmc\n" +
+		"ZgIyBjfh0u+rlhFQOtkAtpxGUu4FfleWA+9Tf6xiWCqFI3CFrP3hUI9YZnyjgbAw\n" +
+		"ga0wHQYDVR0OBBYEFOoKwkSTPG2ojhy11a89s9RlXMQtMB8GA1UdIwQYMBaAFOoK\n" +
+		"wkSTPG2ojhy11a89s9RlXMQtMA8GA1UdEwEB/wQFMAMBAf8wWgYDVR0RBFMwUYIL\n" +
+		"ZXhhbXBsZS5jb22CDSouZXhhbXBsZS5vcmeCBSouY29tgg9mKm8uZXhhbXBsZS5u\n" +
+		"ZXSCFXhuLS1iY2hlci1rdmEuZXhhbXBsZYcEwAACATAKBggqhkjOPQQDAgNIADBF\n" +
+		"AiEA21jEUzGYurVloywhA3yrMOgsPFa3d4oTDVfcMDJOuZgCICF/ufthUgvZK1Rd\n" +
+		"NhBOM7XPpmklapHbhBhypTeJEUHr\n" +
+		"-----END CERTIFICATE-----\n";
+
+	private static X509Certificate certificate;
+
+	@BeforeClass
+	public static void load() throws Exception {
+		certificate = (X509Certificate) CertificateFactory.getInstance("X.509")
+				.generateCertificate(new ByteArrayInputStream(CERTIFICATE.getBytes("US-ASCII")));
+	}
+
+	private static void accepts(String host) {
+		assertTrue(host, HostnameChecker.matches(host, certificate));
+	}
+
+	private static void refuses(String host) {
+		assertFalse(host, HostnameChecker.matches(host, certificate));
+	}
+
+	@Test
+	public void exactNames() {
+		accepts("example.com");
+		accepts("EXAMPLE.com");
+		accepts("example.com.");
+		refuses("www.example.com");
+		refuses("example.co");
+		refuses("xample.com");
+	}
+
+	@Test
+	public void wildcards() {
+		accepts("www.example.org");
+		accepts("WWW.Example.ORG.");
+		refuses("example.org");
+		refuses("a.b.example.org");
+		refuses(".example.org");
+		// "*.com" covers a whole top-level domain and matches nothing
+		refuses("anything.com");
+		// a wildcard is the whole leftmost label or nothing
+		refuses("foo.example.net");
+		refuses("fo.example.net");
+	}
+
+	@Test
+	public void internationalizedNames() {
+		accepts("b\u00fccher.example");
+		accepts("xn--bcher-kva.example");
+		refuses("bucher.example");
+	}
+
+	@Test
+	public void ipAddresses() {
+		accepts("192.0.2.1");
+		refuses("192.0.2.2");
+		refuses("192.0.2.256");
+		refuses("192.0.2");
+		// a host name is not compared with the iPAddress entry, nor the other way round
+		refuses("1.2.0.192.in-addr.arpa");
+		// IPv6 literals are always refused
+		refuses("::1");
+		refuses("[2001:db8::1]");
+	}
+
+	@Test
+	public void commonNameIsNotUsed() {
+		refuses("cn-only.example.net");
+	}
+
+	@Test
+	public void malformedNames() {
+		refuses(null);
+		refuses("");
+		refuses(".");
+		refuses("example..com");
+	}
+}


=====================================
test/com/subgraph/orchid/sockets/sslengine/SSLEngineSSLSocketTest.java
=====================================
@@ -13,6 +13,8 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.InetAddress;
 import java.net.Socket;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
 import java.security.KeyStore;
 import java.util.Arrays;
 import java.util.Random;
@@ -25,7 +27,12 @@ import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLException;
 import javax.net.ssl.SSLServerSocket;
 import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.TrustManager;
 import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509ExtendedTrustManager;
+import javax.net.ssl.X509TrustManager;
 
 import org.junit.After;
 import org.junit.Assume;
@@ -413,4 +420,162 @@ public class SSLEngineSSLSocketTest {
 			s.close();
 		}
 	}
+
+	/*
+	 * A trust manager that checks the chain but never the host name, as an
+	 * engine that ignores endpoint identification behaves (Android's do not
+	 * promise it).
+	 */
+	private static SSLContext nameBlindContext() throws Exception {
+		final KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
+		trust.load(null, null);
+		trust.setCertificateEntry("test", keyStore().getCertificate("test"));
+		final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+		tmf.init(trust);
+		X509TrustManager found = null;
+		for(TrustManager tm: tmf.getTrustManagers()) {
+			if(tm instanceof X509TrustManager) {
+				found = (X509TrustManager) tm;
+			}
+		}
+		final X509TrustManager chainOnly = found;
+		final X509ExtendedTrustManager blind = new X509ExtendedTrustManager() {
+			public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+				chainOnly.checkServerTrusted(chain, authType);
+			}
+			public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException {
+				chainOnly.checkServerTrusted(chain, authType);
+			}
+			public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws CertificateException {
+				chainOnly.checkServerTrusted(chain, authType);
+			}
+			public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+				throw new CertificateException("client side only");
+			}
+			public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException {
+				throw new CertificateException("client side only");
+			}
+			public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws CertificateException {
+				throw new CertificateException("client side only");
+			}
+			public X509Certificate[] getAcceptedIssuers() {
+				return chainOnly.getAcceptedIssuers();
+			}
+		};
+		final SSLContext context = SSLContext.getInstance("TLS");
+		context.init(null, new TrustManager[] { blind }, null);
+		return context;
+	}
+
+	private void startSilentServer() throws Exception {
+		startServer("TLSv1.3".equals(bestProtocol()) ? "TLSv1.3" : "TLSv1.2", new ServerBehaviour() {
+			public void serve(SSLSocket s) throws IOException {
+				try {
+					s.startHandshake();
+					readToEof(s.getInputStream());
+				} catch(IOException e) {
+					// the client hangs up after refusing the certificate
+				}
+			}
+		});
+	}
+
+	private static String bestProtocol() throws Exception {
+		return Arrays.asList(SSLContext.getDefault().getSupportedSSLParameters().getProtocols()).contains("TLSv1.3") ? "TLSv1.3" : "TLSv1.2";
+	}
+
+	/*
+	 * Even if the engine does not check the host name, SSLEngineSSLSocket
+	 * must: the handshake fails, and so does everything after it, so that no
+	 * request goes to a server whose certificate is for another name.
+	 */
+	@Test(timeout = 30000)
+	public void testHostNameCheckedWhenTheEngineDoesNot() throws Exception {
+		startSilentServer();
+		final Socket raw = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
+		raw.setSoTimeout(10000);
+		final SSLEngineSSLSocket s = new SSLEngineSSLSocket(raw, nameBlindContext(), "wrong.orchid.invalid", 443);
+		try {
+			try {
+				s.startHandshake();
+				fail("certificate for "+ HOST +" accepted for wrong.orchid.invalid");
+			} catch(SSLPeerUnverifiedException e) {
+				// expected
+			}
+			try {
+				s.getOutputStream().write("GET / HTTP/1.0\r\n\r\n".getBytes("US-ASCII"));
+				fail("data written after a refused certificate");
+			} catch(SSLException e) {
+				// expected
+			}
+			try {
+				s.getInputStream().read();
+				fail("data read after a refused certificate");
+			} catch(SSLException e) {
+				// expected
+			}
+		} finally {
+			s.close();
+		}
+	}
+
+	/* The same blind engine, with the right name: the check lets it through. */
+	@Test(timeout = 30000)
+	public void testRightNameAcceptedWhenTheEngineDoesNotCheck() throws Exception {
+		startServer(bestProtocol(), new ServerBehaviour() {
+			public void serve(SSLSocket s) throws IOException {
+				readFully(s.getInputStream(), 4);
+				s.getOutputStream().write("pong".getBytes("US-ASCII"));
+				s.getOutputStream().flush();
+				s.getInputStream().read();
+			}
+		});
+		final Socket raw = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
+		raw.setSoTimeout(10000);
+		final SSLEngineSSLSocket s = new SSLEngineSSLSocket(raw, nameBlindContext(), HOST, 443);
+		try {
+			s.getOutputStream().write("ping".getBytes("US-ASCII"));
+			s.getOutputStream().flush();
+			assertArrayEquals("pong".getBytes("US-ASCII"), readFully(s.getInputStream(), 4));
+		} finally {
+			s.close();
+		}
+		assertTrue(String.valueOf(serverFailure.get()), serverFailure.get() == null);
+	}
+
+	/* autoClose false: close() ends TLS but leaves the underlying socket open. */
+	@Test(timeout = 30000)
+	public void testAutoCloseFalseKeepsTheSocket() throws Exception {
+		startSilentServer();
+		final Socket raw = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
+		raw.setSoTimeout(10000);
+		final SSLEngineSSLSocket s = new SSLEngineSSLSocket(raw, clientContext(), HOST, 443, false);
+		s.startHandshake();
+		s.close();
+		assertTrue(s.isClosed());
+		assertFalse("autoClose false closed the underlying socket", raw.isClosed());
+		raw.close();
+	}
+
+	/* A close with close_notify is not a truncation. */
+	@Test(timeout = 30000)
+	public void testOrderlyCloseIsNotTruncation() throws Exception {
+		final String protocol = bestProtocol();
+		startServer(protocol, new ServerBehaviour() {
+			public void serve(SSLSocket s) throws IOException {
+				readFully(s.getInputStream(), 1);
+				s.getOutputStream().write("bye".getBytes("US-ASCII"));
+				s.getOutputStream().flush();
+			}
+		});
+		final SSLEngineSSLSocket s = connect(HOST);
+		try {
+			s.getOutputStream().write('x');
+			s.getOutputStream().flush();
+			assertArrayEquals("bye".getBytes("US-ASCII"), readToEof(s.getInputStream()));
+			assertFalse(s.wasTruncated());
+		} finally {
+			s.close();
+		}
+	}
 }



View it on GitLab: https://salsa.debian.org/java-team/orchid/-/compare/4673b79cc96f0ada97cdcc38a340b0b7a66735e0...1f3d9435bcccec2f2187658fcb13cec4e1bfd118

-- 
View it on GitLab: https://salsa.debian.org/java-team/orchid/-/compare/4673b79cc96f0ada97cdcc38a340b0b7a66735e0...1f3d9435bcccec2f2187658fcb13cec4e1bfd118
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-java-commits/attachments/20261107/1dfa0b0e/attachment.htm>


More information about the pkg-java-commits mailing list