[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