From 0411fe490b159c67aeb86f1a86db9af114bdca72 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 21 Oct 2026 09:25:34 +0200 Subject: [PATCH 1/3] fix(transport): Reject leaked eMudhra intermediate certificates The private keys of eight eMudhra intermediates can be recovered from their certificates (CVE-2026-111943). Their Sectigo cross-signs chain to AAA Certificate Services, which is trusted on every Android version, so platform trust store updates won't reach every device. The envelope transport and the distribution client now reject servers that present one of these keys, right after the TLS handshake and before any request is written. Fixes JAVA-783 Co-Authored-By: Claude Opus 5.5 --- .../distribution/DistributionHttpClient.kt | 4 + sentry/api/sentry.api | 4 + .../transport/DistrustedCertificates.java | 93 +++++++++++++++++++ .../io/sentry/transport/HttpConnection.java | 1 + .../transport/DistrustedCertificatesTest.kt | 75 +++++++++++++++ .../io/sentry/transport/HttpConnectionTest.kt | 14 +++ .../resources/emudhra-em-dv-tls-ca-g3b.pem | 23 +++++ 7 files changed, 214 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java create mode 100644 sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt create mode 100644 sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem 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..ff503e85084 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 @@ -93,6 +94,9 @@ internal class DistributionHttpClient(private val options: SentryOptions) { connection.sslSocketFactory = options.sslSocketFactory } + connection.connect() + DistrustedCertificates.verify(connection) + val responseCode = connection.responseCode val responseBody = readResponse(connection) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index abf8fc28526..f35afda3116 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7764,6 +7764,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 verify (Ljava/net/HttpURLConnection;)V +} + public abstract interface class io/sentry/transport/ICurrentDateProvider { public abstract fun getCurrentTimeMillis ()J } 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..34d054f55ee --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java @@ -0,0 +1,93 @@ +package io.sentry.transport; + +import java.net.HttpURLConnection; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLPeerUnverifiedException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Rejects servers whose certificate chain contains a CA certificate 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 + * 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 { + + /** + * SHA-256 hashes of the SubjectPublicKeyInfo of the eight eMudhra intermediates whose private + * keys can be recovered from their certificates (CVE-2026-111943). Each key is in two + * certificates: one issued by eMudhra and a cross-sign by Sectigo's AAA Certificate Services + * root, which is trusted on every Android version. Matching the key covers both. + * + * @see Compromised + * eMudhra Intermediate Certificates + */ + private static final Set SPKI_SHA256_HASHES = + new HashSet<>( + Arrays.asList( + "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b", + "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f", + "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0", + "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8", + "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30", + "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41", + "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105", + "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6")); + + private DistrustedCertificates() {} + + /** + * Disconnects and throws if the server presented a distrusted certificate. + * + *

Call it right after {@link HttpURLConnection#connect()}: the TLS handshake is done by then, + * but no part of the request has been written yet. A {@link javax.net.ssl.HostnameVerifier} can't + * do this check, because the JVM's {@link HttpsURLConnection} only calls it when its own hostname + * check fails. + */ + public static void verify(final @NotNull HttpURLConnection connection) + throws SSLPeerUnverifiedException { + if (!(connection instanceof HttpsURLConnection)) { + return; + } + final @Nullable Certificate[] certificates = + ((HttpsURLConnection) connection).getServerCertificates(); + if (certificates == null) { + return; + } + for (final @NotNull Certificate certificate : certificates) { + if (SPKI_SHA256_HASHES.contains(spkiSha256(certificate))) { + connection.disconnect(); + throw new SSLPeerUnverifiedException( + "The server's certificate chain contains a distrusted certificate."); + } + } + } + + private static @NotNull String spkiSha256(final @NotNull Certificate certificate) { + final byte[] hash; + try { + hash = MessageDigest.getInstance("SHA-256").digest(certificate.getPublicKey().getEncoded()); + } catch (NoSuchAlgorithmException e) { + // Every Java and Android platform is required to support SHA-256. + throw new AssertionError(e); + } + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16)); + } + return hex.toString(); + } +} diff --git a/sentry/src/main/java/io/sentry/transport/HttpConnection.java b/sentry/src/main/java/io/sentry/transport/HttpConnection.java index e7935e76cf3..b99e36c76b0 100644 --- a/sentry/src/main/java/io/sentry/transport/HttpConnection.java +++ b/sentry/src/main/java/io/sentry/transport/HttpConnection.java @@ -143,6 +143,7 @@ HttpURLConnection open() throws IOException { } connection.connect(); + DistrustedCertificates.verify(connection); return connection; } 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..07961e83438 --- /dev/null +++ b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt @@ -0,0 +1,75 @@ +package io.sentry.transport + +import java.net.HttpURLConnection +import java.security.PublicKey +import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DistrustedCertificatesTest { + + private val connection = mock() + + @Test + fun `throws and disconnects when the chain contains a distrusted certificate`() { + val certificates = arrayOf(trustedCertificate(), distrustedCertificate()) + whenever(connection.serverCertificates).thenReturn(certificates) + + assertFailsWith { DistrustedCertificates.verify(connection) } + + verify(connection).disconnect() + } + + @Test + fun `accepts a chain without distrusted certificates`() { + val certificates = arrayOf(trustedCertificate()) + whenever(connection.serverCertificates).thenReturn(certificates) + + DistrustedCertificates.verify(connection) + + verify(connection, never()).disconnect() + } + + @Test + fun `accepts a connection without server certificates`() { + whenever(connection.serverCertificates).thenReturn(null) + + DistrustedCertificates.verify(connection) + + verify(connection, never()).disconnect() + } + + @Test + fun `ignores plain HTTP connections`() { + val connection = mock() + + DistrustedCertificates.verify(connection) + + verify(connection, never()).disconnect() + } + + companion object { + /** EM DV TLS CA - G3B as cross-signed by Sectigo's AAA Certificate Services root. */ + fun distrustedCertificate(): Certificate = + DistrustedCertificatesTest::class + .java + .classLoader!! + .getResourceAsStream("emudhra-em-dv-tls-ca-g3b.pem")!! + .use { CertificateFactory.getInstance("X.509").generateCertificate(it) } + + private fun trustedCertificate(): Certificate { + val publicKey = mock() + whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3)) + val certificate = mock() + whenever(certificate.publicKey).thenReturn(publicKey) + return certificate + } + } +} diff --git a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt index 4c17c71fecc..1ac1bd8b095 100644 --- a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt +++ b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt @@ -17,9 +17,11 @@ import java.net.Proxy.Type import java.net.URL import java.nio.charset.Charset import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocketFactory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -168,6 +170,18 @@ class HttpConnectionTest { verify(fixture.connection, never()).sslSocketFactory = any() } + @Test + fun `does not send the envelope when the server presents a distrusted certificate`() { + whenever(fixture.connection.serverCertificates) + .thenReturn(arrayOf(DistrustedCertificatesTest.distrustedCertificate())) + val transport = fixture.getSUT() + + assertFailsWith { transport.send(createEnvelope()) } + + verify(fixture.connection, never()).outputStream + verify(fixture.connection).disconnect() + } + @Test fun `When connection error message contains formatting symbols, does not crash the logger`() { fixture.options.isDebug = true 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----- From 494d0d931e19df9d5b5cdd78cf3e5fa0f0f0e84d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 21 Oct 2026 09:27:09 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 465da8d957d..c27f3f2be17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Fixes - 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)) +- Refuse to send data to servers that present one of the leaked eMudhra intermediate certificates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) + - Anyone can recover the private keys of these intermediates, and they are cross-signed by a root that every Android version trusts, so device trust store updates don't protect every device. + - This applies to the SDK's own envelope uploads and to the distribution update check. Other HTTPS connections your app makes, including those instrumented by `sentry-okhttp` and the other HTTP client integrations, need their own fix, for example an OkHttp update. ## 8.61.1 From fdafbb8416cfbbc48f513ad03ed48d076c1bcb54 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 18:06:19 +0200 Subject: [PATCH 3/3] ref(transport): Pass DistrustedCertificates to HttpConnection Inject the check through HttpConnection's constructor like AuthenticatorWrapper, so its tests can stub it instead of loading the real eMudhra certificate. Co-Authored-By: Claude Opus 5.5 --- .../distribution/DistributionHttpClient.kt | 2 +- sentry/api/sentry.api | 3 +- .../transport/DistrustedCertificates.java | 8 ++++- .../io/sentry/transport/HttpConnection.java | 14 ++++++-- .../transport/DistrustedCertificatesTest.kt | 35 +++++++++---------- .../io/sentry/transport/HttpConnectionTest.kt | 29 ++++++++++++--- 6 files changed, 62 insertions(+), 29 deletions(-) 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 ff503e85084..9bb80a9f2ab 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 @@ -95,7 +95,7 @@ internal class DistributionHttpClient(private val options: SentryOptions) { } connection.connect() - DistrustedCertificates.verify(connection) + DistrustedCertificates.getInstance().verify(connection) val responseCode = connection.responseCode val responseBody = readResponse(connection) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f35afda3116..78d8616af00 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7765,7 +7765,8 @@ public final class io/sentry/transport/CurrentDateProvider : io/sentry/transport } public final class io/sentry/transport/DistrustedCertificates { - public static fun verify (Ljava/net/HttpURLConnection;)V + public static fun getInstance ()Lio/sentry/transport/DistrustedCertificates; + public fun verify (Ljava/net/HttpURLConnection;)V } public abstract interface class io/sentry/transport/ICurrentDateProvider { diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java index 34d054f55ee..87a38e862f9 100644 --- a/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java @@ -47,6 +47,12 @@ public final class DistrustedCertificates { "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105", "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6")); + private static final DistrustedCertificates instance = new DistrustedCertificates(); + + public static @NotNull DistrustedCertificates getInstance() { + return instance; + } + private DistrustedCertificates() {} /** @@ -57,7 +63,7 @@ private DistrustedCertificates() {} * do this check, because the JVM's {@link HttpsURLConnection} only calls it when its own hostname * check fails. */ - public static void verify(final @NotNull HttpURLConnection connection) + public void verify(final @NotNull HttpURLConnection connection) throws SSLPeerUnverifiedException { if (!(connection instanceof HttpsURLConnection)) { return; diff --git a/sentry/src/main/java/io/sentry/transport/HttpConnection.java b/sentry/src/main/java/io/sentry/transport/HttpConnection.java index b99e36c76b0..c7c9f82fe13 100644 --- a/sentry/src/main/java/io/sentry/transport/HttpConnection.java +++ b/sentry/src/main/java/io/sentry/transport/HttpConnection.java @@ -33,6 +33,7 @@ final class HttpConnection { private final @NotNull RequestDetails requestDetails; private final @NotNull SentryOptions options; private final @NotNull RateLimiter rateLimiter; + private final @NotNull DistrustedCertificates distrustedCertificates; /** * Constructs a new HTTP transport instance. Notably, the provided {@code requestUpdater} must set @@ -47,17 +48,24 @@ public HttpConnection( final @NotNull SentryOptions options, final @NotNull RequestDetails requestDetails, final @NotNull RateLimiter rateLimiter) { - this(options, requestDetails, AuthenticatorWrapper.getInstance(), rateLimiter); + this( + options, + requestDetails, + AuthenticatorWrapper.getInstance(), + rateLimiter, + DistrustedCertificates.getInstance()); } HttpConnection( final @NotNull SentryOptions options, final @NotNull RequestDetails requestDetails, final @NotNull AuthenticatorWrapper authenticatorWrapper, - final @NotNull RateLimiter rateLimiter) { + final @NotNull RateLimiter rateLimiter, + final @NotNull DistrustedCertificates distrustedCertificates) { this.requestDetails = requestDetails; this.options = options; this.rateLimiter = rateLimiter; + this.distrustedCertificates = distrustedCertificates; this.proxy = resolveProxy(options.getProxy()); @@ -143,7 +151,7 @@ HttpURLConnection open() throws IOException { } connection.connect(); - DistrustedCertificates.verify(connection); + distrustedCertificates.verify(connection); return connection; } diff --git a/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt index 07961e83438..da7f2de6a73 100644 --- a/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt +++ b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt @@ -16,13 +16,14 @@ import org.mockito.kotlin.whenever class DistrustedCertificatesTest { private val connection = mock() + private val sut = DistrustedCertificates.getInstance() @Test fun `throws and disconnects when the chain contains a distrusted certificate`() { val certificates = arrayOf(trustedCertificate(), distrustedCertificate()) whenever(connection.serverCertificates).thenReturn(certificates) - assertFailsWith { DistrustedCertificates.verify(connection) } + assertFailsWith { sut.verify(connection) } verify(connection).disconnect() } @@ -32,7 +33,7 @@ class DistrustedCertificatesTest { val certificates = arrayOf(trustedCertificate()) whenever(connection.serverCertificates).thenReturn(certificates) - DistrustedCertificates.verify(connection) + sut.verify(connection) verify(connection, never()).disconnect() } @@ -41,7 +42,7 @@ class DistrustedCertificatesTest { fun `accepts a connection without server certificates`() { whenever(connection.serverCertificates).thenReturn(null) - DistrustedCertificates.verify(connection) + sut.verify(connection) verify(connection, never()).disconnect() } @@ -50,26 +51,22 @@ class DistrustedCertificatesTest { fun `ignores plain HTTP connections`() { val connection = mock() - DistrustedCertificates.verify(connection) + sut.verify(connection) verify(connection, never()).disconnect() } - companion object { - /** EM DV TLS CA - G3B as cross-signed by Sectigo's AAA Certificate Services root. */ - fun distrustedCertificate(): Certificate = - DistrustedCertificatesTest::class - .java - .classLoader!! - .getResourceAsStream("emudhra-em-dv-tls-ca-g3b.pem")!! - .use { CertificateFactory.getInstance("X.509").generateCertificate(it) } - - private fun trustedCertificate(): Certificate { - val publicKey = mock() - whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3)) - val certificate = mock() - whenever(certificate.publicKey).thenReturn(publicKey) - return certificate + /** EM DV TLS CA - G3B as cross-signed by Sectigo's AAA Certificate Services root. */ + private fun distrustedCertificate(): Certificate = + javaClass.classLoader!!.getResourceAsStream("emudhra-em-dv-tls-ca-g3b.pem")!!.use { + CertificateFactory.getInstance("X.509").generateCertificate(it) } + + private fun trustedCertificate(): Certificate { + val publicKey = mock() + whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3)) + val certificate = mock() + whenever(certificate.publicKey).thenReturn(publicKey) + return certificate } } diff --git a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt index 1ac1bd8b095..f3229925890 100644 --- a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt +++ b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt @@ -27,7 +27,9 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.check +import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -41,6 +43,7 @@ class HttpConnectionTest { val currentDateProvider = mock() val authenticatorWrapper = mock() val rateLimiter = mock() + val distrustedCertificates = mock() var sslSocketFactory: SSLSocketFactory? = null val requestDetails = mock() val socketTagger = mock() @@ -63,7 +66,13 @@ class HttpConnectionTest { options.sslSocketFactory = sslSocketFactory options.setSocketTagger(socketTagger) - return HttpConnection(options, requestDetails, authenticatorWrapper, rateLimiter) + return HttpConnection( + options, + requestDetails, + authenticatorWrapper, + rateLimiter, + distrustedCertificates, + ) } } @@ -170,16 +179,28 @@ class HttpConnectionTest { verify(fixture.connection, never()).sslSocketFactory = any() } + @Test + fun `verifies the server certificates after connecting and before writing the envelope`() { + val transport = fixture.getSUT() + + transport.send(createEnvelope()) + + val order = inOrder(fixture.connection, fixture.distrustedCertificates) + order.verify(fixture.connection).connect() + order.verify(fixture.distrustedCertificates).verify(fixture.connection) + order.verify(fixture.connection).outputStream + } + @Test fun `does not send the envelope when the server presents a distrusted certificate`() { - whenever(fixture.connection.serverCertificates) - .thenReturn(arrayOf(DistrustedCertificatesTest.distrustedCertificate())) + doThrow(SSLPeerUnverifiedException("distrusted")) + .whenever(fixture.distrustedCertificates) + .verify(fixture.connection) val transport = fixture.getSUT() assertFailsWith { transport.send(createEnvelope()) } verify(fixture.connection, never()).outputStream - verify(fixture.connection).disconnect() } @Test