diff --git a/CHANGELOG.md b/CHANGELOG.md index ee444a8d3da..73bb1759a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Fix a D8 crash (`NullPointerException: Cannot invoke "String.length()"`) when dexing the SDK with AGP 8.0 and older, a regression from 8.61.1 ([#6356](https://github.com/getsentry/sentry-java/pull/6356)) - Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152)) - Prevent Reactor pool maintenance tasks from retaining unbounded Sentry scope chains ([#6283](https://github.com/getsentry/sentry-java/pull/6283)) +- Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) ## 8.61.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09ac7a2113e..6281e4a68bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -291,6 +291,7 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.4. mockito-core = { module = "org.mockito:mockito-core", version = "5.24.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } # apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt index ed2bbd18b1d..9ffaf19949b 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -2,6 +2,7 @@ package io.sentry.android.distribution import io.sentry.SentryLevel import io.sentry.SentryOptions +import io.sentry.transport.DistrustedCertificates import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -89,8 +90,9 @@ internal class DistributionHttpClient(private val options: SentryOptions) { connection.connectTimeout = options.connectionTimeoutMillis connection.readTimeout = options.readTimeoutMillis - if (connection is HttpsURLConnection && options.sslSocketFactory != null) { - connection.sslSocketFactory = options.sslSocketFactory + if (connection is HttpsURLConnection) { + connection.sslSocketFactory = + DistrustedCertificates.wrap(options.sslSocketFactory ?: connection.sslSocketFactory) } val responseCode = connection.responseCode diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2890298efb5..6e1ebe4ff30 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7777,6 +7777,10 @@ public final class io/sentry/transport/CurrentDateProvider : io/sentry/transport public static fun getInstance ()Lio/sentry/transport/ICurrentDateProvider; } +public final class io/sentry/transport/DistrustedCertificates { + public static fun wrap (Ljavax/net/ssl/SSLSocketFactory;)Ljavax/net/ssl/SSLSocketFactory; +} + public abstract interface class io/sentry/transport/ICurrentDateProvider { public abstract fun getCurrentTimeMillis ()J } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 70f0068a5c3..b1621c440dc 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) testImplementation(libs.msgpack) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.okio) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java new file mode 100644 index 00000000000..390586999cc --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java @@ -0,0 +1,81 @@ +package io.sentry.transport; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocketFactory; +import javax.security.auth.x500.X500Principal; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Rejects servers whose certificate was issued by a CA that is known to be compromised but still + * trusted by platform trust stores. + * + *

Browsers block such certificates on their own and require Certificate Transparency, but {@link + * javax.net.ssl.HttpsURLConnection} does neither. Android only updates its CA store with OS updates + * (or Mainline on Android 14+) and the JVM only with JDK updates, so a distrust by the root + * programs doesn't reach every device. + */ +@ApiStatus.Internal +public final class DistrustedCertificates { + + /** + * The eight eMudhra intermediates whose private keys can be recovered from their certificates + * (CVE-2026-111943). Each key is in two certificates with the same subject: one issued by eMudhra + * and a cross-sign by Sectigo's AAA Certificate Services root, which is trusted on every Android + * version. + * + *

They are matched by name rather than by key because the server doesn't have to send them: + * Android caches intermediates from earlier connections and completes a chain that is missing + * one. The server always sends the leaf, which names its issuer, and since none of these CAs may + * issue further CAs ({@code pathlen:0}), any certificate they issued is a leaf. + * + * @see Compromised + * eMudhra Intermediate Certificates + */ + private static final Set DISTRUSTED_CAS = + new HashSet<>( + Arrays.asList( + new X500Principal("CN=EM DV TLS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM EV TLS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM OV TLS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM OV CS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM DV TLS CA - G3C, O=eMudhra Inc, C=US"), + new X500Principal("CN=EM EV TLS CA - G3C, O=eMudhra Inc, C=US"), + new X500Principal("CN=EM OV TLS CA - G3C, O=eMudhra Inc, C=US"), + new X500Principal("CN=EM OV CS CA - G3C, O=eMudhra Inc, C=US"))); + + private DistrustedCertificates() {} + + /** + * Wraps {@code delegate} so that every TLS handshake of its sockets is checked, including the + * ones {@link java.net.HttpURLConnection} makes on its own to retry a request on a new + * connection. + */ + public static @NotNull SSLSocketFactory wrap(final @NotNull SSLSocketFactory delegate) { + return new DistrustedCertificatesSocketFactory(delegate); + } + + /** + * @param peerCertificates the certificates the server sent, leaf first + */ + static void check(final @NotNull Certificate[] peerCertificates) + throws SSLPeerUnverifiedException { + for (final Certificate certificate : peerCertificates) { + if (certificate instanceof X509Certificate && isDistrusted((X509Certificate) certificate)) { + throw new SSLPeerUnverifiedException( + "The server's certificate chain contains a distrusted certificate."); + } + } + } + + private static boolean isDistrusted(final @NotNull X509Certificate certificate) { + return DISTRUSTED_CAS.contains(certificate.getIssuerX500Principal()) + || DISTRUSTED_CAS.contains(certificate.getSubjectX500Principal()); + } +} diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java new file mode 100644 index 00000000000..9957f32a974 --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java @@ -0,0 +1,403 @@ +package io.sentry.transport; + +import java.io.FilterInputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.channels.SocketChannel; +import javax.net.ssl.HandshakeCompletedListener; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import org.jetbrains.annotations.NotNull; + +/** + * Delegates to an {@link SSLSocket} and runs {@link DistrustedCertificates#check} once its + * handshake is done. + * + *

The HTTP stacks of the JVM and Android call {@link #startHandshake()} before they send + * anything. The streams check again before every read and write, so that application data can't + * flow over a handshake that was started implicitly. They can't check when they are created, + * because the JVM gets the output stream before it configures the socket for the handshake. + */ +// The delegate synchronizes its own state, so the methods Socket synchronizes on the wrapper don't +// need to. +@SuppressWarnings("UnsynchronizedOverridesSynchronized") +final class DistrustedCertificatesSocket extends SSLSocket { + + private final @NotNull SSLSocket delegate; + private volatile boolean verified; + + DistrustedCertificatesSocket(final @NotNull SSLSocket delegate) { + this.delegate = delegate; + } + + @Override + public void startHandshake() throws IOException { + delegate.startHandshake(); + verify(); + } + + @Override + public InputStream getInputStream() throws IOException { + return new VerifyingInputStream(delegate.getInputStream()); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return new VerifyingOutputStream(delegate.getOutputStream()); + } + + private void verify() throws IOException { + if (verified) { + return; + } + try { + DistrustedCertificates.check(delegate.getSession().getPeerCertificates()); + } catch (SSLPeerUnverifiedException e) { + delegate.close(); + throw e; + } + verified = true; + } + + private final class VerifyingInputStream extends FilterInputStream { + VerifyingInputStream(final @NotNull InputStream in) { + super(in); + } + + @Override + public int read() throws IOException { + verify(); + return in.read(); + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + verify(); + return in.read(b, off, len); + } + + @Override + public long skip(final long n) throws IOException { + verify(); + return in.skip(n); + } + } + + private final class VerifyingOutputStream extends FilterOutputStream { + VerifyingOutputStream(final @NotNull OutputStream out) { + super(out); + } + + @Override + public void write(final int b) throws IOException { + verify(); + out.write(b); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + verify(); + out.write(b, off, len); + } + } + + // Everything below delegates unchanged. + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public String[] getEnabledCipherSuites() { + return delegate.getEnabledCipherSuites(); + } + + @Override + public void setEnabledCipherSuites(final String[] suites) { + delegate.setEnabledCipherSuites(suites); + } + + @Override + public String[] getSupportedProtocols() { + return delegate.getSupportedProtocols(); + } + + @Override + public String[] getEnabledProtocols() { + return delegate.getEnabledProtocols(); + } + + @Override + public void setEnabledProtocols(final String[] protocols) { + delegate.setEnabledProtocols(protocols); + } + + @Override + public SSLSession getSession() { + return delegate.getSession(); + } + + @Override + public void addHandshakeCompletedListener(final HandshakeCompletedListener listener) { + delegate.addHandshakeCompletedListener(listener); + } + + @Override + public void removeHandshakeCompletedListener(final HandshakeCompletedListener listener) { + delegate.removeHandshakeCompletedListener(listener); + } + + @Override + public void setUseClientMode(final boolean mode) { + delegate.setUseClientMode(mode); + } + + @Override + public boolean getUseClientMode() { + return delegate.getUseClientMode(); + } + + @Override + public void setNeedClientAuth(final boolean need) { + delegate.setNeedClientAuth(need); + } + + @Override + public boolean getNeedClientAuth() { + return delegate.getNeedClientAuth(); + } + + @Override + public void setWantClientAuth(final boolean want) { + delegate.setWantClientAuth(want); + } + + @Override + public boolean getWantClientAuth() { + return delegate.getWantClientAuth(); + } + + @Override + public void setEnableSessionCreation(final boolean flag) { + delegate.setEnableSessionCreation(flag); + } + + @Override + public boolean getEnableSessionCreation() { + return delegate.getEnableSessionCreation(); + } + + @Override + public SSLParameters getSSLParameters() { + return delegate.getSSLParameters(); + } + + @Override + public void setSSLParameters(final SSLParameters params) { + delegate.setSSLParameters(params); + } + + @Override + public void connect(final SocketAddress endpoint) throws IOException { + delegate.connect(endpoint); + } + + @Override + public void connect(final SocketAddress endpoint, final int timeout) throws IOException { + delegate.connect(endpoint, timeout); + } + + @Override + public void bind(final SocketAddress bindpoint) throws IOException { + delegate.bind(bindpoint); + } + + @Override + public InetAddress getInetAddress() { + return delegate.getInetAddress(); + } + + @Override + public InetAddress getLocalAddress() { + return delegate.getLocalAddress(); + } + + @Override + public int getPort() { + return delegate.getPort(); + } + + @Override + public int getLocalPort() { + return delegate.getLocalPort(); + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return delegate.getRemoteSocketAddress(); + } + + @Override + public SocketAddress getLocalSocketAddress() { + return delegate.getLocalSocketAddress(); + } + + @Override + public SocketChannel getChannel() { + return delegate.getChannel(); + } + + @Override + public void setTcpNoDelay(final boolean on) throws SocketException { + delegate.setTcpNoDelay(on); + } + + @Override + public boolean getTcpNoDelay() throws SocketException { + return delegate.getTcpNoDelay(); + } + + @Override + public void setSoLinger(final boolean on, final int linger) throws SocketException { + delegate.setSoLinger(on, linger); + } + + @Override + public int getSoLinger() throws SocketException { + return delegate.getSoLinger(); + } + + @Override + public void sendUrgentData(final int data) throws IOException { + delegate.sendUrgentData(data); + } + + @Override + public void setOOBInline(final boolean on) throws SocketException { + delegate.setOOBInline(on); + } + + @Override + public boolean getOOBInline() throws SocketException { + return delegate.getOOBInline(); + } + + @Override + public void setSoTimeout(final int timeout) throws SocketException { + delegate.setSoTimeout(timeout); + } + + @Override + public int getSoTimeout() throws SocketException { + return delegate.getSoTimeout(); + } + + @Override + public void setSendBufferSize(final int size) throws SocketException { + delegate.setSendBufferSize(size); + } + + @Override + public int getSendBufferSize() throws SocketException { + return delegate.getSendBufferSize(); + } + + @Override + public void setReceiveBufferSize(final int size) throws SocketException { + delegate.setReceiveBufferSize(size); + } + + @Override + public int getReceiveBufferSize() throws SocketException { + return delegate.getReceiveBufferSize(); + } + + @Override + public void setKeepAlive(final boolean on) throws SocketException { + delegate.setKeepAlive(on); + } + + @Override + public boolean getKeepAlive() throws SocketException { + return delegate.getKeepAlive(); + } + + @Override + public void setTrafficClass(final int tc) throws SocketException { + delegate.setTrafficClass(tc); + } + + @Override + public int getTrafficClass() throws SocketException { + return delegate.getTrafficClass(); + } + + @Override + public void setReuseAddress(final boolean on) throws SocketException { + delegate.setReuseAddress(on); + } + + @Override + public boolean getReuseAddress() throws SocketException { + return delegate.getReuseAddress(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public void shutdownInput() throws IOException { + delegate.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + delegate.shutdownOutput(); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public boolean isBound() { + return delegate.isBound(); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public boolean isInputShutdown() { + return delegate.isInputShutdown(); + } + + @Override + public boolean isOutputShutdown() { + return delegate.isOutputShutdown(); + } + + @Override + public void setPerformancePreferences( + final int connectionTime, final int latency, final int bandwidth) { + delegate.setPerformancePreferences(connectionTime, latency, bandwidth); + } + + @Override + public String toString() { + return delegate.toString(); + } +} diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java new file mode 100644 index 00000000000..13bc120be49 --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java @@ -0,0 +1,104 @@ +package io.sentry.transport; + +import io.sentry.util.Platform; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.net.InetAddress; +import java.net.Socket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import org.jetbrains.annotations.NotNull; + +/** Creates sockets that run {@link DistrustedCertificates#check} after every TLS handshake. */ +final class DistrustedCertificatesSocketFactory extends SSLSocketFactory { + + /** + * Conscrypt on Android 5 only sends SNI after {@code setHostname} has been called, which + * Android's HTTP stack does by reflection on the socket it gets. That call silently does nothing + * on our wrapper, so we make it on the delegate instead. Later versions send SNI by default, and + * calling the method there would access a hidden API. + */ + private static final boolean ENABLE_SNI_BY_REFLECTION = + Platform.isAndroid() && androidSdkInt() < 23; + + private final @NotNull SSLSocketFactory delegate; + + DistrustedCertificatesSocketFactory(final @NotNull SSLSocketFactory delegate) { + this.delegate = delegate; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return wrap(delegate.createSocket()); + } + + @Override + public Socket createSocket( + final Socket socket, final String host, final int port, final boolean autoClose) + throws IOException { + final Socket sslSocket = delegate.createSocket(socket, host, port, autoClose); + if (ENABLE_SNI_BY_REFLECTION) { + setHostname(sslSocket, host); + } + return wrap(sslSocket); + } + + @Override + public Socket createSocket(final String host, final int port) throws IOException { + return wrap(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket( + final String host, final int port, final InetAddress localHost, final int localPort) + throws IOException { + return wrap(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(final InetAddress host, final int port) throws IOException { + return wrap(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket( + final InetAddress address, + final int port, + final InetAddress localAddress, + final int localPort) + throws IOException { + return wrap(delegate.createSocket(address, port, localAddress, localPort)); + } + + private static @NotNull Socket wrap(final @NotNull Socket socket) { + return socket instanceof SSLSocket + ? new DistrustedCertificatesSocket((SSLSocket) socket) + : socket; + } + + private static void setHostname(final @NotNull Socket socket, final @NotNull String host) { + try { + socket.getClass().getMethod("setHostname", String.class).invoke(socket, host); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + // Not a Conscrypt socket, which leaves SNI as the delegate configured it. + } + } + + private static int androidSdkInt() { + try { + return Class.forName("android.os.Build$VERSION").getField("SDK_INT").getInt(null); + } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException e) { + return Integer.MAX_VALUE; + } + } +} diff --git a/sentry/src/main/java/io/sentry/transport/HttpConnection.java b/sentry/src/main/java/io/sentry/transport/HttpConnection.java index e7935e76cf3..34abeb31835 100644 --- a/sentry/src/main/java/io/sentry/transport/HttpConnection.java +++ b/sentry/src/main/java/io/sentry/transport/HttpConnection.java @@ -136,10 +136,12 @@ HttpURLConnection open() throws IOException { connection.setConnectTimeout(options.getConnectionTimeoutMillis()); connection.setReadTimeout(options.getReadTimeoutMillis()); - final SSLSocketFactory sslSocketFactory = options.getSslSocketFactory(); - - if (connection instanceof HttpsURLConnection && sslSocketFactory != null) { - ((HttpsURLConnection) connection).setSSLSocketFactory(sslSocketFactory); + if (connection instanceof HttpsURLConnection) { + final HttpsURLConnection httpsConnection = (HttpsURLConnection) connection; + final @Nullable SSLSocketFactory sslSocketFactory = options.getSslSocketFactory(); + httpsConnection.setSSLSocketFactory( + DistrustedCertificates.wrap( + sslSocketFactory != null ? sslSocketFactory : httpsConnection.getSSLSocketFactory())); } connection.connect(); diff --git a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt index faa2b2e0ba4..f288a1b7c56 100644 --- a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt +++ b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt @@ -30,7 +30,8 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail import okio.BufferedSource -import okio.Okio +import okio.buffer +import okio.source import org.junit.Assert import org.junit.Assert.assertArrayEquals import org.junit.Before @@ -432,7 +433,7 @@ class QueueFileTest { queueFile.remove() queueFile.close() - val source: BufferedSource = Okio.buffer(Okio.source(file)) + val source: BufferedSource = file.source().buffer() source.skip(headerLength.toLong()) source.skip(Element.HEADER_LENGTH.toLong()) assertEquals(source.readByteString(4).hex(), "00000000") @@ -445,7 +446,7 @@ class QueueFileTest { queueFile.remove() queueFile.close() - val source: BufferedSource = Okio.buffer(Okio.source(file)) + val source: BufferedSource = file.source().buffer() source.skip(headerLength.toLong()) source.skip(Element.HEADER_LENGTH.toLong()) assertEquals(source.readByteString(4).hex(), "04030201") diff --git a/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt new file mode 100644 index 00000000000..994a0a438e7 --- /dev/null +++ b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt @@ -0,0 +1,204 @@ +package io.sentry.transport + +import com.google.common.truth.Truth.assertThat +import io.sentry.JsonSerializer +import io.sentry.RequestDetails +import io.sentry.SentryEnvelope +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import java.io.IOException +import java.math.BigInteger +import java.net.InetAddress +import java.net.Socket +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPair +import java.security.PrivateKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPrivateKeySpec +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSocketFactory +import kotlin.test.Test +import kotlin.test.assertFailsWith +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.Rule + +class DistrustedCertificatesTest { + + @get:Rule val server = MockWebServer() + + private val emSignRoot = certificate("emsign-root-tls-ca-g3.pem") + + /** + * EM OV TLS CA - G3C, issued by emSign Root TLS CA - G3. Its private key is 4, as OkHttp found: + * https://github.com/square/okhttp/blob/master/okhttp/src/commonTest/kotlin/okhttp3/CompromisedIntermediateTest.kt + */ + private val distrustedCa = + certificate("emudhra-em-ov-tls-ca-g3c.pem").let { + HeldCertificate(KeyPair(it.publicKey, secp384r1PrivateKey(4)), it) + } + + private val trustedCa = HeldCertificate.Builder().certificateAuthority(0).build() + + private val options = SentryOptions().apply { setSerializer(JsonSerializer(this)) } + + @Test + fun `rejects a certificate issued by a distrusted CA`() { + assertFailsWith { + DistrustedCertificates.check(arrayOf(leafIssuedBy(distrustedCa).certificate)) + } + } + + @Test + fun `rejects a distrusted CA cross-signed by AAA Certificate Services`() { + assertFailsWith { + DistrustedCertificates.check(arrayOf(certificate("emudhra-em-dv-tls-ca-g3b.pem"))) + } + } + + @Test + fun `accepts a chain without distrusted CAs`() { + DistrustedCertificates.check( + arrayOf(leafIssuedBy(trustedCa).certificate, trustedCa.certificate) + ) + } + + @Test + fun `sends the envelope to a server whose certificate a trusted CA issued`() { + server.useHttps(serverCertificates(leafIssuedBy(trustedCa)), false) + server.enqueue(MockResponse()) + + val result = send(trusting(emSignRoot, trustedCa.certificate)) + + assertThat(result.isSuccess).isTrue() + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `still verifies the hostname`() { + server.useHttps(serverCertificates(leafIssuedBy(trustedCa, "sentry.example")), false) + + assertFailsWith { send(trusting(trustedCa.certificate)) } + + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `does not send the envelope to a server whose certificate a distrusted CA issued`() { + server.useHttps( + serverCertificates(leafIssuedBy(distrustedCa), distrustedCa.certificate), + false, + ) + + assertFailsWith { send(trusting(emSignRoot)) } + + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `does not send the envelope when the client completes the chain with a distrusted CA the server left out`() { + // Like Android, which completes the chain from intermediates it cached on earlier connections. + server.useHttps(serverCertificates(leafIssuedBy(distrustedCa)), false) + + assertFailsWith { send(trusting(distrustedCa.certificate)) } + + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `does not send the envelope again when HttpURLConnection retries it on a connection to a distrusted CA`() { + server.useHttps( + PerConnectionSocketFactory( + serverCertificates(leafIssuedBy(trustedCa)), + serverCertificates(leafIssuedBy(distrustedCa), distrustedCa.certificate), + ), + false, + ) + // The JVM sends a POST again on a new connection when the first one closes before a response. + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + server.enqueue(MockResponse()) + + val result = send(trusting(emSignRoot, trustedCa.certificate)) + + assertThat(result.isSuccess).isFalse() + assertThat(server.requestCount).isEqualTo(1) + } + + private fun send(sslSocketFactory: SSLSocketFactory): TransportResult { + options.sslSocketFactory = sslSocketFactory + val requestDetails = RequestDetails(server.url("/api/1/envelope/").toString(), emptyMap()) + val connection = HttpConnection(options, requestDetails, RateLimiter(options)) + return connection.send(SentryEnvelope.from(options.serializer, SentryEvent(), null)) + } + + private fun leafIssuedBy( + issuer: HeldCertificate, + hostname: String = server.hostName, + ): HeldCertificate = + HeldCertificate.Builder().addSubjectAlternativeName(hostname).signedBy(issuer).build() + + private fun serverCertificates( + leaf: HeldCertificate, + vararg intermediates: X509Certificate, + ): SSLSocketFactory = + HandshakeCertificates.Builder().heldCertificate(leaf, *intermediates).build().sslSocketFactory() + + private fun trusting(vararg certificates: X509Certificate): SSLSocketFactory = + HandshakeCertificates.Builder() + .apply { certificates.forEach { addTrustedCertificate(it) } } + .build() + .sslSocketFactory() + + private fun certificate(resource: String): X509Certificate = + javaClass.classLoader!!.getResourceAsStream(resource)!!.use { + CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate + } + + private fun secp384r1PrivateKey(value: Long): PrivateKey { + val secp384r1 = + AlgorithmParameters.getInstance("EC").run { + init(ECGenParameterSpec("secp384r1")) + getParameterSpec(ECParameterSpec::class.java) + } + return KeyFactory.getInstance("EC") + .generatePrivate(ECPrivateKeySpec(BigInteger.valueOf(value), secp384r1)) + } + + /** Presents the certificates of [first] on the first connection and of [then] on later ones. */ + private class PerConnectionSocketFactory( + private val first: SSLSocketFactory, + private val then: SSLSocketFactory, + ) : SSLSocketFactory() { + private var connections = 0 + + private fun next(): SSLSocketFactory = if (connections++ == 0) first else then + + override fun createSocket(socket: Socket, host: String?, port: Int, autoClose: Boolean) = + next().createSocket(socket, host, port, autoClose) + + override fun getDefaultCipherSuites(): Array = first.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = first.supportedCipherSuites + + override fun createSocket(host: String, port: Int) = throw UnsupportedOperationException() + + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int) = + throw UnsupportedOperationException() + + override fun createSocket(host: InetAddress, port: Int) = throw UnsupportedOperationException() + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ) = throw UnsupportedOperationException() + } +} diff --git a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt index 4c17c71fecc..63a297d950c 100644 --- a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt +++ b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt @@ -24,6 +24,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -149,23 +150,34 @@ class HttpConnectionTest { } @Test - fun `When SSLSocketFactory is given, set to connection`() { + fun `When SSLSocketFactory is given, set it to connection wrapped in a distrusted certificates check`() { val factory = mock() fixture.sslSocketFactory = factory val transport = fixture.getSUT() transport.send(createEnvelope()) - verify(fixture.connection).sslSocketFactory = eq(factory) + assertWraps(factory) } @Test - fun `When SSLSocketFactory is not given, do not set to connection`() { + fun `When SSLSocketFactory is not given, wrap the connection's own factory in a distrusted certificates check`() { + val factory = mock() + whenever(fixture.connection.sslSocketFactory).thenReturn(factory) val transport = fixture.getSUT() transport.send(createEnvelope()) - verify(fixture.connection, never()).sslSocketFactory = any() + assertWraps(factory) + } + + private fun assertWraps(delegate: SSLSocketFactory) { + val captor = argumentCaptor() + verify(fixture.connection).sslSocketFactory = captor.capture() + val wrapper = captor.firstValue + assertTrue(wrapper is DistrustedCertificatesSocketFactory) + wrapper.defaultCipherSuites + verify(delegate).defaultCipherSuites } @Test diff --git a/sentry/src/test/resources/emsign-root-tls-ca-g3.pem b/sentry/src/test/resources/emsign-root-tls-ca-g3.pem new file mode 100644 index 00000000000..3b8be38cb38 --- /dev/null +++ b/sentry/src/test/resources/emsign-root-tls-ca-g3.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw +CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx +OTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11 +ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg +VExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG +H6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao +4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW +BBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI +rvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT +KCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc= +-----END CERTIFICATE----- diff --git a/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem b/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem new file mode 100644 index 00000000000..470f0f7e77d --- /dev/null +++ b/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw +ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV +BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y +ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW +MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO +L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2 +PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd +BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC +MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h +AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB +QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF +BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB +AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE +wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM +QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ +7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl +bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V +GUyRCEIDhF5TBUuKF4E3kf1h +-----END CERTIFICATE----- diff --git a/sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem b/sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem new file mode 100644 index 00000000000..0f4083092ff --- /dev/null +++ b/sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ +BgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk +MSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3 +NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk +aHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ +fjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+ +N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L +WUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow +KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB +BAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY +aHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw +b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f +BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j +cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E +AwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c +NVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79 +4rSb9vGqxZzelQth +-----END CERTIFICATE-----