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 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