From a10efb3b4e70ae4b7b8728572c3d27a76d1feb0e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:53:08 +0000 Subject: [PATCH 01/11] fix(android): Trust bundled Sentry root CAs on API 25 and lower Co-Authored-By: roman --- .../core/AndroidOptionsInitializer.java | 6 + .../core/SentryRootCaSslSocketFactory.java | 160 +++++++++++ .../android/core/SentryRootCertificates.java | 252 ++++++++++++++++++ .../core/AndroidOptionsInitializerTest.kt | 25 ++ .../core/SentryRootCaSslSocketFactoryTest.kt | 80 ++++++ 5 files changed, 523 insertions(+) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 12ffb90356c..fed9156ace4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -202,6 +202,12 @@ static void initializeIntegrationsAndProcessors( if (options.getTransportGate() instanceof NoOpTransportGate) { options.setTransportGate(new AndroidTransportGate(options)); } + // API 25 and lower lack some of the root CAs Sentry uses (e.g. ISRG Root X1), so the SDK's own + // envelope uploads trust the bundled Sentry root CAs in addition to the system ones. + if (buildInfoProvider.getSdkInfoVersion() <= Build.VERSION_CODES.N_MR1 + && options.getSslSocketFactory() == null) { + options.setSslSocketFactory(new SentryRootCaSslSocketFactory(options.getLogger())); + } final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); options.setAppStartExtender(appStartMetrics.getAppStartExtension()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java new file mode 100644 index 00000000000..0547042b989 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java @@ -0,0 +1,160 @@ +package io.sentry.android.core; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.util.LazyEvaluator; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.charset.Charset; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.TestOnly; + +/** + * An {@link SSLSocketFactory} that trusts the system root CAs plus the root CAs Sentry SaaS uses + * (see {@link SentryRootCertificates}). Both are put into a single trust store which is handed to + * the platform's default {@link TrustManagerFactory}, so certificate validation itself is still + * done by the platform. + * + *

Android API 25 and lower don't ship all of Sentry's root CAs (e.g. ISRG Root X1), so TLS + * handshakes with Sentry fail on those devices. This factory is only set as {@link + * io.sentry.SentryOptions#setSslSocketFactory(SSLSocketFactory)} on those API levels, so it only + * applies to the SDK's own envelope uploads and not to any other connection of the app. + * + *

The underlying {@link SSLContext} is created lazily on first use, so the cost of parsing the + * certificates is paid on the transport thread instead of during {@code SentryAndroid.init}. If + * creating it fails, the platform default {@link SSLSocketFactory} is used. + */ +final class SentryRootCaSslSocketFactory extends SSLSocketFactory { + + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + static final @NotNull String SENTRY_ROOT_CA_ALIAS_PREFIX = "sentry-root-ca-"; + + private final @NotNull LazyEvaluator delegate; + + SentryRootCaSslSocketFactory(final @NotNull ILogger logger) { + this.delegate = new LazyEvaluator<>(() -> createDelegate(logger)); + } + + private static @NotNull SSLSocketFactory createDelegate(final @NotNull ILogger logger) { + try { + final @NotNull TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(createTrustStore(getSystemTrustedIssuers())); + + final @NotNull SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + return sslContext.getSocketFactory(); + } catch (GeneralSecurityException | IOException e) { + logger.log( + SentryLevel.ERROR, + "Failed to create SSLSocketFactory with bundled Sentry root CAs, using the default one.", + e); + return HttpsURLConnection.getDefaultSSLSocketFactory(); + } + } + + /** Returns the CAs trusted by the platform's default trust manager. */ + private static @NotNull X509Certificate[] getSystemTrustedIssuers() + throws GeneralSecurityException { + final @NotNull TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + for (final TrustManager trustManager : trustManagerFactory.getTrustManagers()) { + if (trustManager instanceof X509TrustManager) { + return ((X509TrustManager) trustManager).getAcceptedIssuers(); + } + } + throw new GeneralSecurityException("No default X509TrustManager available"); + } + + /** + * Creates a trust store containing the given system trusted CAs plus the bundled Sentry root CAs. + */ + @TestOnly + static @NotNull KeyStore createTrustStore(final @NotNull X509Certificate[] systemTrustedIssuers) + throws GeneralSecurityException, IOException { + final @NotNull KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + for (int i = 0; i < systemTrustedIssuers.length; i++) { + keyStore.setCertificateEntry("system-ca-" + i, systemTrustedIssuers[i]); + } + final @NotNull CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + for (int i = 0; i < SentryRootCertificates.ALL.length; i++) { + final @NotNull Certificate certificate = + certificateFactory.generateCertificate( + new ByteArrayInputStream(SentryRootCertificates.ALL[i].getBytes(UTF_8))); + keyStore.setCertificateEntry(SENTRY_ROOT_CA_ALIAS_PREFIX + i, certificate); + } + return keyStore; + } + + @TestOnly + @NotNull + SSLSocketFactory getDelegate() { + return delegate.getValue(); + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getValue().getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getValue().getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return delegate.getValue().createSocket(); + } + + @Override + public Socket createSocket( + final Socket socket, final String host, final int port, final boolean autoClose) + throws IOException { + return delegate.getValue().createSocket(socket, host, port, autoClose); + } + + @Override + public Socket createSocket(final String host, final int port) throws IOException { + return delegate.getValue().createSocket(host, port); + } + + @Override + public Socket createSocket( + final String host, final int port, final InetAddress localHost, final int localPort) + throws IOException { + return delegate.getValue().createSocket(host, port, localHost, localPort); + } + + @Override + public Socket createSocket(final InetAddress host, final int port) throws IOException { + return delegate.getValue().createSocket(host, port); + } + + @Override + public Socket createSocket( + final InetAddress address, + final int port, + final InetAddress localAddress, + final int localPort) + throws IOException { + return delegate.getValue().createSocket(address, port, localAddress, localPort); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java new file mode 100644 index 00000000000..3a52406767f --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java @@ -0,0 +1,252 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.NotNull; + +/** + * Root CA certificates used by Sentry SaaS endpoints, as listed on + * https://docs.sentry.io/security-legal-pii/security/ssl/ + * + *

Older Android versions (API 25 and lower) don't ship all of these roots, so the SDK bundles + * them and trusts them for its own envelope uploads, see {@link SentryRootCaSslSocketFactory}. + */ +final class SentryRootCertificates { + + private SentryRootCertificates() {} + + /** + * DigiCert Global Root CA, SHA-256 + * 43:48:A0:E9:44:4C:78:CB:26:5E:05:8D:5E:89:44:B4:D8:4F:96:62:BD:26:DB:25:7F:89:34:A4:43:C7:01:61 + */ + static final @NotNull String DIGICERT_GLOBAL_ROOT_CA = + "-----BEGIN CERTIFICATE-----\n" + + "MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh\n" + + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD\n" + + "QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT\n" + + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG\n" + + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB\n" + + "CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97\n" + + "nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt\n" + + "43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P\n" + + "T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4\n" + + "gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO\n" + + "BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR\n" + + "TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw\n" + + "DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr\n" + + "hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg\n" + + "06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF\n" + + "PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls\n" + + "YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk\n" + + "CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=\n" + + "-----END CERTIFICATE-----\n"; + + /** + * DigiCert Global Root G2, SHA-256 + * CB:3C:CB:B7:60:31:E5:E0:13:8F:8D:D3:9A:23:F9:DE:47:FF:C3:5E:43:C1:14:4C:EA:27:D4:6A:5A:B1:CB:5F + */ + static final @NotNull String DIGICERT_GLOBAL_ROOT_G2 = + "-----BEGIN CERTIFICATE-----\n" + + "MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n" + + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n" + + "MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n" + + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n" + + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n" + + "2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n" + + "1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n" + + "q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n" + + "tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n" + + "vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n" + + "BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n" + + "5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n" + + "1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n" + + "NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n" + + "Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n" + + "8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n" + + "pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n" + + "MrY=\n" + + "-----END CERTIFICATE-----\n"; + + /** + * ISRG Root X1, SHA-256 + * 96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6 + */ + static final @NotNull String ISRG_ROOT_X1 = + "-----BEGIN CERTIFICATE-----\n" + + "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\n" + + "-----END CERTIFICATE-----\n"; + + /** + * ISRG Root X2, SHA-256 + * 69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70 + */ + static final @NotNull String ISRG_ROOT_X2 = + "-----BEGIN CERTIFICATE-----\n" + + "MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n" + + "CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n" + + "R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n" + + "MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n" + + "ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n" + + "EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n" + + "+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n" + + "ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n" + + "AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n" + + "zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n" + + "tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n" + + "/q4AaOeMSQ+2b1tbFfLn\n" + + "-----END CERTIFICATE-----\n"; + + /** + * GTS Root R1, SHA-256 + * D9:47:43:2A:BD:E7:B7:FA:90:FC:2E:6B:59:10:1B:12:80:E0:E1:C7:E4:E4:0F:A3:C6:88:7F:FF:57:A7:F4:CF + */ + static final @NotNull String GTS_ROOT_R1 = + "-----BEGIN CERTIFICATE-----\n" + + "MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw\n" + + "CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n" + + "MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n" + + "MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n" + + "Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo\n" + + "27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w\n" + + "Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw\n" + + "TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl\n" + + "qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH\n" + + "szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8\n" + + "Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk\n" + + "MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92\n" + + "wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p\n" + + "aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN\n" + + "VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID\n" + + "AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n" + + "FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb\n" + + "C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n" + + "QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy\n" + + "h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4\n" + + "7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J\n" + + "ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef\n" + + "MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/\n" + + "Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT\n" + + "6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ\n" + + "0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm\n" + + "2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb\n" + + "bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n" + + "-----END CERTIFICATE-----\n"; + + /** + * GTS Root R2, SHA-256 + * 8D:25:CD:97:22:9D:BF:70:35:6B:DA:4E:B3:CC:73:40:31:E2:4C:F0:0F:AF:CF:D3:2D:C7:6E:B5:84:1C:7E:A8 + */ + static final @NotNull String GTS_ROOT_R2 = + "-----BEGIN CERTIFICATE-----\n" + + "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\n" + + "6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL\n" + + "-----END CERTIFICATE-----\n"; + + /** + * GTS Root R3, SHA-256 + * 34:D8:A7:3E:E2:08:D9:BC:DB:0D:95:65:20:93:4B:4E:40:E6:94:82:59:6E:8B:6F:73:C8:42:6B:01:0A:6F:48 + */ + static final @NotNull String GTS_ROOT_R3 = + "-----BEGIN CERTIFICATE-----\n" + + "MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD\n" + + "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" + + "A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n" + + "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n" + + "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n" + + "AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G\n" + + "jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2\n" + + "4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" + + "BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7\n" + + "VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm\n" + + "ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X\n" + + "-----END CERTIFICATE-----\n"; + + /** + * GTS Root R4, SHA-256 + * 34:9D:FA:40:58:C5:E2:63:12:3B:39:8A:E7:95:57:3C:4E:13:13:C8:3F:E6:8F:93:55:6C:D5:E8:03:1B:3C:7D + */ + static final @NotNull String GTS_ROOT_R4 = + "-----BEGIN CERTIFICATE-----\n" + + "MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n" + + "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" + + "A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n" + + "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n" + + "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n" + + "AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n" + + "QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n" + + "HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" + + "BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n" + + "9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n" + + "p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n" + + "-----END CERTIFICATE-----\n"; + + static final @NotNull String[] ALL = + new String[] { + DIGICERT_GLOBAL_ROOT_CA, + DIGICERT_GLOBAL_ROOT_G2, + ISRG_ROOT_X1, + ISRG_ROOT_X2, + GTS_ROOT_R1, + GTS_ROOT_R2, + GTS_ROOT_R3, + GTS_ROOT_R4 + }; +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 4610e4bbcb8..e5b3bae5a56 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -368,6 +368,31 @@ class AndroidOptionsInitializerTest { assertTrue(fixture.sentryOptions.transportGate is AndroidTransportGate) } + @Config(sdk = [Build.VERSION_CODES.N_MR1]) + @Test + fun `init on API 25 and lower sets SSLSocketFactory with bundled Sentry root CAs`() { + fixture.initSut() + + assertIs(fixture.sentryOptions.sslSocketFactory) + } + + @Config(sdk = [Build.VERSION_CODES.O]) + @Test + fun `init on API 26 and higher does not set SSLSocketFactory`() { + fixture.initSut() + + assertNull(fixture.sentryOptions.sslSocketFactory) + } + + @Config(sdk = [Build.VERSION_CODES.N_MR1]) + @Test + fun `init on API 25 and lower keeps user provided SSLSocketFactory`() { + val sslSocketFactory = mock() + fixture.initSut(configureOptions = { this.sslSocketFactory = sslSocketFactory }) + + assertEquals(sslSocketFactory, fixture.sentryOptions.sslSocketFactory) + } + @Test fun `init should set Android continuous profiler`() { fixture.initSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt new file mode 100644 index 00000000000..3a708c65fe9 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -0,0 +1,80 @@ +package io.sentry.android.core + +import com.google.common.truth.Truth.assertThat +import io.sentry.ILogger +import java.security.KeyStore +import java.security.MessageDigest +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import kotlin.test.Test +import org.mockito.kotlin.mock + +class SentryRootCaSslSocketFactoryTest { + + @Test + fun `bundled root CAs match the fingerprints published on docs_sentry_io`() { + val keyStore = SentryRootCaSslSocketFactory.createTrustStore(emptyArray()) + + val fingerprints = + keyStore.aliases().toList().map { alias -> keyStore.getCertificate(alias).sha256() } + + assertThat(fingerprints) + .containsExactly( + // DigiCert Global Root CA + "43:48:A0:E9:44:4C:78:CB:26:5E:05:8D:5E:89:44:B4:D8:4F:96:62:BD:26:DB:25:7F:89:34:A4:43:C7:01:61", + // DigiCert Global Root G2 + "CB:3C:CB:B7:60:31:E5:E0:13:8F:8D:D3:9A:23:F9:DE:47:FF:C3:5E:43:C1:14:4C:EA:27:D4:6A:5A:B1:CB:5F", + // ISRG Root X1 + "96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6", + // ISRG Root X2 + "69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70", + // GTS Root R1 + "D9:47:43:2A:BD:E7:B7:FA:90:FC:2E:6B:59:10:1B:12:80:E0:E1:C7:E4:E4:0F:A3:C6:88:7F:FF:57:A7:F4:CF", + // GTS Root R2 + "8D:25:CD:97:22:9D:BF:70:35:6B:DA:4E:B3:CC:73:40:31:E2:4C:F0:0F:AF:CF:D3:2D:C7:6E:B5:84:1C:7E:A8", + // GTS Root R3 + "34:D8:A7:3E:E2:08:D9:BC:DB:0D:95:65:20:93:4B:4E:40:E6:94:82:59:6E:8B:6F:73:C8:42:6B:01:0A:6F:48", + // GTS Root R4 + "34:9D:FA:40:58:C5:E2:63:12:3B:39:8A:E7:95:57:3C:4E:13:13:C8:3F:E6:8F:93:55:6C:D5:E8:03:1B:3C:7D", + ) + } + + @Test + fun `trust store keeps the system trusted CAs`() { + val systemIssuers = defaultTrustManager(null).acceptedIssuers + assertThat(systemIssuers).isNotEmpty() + + val trustManager = + defaultTrustManager(SentryRootCaSslSocketFactory.createTrustStore(systemIssuers)) + + val accepted = trustManager.acceptedIssuers.map { it.sha256() } + assertThat(accepted).containsAtLeastElementsIn(systemIssuers.map { it.sha256() }) + assertThat(accepted) + .containsAtLeastElementsIn( + SentryRootCaSslSocketFactory.createTrustStore(emptyArray()).let { store -> + store.aliases().toList().map { store.getCertificate(it).sha256() } + } + ) + } + + @Test + fun `creates a custom delegate SSLSocketFactory lazily and only once`() { + val sut = SentryRootCaSslSocketFactory(mock()) + + val delegate = sut.delegate + + assertThat(delegate).isNotSameInstanceAs(HttpsURLConnection.getDefaultSSLSocketFactory()) + assertThat(sut.supportedCipherSuites).isNotEmpty() + assertThat(sut.delegate).isSameInstanceAs(delegate) + } + + private fun defaultTrustManager(keyStore: KeyStore?): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(keyStore) + return factory.trustManagers.filterIsInstance().first() + } + + private fun java.security.cert.Certificate.sha256(): String = + MessageDigest.getInstance("SHA-256").digest(encoded).joinToString(":") { "%02X".format(it) } +} From 93b082b089e61033789b4fb346502249098ed532 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:53:36 +0000 Subject: [PATCH 02/11] changelog Co-Authored-By: roman --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79054231ae2..6ce3e77057d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ ## 8.60.0 +### Fixes + +- Trust the bundled Sentry root CAs (Let's Encrypt, Google Trust Services, DigiCert) for SDK envelope uploads on Android API 25 and lower, which don't ship all of them ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) + ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233)) From 6472eb521a764a49cdce935bed49caac982de30f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:00:42 +0000 Subject: [PATCH 03/11] ref(android): Only bundle ISRG Root X1 Co-Authored-By: roman --- CHANGELOG.md | 2 +- .../core/AndroidOptionsInitializer.java | 4 +- .../core/SentryRootCaSslSocketFactory.java | 12 +- .../android/core/SentryRootCertificates.java | 213 +----------------- .../core/SentryRootCaSslSocketFactoryTest.kt | 18 +- 5 files changed, 21 insertions(+), 228 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce3e77057d..d1cbec96d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ ### Fixes -- Trust the bundled Sentry root CAs (Let's Encrypt, Google Trust Services, DigiCert) for SDK envelope uploads on Android API 25 and lower, which don't ship all of them ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) +- Trust a bundled Let's Encrypt root (ISRG Root X1) for SDK envelope uploads on Android API 25 and lower, which may not ship it ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index fed9156ace4..ea27a2ba458 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -202,8 +202,8 @@ static void initializeIntegrationsAndProcessors( if (options.getTransportGate() instanceof NoOpTransportGate) { options.setTransportGate(new AndroidTransportGate(options)); } - // API 25 and lower lack some of the root CAs Sentry uses (e.g. ISRG Root X1), so the SDK's own - // envelope uploads trust the bundled Sentry root CAs in addition to the system ones. + // API 25 and lower may lack ISRG Root X1 (Let's Encrypt), so the SDK's own envelope uploads + // trust the bundled root in addition to the system ones. if (buildInfoProvider.getSdkInfoVersion() <= Build.VERSION_CODES.N_MR1 && options.getSslSocketFactory() == null) { options.setSslSocketFactory(new SentryRootCaSslSocketFactory(options.getLogger())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java index 0547042b989..ff3d905f216 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java @@ -23,13 +23,13 @@ import org.jetbrains.annotations.TestOnly; /** - * An {@link SSLSocketFactory} that trusts the system root CAs plus the root CAs Sentry SaaS uses - * (see {@link SentryRootCertificates}). Both are put into a single trust store which is handed to - * the platform's default {@link TrustManagerFactory}, so certificate validation itself is still - * done by the platform. + * An {@link SSLSocketFactory} that trusts the system root CAs plus the root CAs bundled in {@link + * SentryRootCertificates}. Both are put into a single trust store which is handed to the platform's + * default {@link TrustManagerFactory}, so certificate validation itself is still done by the + * platform. * - *

Android API 25 and lower don't ship all of Sentry's root CAs (e.g. ISRG Root X1), so TLS - * handshakes with Sentry fail on those devices. This factory is only set as {@link + *

Android API 25 and lower may not ship ISRG Root X1, so TLS handshakes with Let's Encrypt + * certificates fail on those devices. This factory is only set as {@link * io.sentry.SentryOptions#setSslSocketFactory(SSLSocketFactory)} on those API levels, so it only * applies to the SDK's own envelope uploads and not to any other connection of the app. * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java index 3a52406767f..416f3049840 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java @@ -3,72 +3,21 @@ import org.jetbrains.annotations.NotNull; /** - * Root CA certificates used by Sentry SaaS endpoints, as listed on - * https://docs.sentry.io/security-legal-pii/security/ssl/ + * Root CA certificates the SDK bundles for its own envelope uploads, see {@link + * SentryRootCaSslSocketFactory}. * - *

Older Android versions (API 25 and lower) don't ship all of these roots, so the SDK bundles - * them and trusts them for its own envelope uploads, see {@link SentryRootCaSslSocketFactory}. + *

Sentry SaaS uses Let's Encrypt, whose default certificate chains all end at ISRG Root X1 (see + * https://docs.sentry.io/security-legal-pii/security/ssl/ and + * https://letsencrypt.org/certificates/). ISRG Root X1 was only added to the Android CA store in + * Android 7.1 (API 25), so older devices can't validate those chains on their own. It's also + * applied on API 25 to cover vendor builds that lack it. The other Sentry roots (DigiCert, and + * Google Trust Services via its GlobalSign cross-sign) are already trusted on all supported API + * levels. */ final class SentryRootCertificates { private SentryRootCertificates() {} - /** - * DigiCert Global Root CA, SHA-256 - * 43:48:A0:E9:44:4C:78:CB:26:5E:05:8D:5E:89:44:B4:D8:4F:96:62:BD:26:DB:25:7F:89:34:A4:43:C7:01:61 - */ - static final @NotNull String DIGICERT_GLOBAL_ROOT_CA = - "-----BEGIN CERTIFICATE-----\n" - + "MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh\n" - + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" - + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD\n" - + "QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT\n" - + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" - + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG\n" - + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB\n" - + "CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97\n" - + "nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt\n" - + "43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P\n" - + "T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4\n" - + "gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO\n" - + "BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR\n" - + "TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw\n" - + "DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr\n" - + "hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg\n" - + "06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF\n" - + "PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls\n" - + "YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk\n" - + "CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=\n" - + "-----END CERTIFICATE-----\n"; - - /** - * DigiCert Global Root G2, SHA-256 - * CB:3C:CB:B7:60:31:E5:E0:13:8F:8D:D3:9A:23:F9:DE:47:FF:C3:5E:43:C1:14:4C:EA:27:D4:6A:5A:B1:CB:5F - */ - static final @NotNull String DIGICERT_GLOBAL_ROOT_G2 = - "-----BEGIN CERTIFICATE-----\n" - + "MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n" - + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" - + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n" - + "MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n" - + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" - + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n" - + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n" - + "2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n" - + "1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n" - + "q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n" - + "tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n" - + "vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n" - + "BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n" - + "5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n" - + "1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n" - + "NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n" - + "Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n" - + "8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n" - + "pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n" - + "MrY=\n" - + "-----END CERTIFICATE-----\n"; - /** * ISRG Root X1, SHA-256 * 96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6 @@ -106,147 +55,5 @@ private SentryRootCertificates() {} + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; - /** - * ISRG Root X2, SHA-256 - * 69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70 - */ - static final @NotNull String ISRG_ROOT_X2 = - "-----BEGIN CERTIFICATE-----\n" - + "MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n" - + "CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n" - + "R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n" - + "MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n" - + "ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n" - + "EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n" - + "+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n" - + "ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n" - + "AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n" - + "zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n" - + "tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n" - + "/q4AaOeMSQ+2b1tbFfLn\n" - + "-----END CERTIFICATE-----\n"; - - /** - * GTS Root R1, SHA-256 - * D9:47:43:2A:BD:E7:B7:FA:90:FC:2E:6B:59:10:1B:12:80:E0:E1:C7:E4:E4:0F:A3:C6:88:7F:FF:57:A7:F4:CF - */ - static final @NotNull String GTS_ROOT_R1 = - "-----BEGIN CERTIFICATE-----\n" - + "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\n" - + "bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n" - + "-----END CERTIFICATE-----\n"; - - /** - * GTS Root R2, SHA-256 - * 8D:25:CD:97:22:9D:BF:70:35:6B:DA:4E:B3:CC:73:40:31:E2:4C:F0:0F:AF:CF:D3:2D:C7:6E:B5:84:1C:7E:A8 - */ - static final @NotNull String GTS_ROOT_R2 = - "-----BEGIN CERTIFICATE-----\n" - + "MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw\n" - + "CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n" - + "MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n" - + "MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n" - + "Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA\n" - + "A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt\n" - + "nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY\n" - + "6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu\n" - + "MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k\n" - + "RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg\n" - + "f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV\n" - + "+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo\n" - + "dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW\n" - + "Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa\n" - + "G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq\n" - + "gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID\n" - + "AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n" - + "FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H\n" - + "vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8\n" - + "0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC\n" - + "B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u\n" - + "NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg\n" - + "yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev\n" - + "HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6\n" - + "xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR\n" - + "TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg\n" - + "JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV\n" - + "7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl\n" - + "6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL\n" - + "-----END CERTIFICATE-----\n"; - - /** - * GTS Root R3, SHA-256 - * 34:D8:A7:3E:E2:08:D9:BC:DB:0D:95:65:20:93:4B:4E:40:E6:94:82:59:6E:8B:6F:73:C8:42:6B:01:0A:6F:48 - */ - static final @NotNull String GTS_ROOT_R3 = - "-----BEGIN CERTIFICATE-----\n" - + "MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD\n" - + "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" - + "A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n" - + "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n" - + "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n" - + "AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G\n" - + "jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2\n" - + "4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" - + "BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7\n" - + "VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm\n" - + "ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X\n" - + "-----END CERTIFICATE-----\n"; - - /** - * GTS Root R4, SHA-256 - * 34:9D:FA:40:58:C5:E2:63:12:3B:39:8A:E7:95:57:3C:4E:13:13:C8:3F:E6:8F:93:55:6C:D5:E8:03:1B:3C:7D - */ - static final @NotNull String GTS_ROOT_R4 = - "-----BEGIN CERTIFICATE-----\n" - + "MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n" - + "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" - + "A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n" - + "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n" - + "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n" - + "AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n" - + "QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n" - + "HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" - + "BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n" - + "9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n" - + "p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n" - + "-----END CERTIFICATE-----\n"; - - static final @NotNull String[] ALL = - new String[] { - DIGICERT_GLOBAL_ROOT_CA, - DIGICERT_GLOBAL_ROOT_G2, - ISRG_ROOT_X1, - ISRG_ROOT_X2, - GTS_ROOT_R1, - GTS_ROOT_R2, - GTS_ROOT_R3, - GTS_ROOT_R4 - }; + static final @NotNull String[] ALL = new String[] {ISRG_ROOT_X1}; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt index 3a708c65fe9..0eff08bb5d1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -13,7 +13,7 @@ import org.mockito.kotlin.mock class SentryRootCaSslSocketFactoryTest { @Test - fun `bundled root CAs match the fingerprints published on docs_sentry_io`() { + fun `bundles ISRG Root X1 matching the fingerprint published on docs_sentry_io`() { val keyStore = SentryRootCaSslSocketFactory.createTrustStore(emptyArray()) val fingerprints = @@ -21,22 +21,8 @@ class SentryRootCaSslSocketFactoryTest { assertThat(fingerprints) .containsExactly( - // DigiCert Global Root CA - "43:48:A0:E9:44:4C:78:CB:26:5E:05:8D:5E:89:44:B4:D8:4F:96:62:BD:26:DB:25:7F:89:34:A4:43:C7:01:61", - // DigiCert Global Root G2 - "CB:3C:CB:B7:60:31:E5:E0:13:8F:8D:D3:9A:23:F9:DE:47:FF:C3:5E:43:C1:14:4C:EA:27:D4:6A:5A:B1:CB:5F", // ISRG Root X1 - "96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6", - // ISRG Root X2 - "69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70", - // GTS Root R1 - "D9:47:43:2A:BD:E7:B7:FA:90:FC:2E:6B:59:10:1B:12:80:E0:E1:C7:E4:E4:0F:A3:C6:88:7F:FF:57:A7:F4:CF", - // GTS Root R2 - "8D:25:CD:97:22:9D:BF:70:35:6B:DA:4E:B3:CC:73:40:31:E2:4C:F0:0F:AF:CF:D3:2D:C7:6E:B5:84:1C:7E:A8", - // GTS Root R3 - "34:D8:A7:3E:E2:08:D9:BC:DB:0D:95:65:20:93:4B:4E:40:E6:94:82:59:6E:8B:6F:73:C8:42:6B:01:0A:6F:48", - // GTS Root R4 - "34:9D:FA:40:58:C5:E2:63:12:3B:39:8A:E7:95:57:3C:4E:13:13:C8:3F:E6:8F:93:55:6C:D5:E8:03:1B:3C:7D", + "96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6" ) } From de0f67bb8d062a385cd3e3cf1e0453e98b87d379 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:07:32 +0000 Subject: [PATCH 04/11] ref(android): Add TODO for GTS roots before 2028 cross-sign expiry Co-Authored-By: roman --- .../java/io/sentry/android/core/SentryRootCertificates.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java index 416f3049840..9f0d7513299 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java @@ -55,5 +55,9 @@ private SentryRootCertificates() {} + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; + // TODO(2028-01-28): Google Trust Services certs are only trusted on API <= 28 via the GTS + // Root R1 cross-sign by GlobalSign Root CA, which expires on 2028-01-28. Before that date, + // bundle GTS Root R1-R4 here and raise the API gate in AndroidOptionsInitializer to <= 28. + // See https://github.com/getsentry/sentry-java/pull/6227 static final @NotNull String[] ALL = new String[] {ISRG_ROOT_X1}; } From 00f6637d61ff788e31fdbd86c6c12e5c71aedbab Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:52:12 +0000 Subject: [PATCH 05/11] ref(android): Address review feedback on bundled root CA - Mark SentryRootCaSslSocketFactory and SentryRootCertificates as @ApiStatus.Internal - Use StandardCharsets.UTF_8 instead of suppressing CharsetObjectCanBeUsed (minSdk 21) - Import Certificate in the test - Rewrite the changelog entry and link the TLS CA change announcement Co-Authored-By: roman --- CHANGELOG.md | 6 +++++- .../android/core/SentryRootCaSslSocketFactory.java | 10 +++++----- .../io/sentry/android/core/SentryRootCertificates.java | 2 ++ .../android/core/SentryRootCaSslSocketFactoryTest.kt | 3 ++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1cbec96d38..6e9315bed7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,11 @@ ### Fixes -- Trust a bundled Let's Encrypt root (ISRG Root X1) for SDK envelope uploads on Android API 25 and lower, which may not ship it ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) +- Keep sending events from Android 7.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) + - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). + - Android 7.0 (API 24) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. + - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. + - Apps need to update to an SDK version with this fix to keep sending events from these devices. ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java index ff3d905f216..12ce5f76465 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java @@ -7,7 +7,7 @@ import java.io.IOException; import java.net.InetAddress; import java.net.Socket; -import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.cert.Certificate; @@ -19,6 +19,7 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -37,11 +38,9 @@ * certificates is paid on the transport thread instead of during {@code SentryAndroid.init}. If * creating it fails, the platform default {@link SSLSocketFactory} is used. */ +@ApiStatus.Internal final class SentryRootCaSslSocketFactory extends SSLSocketFactory { - @SuppressWarnings("CharsetObjectCanBeUsed") - private static final Charset UTF_8 = Charset.forName("UTF-8"); - static final @NotNull String SENTRY_ROOT_CA_ALIAS_PREFIX = "sentry-root-ca-"; private final @NotNull LazyEvaluator delegate; @@ -97,7 +96,8 @@ final class SentryRootCaSslSocketFactory extends SSLSocketFactory { for (int i = 0; i < SentryRootCertificates.ALL.length; i++) { final @NotNull Certificate certificate = certificateFactory.generateCertificate( - new ByteArrayInputStream(SentryRootCertificates.ALL[i].getBytes(UTF_8))); + new ByteArrayInputStream( + SentryRootCertificates.ALL[i].getBytes(StandardCharsets.UTF_8))); keyStore.setCertificateEntry(SENTRY_ROOT_CA_ALIAS_PREFIX + i, certificate); } return keyStore; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java index 9f0d7513299..f6a53189adb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java @@ -1,5 +1,6 @@ package io.sentry.android.core; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** @@ -14,6 +15,7 @@ * Google Trust Services via its GlobalSign cross-sign) are already trusted on all supported API * levels. */ +@ApiStatus.Internal final class SentryRootCertificates { private SentryRootCertificates() {} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt index 0eff08bb5d1..c130f6b41f8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import java.security.KeyStore import java.security.MessageDigest +import java.security.cert.Certificate import javax.net.ssl.HttpsURLConnection import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -61,6 +62,6 @@ class SentryRootCaSslSocketFactoryTest { return factory.trustManagers.filterIsInstance().first() } - private fun java.security.cert.Certificate.sha256(): String = + private fun Certificate.sha256(): String = MessageDigest.getInstance("SHA-256").digest(encoded).joinToString(":") { "%02X".format(it) } } From d54b415ce321de0ed5035ced50f707eed510cd4e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:12:20 +0000 Subject: [PATCH 06/11] docs(changelog): Mention Android 7.1.0 (API 25) as affected Co-Authored-By: roman --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e9315bed7c..528038765a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,9 +35,9 @@ ### Fixes -- Keep sending events from Android 7.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) +- Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - - Android 7.0 (API 24) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. + - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. - Apps need to update to an SDK version with this fix to keep sending events from these devices. From 3f8dd9e574014c2cd79f3b90fc7417c9241ff69d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:38:49 +0000 Subject: [PATCH 07/11] fix(android): Only use bundled root CA for Sentry SaaS DSNs On API 24-25 the custom SSLSocketFactory reads the system CAs from the Network Security Config's base config, so domain-config trust anchors (for example a private CA for a self-hosted Sentry) would no longer apply to SDK uploads. Only install the factory when the DSN host is sentry.io or a subdomain, which is the only case that needs the bundled ISRG Root X1. Co-Authored-By: roman --- CHANGELOG.md | 2 +- .../core/AndroidOptionsInitializer.java | 8 +-- .../core/SentryRootCaSslSocketFactory.java | 51 ++++++++++++++++++- .../core/AndroidOptionsInitializerTest.kt | 21 ++++++-- .../core/SentryRootCaSslSocketFactoryTest.kt | 22 ++++++++ 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 528038765a3..bdfe1072dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. - - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. + - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry SaaS (DSNs on `sentry.io`), not to self-hosted Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. - Apps need to update to an SDK version with this fix to keep sending events from these devices. ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index ea27a2ba458..7626acfb1ba 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -202,10 +202,12 @@ static void initializeIntegrationsAndProcessors( if (options.getTransportGate() instanceof NoOpTransportGate) { options.setTransportGate(new AndroidTransportGate(options)); } - // API 25 and lower may lack ISRG Root X1 (Let's Encrypt), so the SDK's own envelope uploads - // trust the bundled root in addition to the system ones. + // API 25 and lower may lack ISRG Root X1 (Let's Encrypt), so the SDK's own envelope uploads to + // Sentry SaaS trust the bundled root in addition to the system ones. Self-hosted DSNs keep the + // platform default, so Network Security Config domain-config trust anchors still apply there. if (buildInfoProvider.getSdkInfoVersion() <= Build.VERSION_CODES.N_MR1 - && options.getSslSocketFactory() == null) { + && options.getSslSocketFactory() == null + && SentryRootCaSslSocketFactory.isSentrySaasDsn(options.getDsn())) { options.setSslSocketFactory(new SentryRootCaSslSocketFactory(options.getLogger())); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java index 12ce5f76465..24efef1bd83 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java @@ -13,6 +13,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.Locale; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; @@ -21,6 +22,7 @@ import javax.net.ssl.X509TrustManager; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; /** @@ -31,8 +33,15 @@ * *

Android API 25 and lower may not ship ISRG Root X1, so TLS handshakes with Let's Encrypt * certificates fail on those devices. This factory is only set as {@link - * io.sentry.SentryOptions#setSslSocketFactory(SSLSocketFactory)} on those API levels, so it only - * applies to the SDK's own envelope uploads and not to any other connection of the app. + * io.sentry.SentryOptions#setSslSocketFactory(SSLSocketFactory)} on those API levels and only when + * the DSN points to Sentry SaaS ({@code sentry.io}), see {@link #isSentrySaasDsn(String)}. So it + * only applies to the SDK's own envelope uploads to Sentry and not to any other connection of the + * app. + * + *

On API 24 and 25, the system CAs are read from the Network Security Config's base config. Any + * {@code domain-config} trust anchors or pins do not apply to connections using this factory. This + * is why it's limited to Sentry SaaS: self-hosted Sentry with a private CA keeps using the platform + * default. * *

The underlying {@link SSLContext} is created lazily on first use, so the cost of parsing the * certificates is paid on the transport thread instead of during {@code SentryAndroid.init}. If @@ -49,6 +58,44 @@ final class SentryRootCaSslSocketFactory extends SSLSocketFactory { this.delegate = new LazyEvaluator<>(() -> createDelegate(logger)); } + /** + * Returns {@code true} if the DSN's host is {@code sentry.io} or a subdomain of it, for example + * {@code o123.ingest.us.sentry.io}. Those are the hosts whose certificates chain to the bundled + * roots. Parses the host by hand instead of using {@link java.net.URI}, which is slow on Android. + */ + static boolean isSentrySaasDsn(final @Nullable String dsn) { + if (dsn == null) { + return false; + } + final int schemeEnd = dsn.indexOf("://"); + if (schemeEnd < 0) { + return false; + } + final int authorityStart = schemeEnd + 3; + int authorityEnd = dsn.length(); + for (int i = authorityStart; i < dsn.length(); i++) { + final char c = dsn.charAt(i); + if (c == '/' || c == '?' || c == '#') { + authorityEnd = i; + break; + } + } + String host = dsn.substring(authorityStart, authorityEnd); + final int userInfoEnd = host.lastIndexOf('@'); + if (userInfoEnd >= 0) { + host = host.substring(userInfoEnd + 1); + } + final int portStart = host.indexOf(':'); + if (portStart >= 0) { + host = host.substring(0, portStart); + } + if (host.endsWith(".")) { + host = host.substring(0, host.length() - 1); + } + host = host.toLowerCase(Locale.ROOT); + return host.equals("sentry.io") || host.endsWith(".sentry.io"); + } + private static @NotNull SSLSocketFactory createDelegate(final @NotNull ILogger logger) { try { final @NotNull TrustManagerFactory trustManagerFactory = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index e5b3bae5a56..99b99b81120 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -60,6 +60,8 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +private const val SAAS_DSN = "https://key@o1.ingest.us.sentry.io/1" + @RunWith(AndroidJUnit4::class) class AndroidOptionsInitializerTest { class Fixture(val context: Context, private val file: File) { @@ -371,15 +373,23 @@ class AndroidOptionsInitializerTest { @Config(sdk = [Build.VERSION_CODES.N_MR1]) @Test fun `init on API 25 and lower sets SSLSocketFactory with bundled Sentry root CAs`() { - fixture.initSut() + fixture.initSut(configureOptions = { dsn = SAAS_DSN }) assertIs(fixture.sentryOptions.sslSocketFactory) } + @Config(sdk = [Build.VERSION_CODES.N_MR1]) + @Test + fun `init on API 25 and lower does not set SSLSocketFactory for self-hosted DSN`() { + fixture.initSut(configureOptions = { dsn = "https://key@sentry.example.com/1" }) + + assertNull(fixture.sentryOptions.sslSocketFactory) + } + @Config(sdk = [Build.VERSION_CODES.O]) @Test fun `init on API 26 and higher does not set SSLSocketFactory`() { - fixture.initSut() + fixture.initSut(configureOptions = { dsn = SAAS_DSN }) assertNull(fixture.sentryOptions.sslSocketFactory) } @@ -388,7 +398,12 @@ class AndroidOptionsInitializerTest { @Test fun `init on API 25 and lower keeps user provided SSLSocketFactory`() { val sslSocketFactory = mock() - fixture.initSut(configureOptions = { this.sslSocketFactory = sslSocketFactory }) + fixture.initSut( + configureOptions = { + dsn = SAAS_DSN + this.sslSocketFactory = sslSocketFactory + } + ) assertEquals(sslSocketFactory, fixture.sentryOptions.sslSocketFactory) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt index c130f6b41f8..60a2c43f06d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -56,6 +56,28 @@ class SentryRootCaSslSocketFactoryTest { assertThat(sut.delegate).isSameInstanceAs(delegate) } + @Test + fun `isSentrySaasDsn matches sentry_io and its subdomains only`() { + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@o1.ingest.us.sentry.io/1")) + .isTrue() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@o1.ingest.sentry.io/1")) + .isTrue() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key:secret@sentry.io:443/1")) + .isTrue() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@O1.Ingest.SENTRY.IO./1")) + .isTrue() + + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn(null)).isFalse() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("")).isFalse() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@sentry.example.com/1")) + .isFalse() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@notsentry.io/1")).isFalse() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@sentry.io.evil.com/1")) + .isFalse() + assertThat(SentryRootCaSslSocketFactory.isSentrySaasDsn("https://key@evil.com/sentry.io")) + .isFalse() + } + private fun defaultTrustManager(keyStore: KeyStore?): X509TrustManager { val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) factory.init(keyStore) From 7a4b2ad4b368aa972dad7315156d3a74a453ddd0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:38:16 +0000 Subject: [PATCH 08/11] fix(android): Harden bundled root CA fallback and tests - Catch Throwable when building the delegate SSLSocketFactory, so a RuntimeException from OEM trust store code falls back to the default factory once instead of failing every upload without retry - Use @VisibleForTesting instead of @TestOnly on createTrustStore - Read the DSN host with a shared DsnUtil.getHost helper that parses it like Dsn does, without creating a URI - Test a real Let's Encrypt chain with PKIX: it fails without ISRG roots and validates with the bundled root - Remove ISRG roots from the JVM's system CAs in tests, so they prove the bundled root is added Co-Authored-By: roman --- .../core/SentryRootCaSslSocketFactory.java | 63 ++++----- .../core/SentryRootCaSslSocketFactoryTest.kt | 120 ++++++++++++++++-- .../resources/root-ca/letsencrypt-leaf.pem | 30 +++++ ...t-root-yr-cross-signed-by-isrg-root-x1.pem | 34 +++++ .../resources/root-ca/letsencrypt-yr1.pem | 28 ++++ sentry/api/sentry.api | 1 + sentry/src/main/java/io/sentry/Dsn.java | 34 ++++- sentry/src/main/java/io/sentry/DsnUtil.java | 9 ++ sentry/src/test/java/io/sentry/DsnTest.kt | 25 ++++ 9 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 sentry-android-core/src/test/resources/root-ca/letsencrypt-leaf.pem create mode 100644 sentry-android-core/src/test/resources/root-ca/letsencrypt-root-yr-cross-signed-by-isrg-root-x1.pem create mode 100644 sentry-android-core/src/test/resources/root-ca/letsencrypt-yr1.pem diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java index 24efef1bd83..e12cb8f3c08 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java @@ -1,5 +1,6 @@ package io.sentry.android.core; +import io.sentry.DsnUtil; import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.util.LazyEvaluator; @@ -24,6 +25,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; +import org.jetbrains.annotations.VisibleForTesting; /** * An {@link SSLSocketFactory} that trusts the system root CAs plus the root CAs bundled in {@link @@ -45,7 +47,8 @@ * *

The underlying {@link SSLContext} is created lazily on first use, so the cost of parsing the * certificates is paid on the transport thread instead of during {@code SentryAndroid.init}. If - * creating it fails, the platform default {@link SSLSocketFactory} is used. + * creating it fails for any reason, the platform default {@link SSLSocketFactory} is used and the + * failure is not retried. */ @ApiStatus.Internal final class SentryRootCaSslSocketFactory extends SSLSocketFactory { @@ -54,41 +57,34 @@ final class SentryRootCaSslSocketFactory extends SSLSocketFactory { private final @NotNull LazyEvaluator delegate; + /** Provides the CAs trusted by the platform. */ + interface SystemTrustedIssuersProvider { + @NotNull + X509Certificate[] getSystemTrustedIssuers() throws GeneralSecurityException; + } + SentryRootCaSslSocketFactory(final @NotNull ILogger logger) { - this.delegate = new LazyEvaluator<>(() -> createDelegate(logger)); + this(logger, SentryRootCaSslSocketFactory::getSystemTrustedIssuers); + } + + @VisibleForTesting + SentryRootCaSslSocketFactory( + final @NotNull ILogger logger, + final @NotNull SystemTrustedIssuersProvider systemTrustedIssuersProvider) { + this.delegate = new LazyEvaluator<>(() -> createDelegate(logger, systemTrustedIssuersProvider)); } /** * Returns {@code true} if the DSN's host is {@code sentry.io} or a subdomain of it, for example * {@code o123.ingest.us.sentry.io}. Those are the hosts whose certificates chain to the bundled - * roots. Parses the host by hand instead of using {@link java.net.URI}, which is slow on Android. + * roots. The host is read with {@link DsnUtil#getHost(String)}, which parses it the same way as + * the DSN the SDK sends envelopes to. */ static boolean isSentrySaasDsn(final @Nullable String dsn) { - if (dsn == null) { - return false; - } - final int schemeEnd = dsn.indexOf("://"); - if (schemeEnd < 0) { + @Nullable String host = DsnUtil.getHost(dsn); + if (host == null) { return false; } - final int authorityStart = schemeEnd + 3; - int authorityEnd = dsn.length(); - for (int i = authorityStart; i < dsn.length(); i++) { - final char c = dsn.charAt(i); - if (c == '/' || c == '?' || c == '#') { - authorityEnd = i; - break; - } - } - String host = dsn.substring(authorityStart, authorityEnd); - final int userInfoEnd = host.lastIndexOf('@'); - if (userInfoEnd >= 0) { - host = host.substring(userInfoEnd + 1); - } - final int portStart = host.indexOf(':'); - if (portStart >= 0) { - host = host.substring(0, portStart); - } if (host.endsWith(".")) { host = host.substring(0, host.length() - 1); } @@ -96,16 +92,23 @@ static boolean isSentrySaasDsn(final @Nullable String dsn) { return host.equals("sentry.io") || host.endsWith(".sentry.io"); } - private static @NotNull SSLSocketFactory createDelegate(final @NotNull ILogger logger) { + private static @NotNull SSLSocketFactory createDelegate( + final @NotNull ILogger logger, + final @NotNull SystemTrustedIssuersProvider systemTrustedIssuersProvider) { try { final @NotNull TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(createTrustStore(getSystemTrustedIssuers())); + trustManagerFactory.init( + createTrustStore(systemTrustedIssuersProvider.getSystemTrustedIssuers())); final @NotNull SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustManagerFactory.getTrustManagers(), null); return sslContext.getSocketFactory(); - } catch (GeneralSecurityException | IOException e) { + } catch (Throwable e) { + // Catches everything, not only GeneralSecurityException and IOException: some OEM builds + // throw RuntimeExceptions from their trust store or certificate code. An exception escaping + // here would make every envelope upload fail without being retried, and the LazyEvaluator + // would try to build the delegate again on every send. logger.log( SentryLevel.ERROR, "Failed to create SSLSocketFactory with bundled Sentry root CAs, using the default one.", @@ -131,7 +134,7 @@ static boolean isSentrySaasDsn(final @Nullable String dsn) { /** * Creates a trust store containing the given system trusted CAs plus the bundled Sentry root CAs. */ - @TestOnly + @VisibleForTesting static @NotNull KeyStore createTrustStore(final @NotNull X509Certificate[] systemTrustedIssuers) throws GeneralSecurityException, IOException { final @NotNull KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt index 60a2c43f06d..e55eb5508fc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -2,14 +2,26 @@ package io.sentry.android.core import com.google.common.truth.Truth.assertThat import io.sentry.ILogger +import io.sentry.SentryLevel import java.security.KeyStore import java.security.MessageDigest +import java.security.cert.CertPathValidator +import java.security.cert.CertPathValidatorException import java.security.cert.Certificate +import java.security.cert.CertificateFactory +import java.security.cert.PKIXParameters +import java.security.cert.X509Certificate +import java.util.Date +import java.util.concurrent.atomic.AtomicInteger import javax.net.ssl.HttpsURLConnection import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify class SentryRootCaSslSocketFactoryTest { @@ -23,13 +35,15 @@ class SentryRootCaSslSocketFactoryTest { assertThat(fingerprints) .containsExactly( // ISRG Root X1 - "96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6" + ISRG_ROOT_X1_SHA256 ) } @Test - fun `trust store keeps the system trusted CAs`() { - val systemIssuers = defaultTrustManager(null).acceptedIssuers + fun `trust store keeps the system trusted CAs and adds the bundled root`() { + // The JVM's cacerts already has the ISRG roots, so remove them to act like an old Android + // device. + val systemIssuers = systemIssuersWithoutIsrgRoots() assertThat(systemIssuers).isNotEmpty() val trustManager = @@ -37,12 +51,42 @@ class SentryRootCaSslSocketFactoryTest { val accepted = trustManager.acceptedIssuers.map { it.sha256() } assertThat(accepted).containsAtLeastElementsIn(systemIssuers.map { it.sha256() }) - assertThat(accepted) - .containsAtLeastElementsIn( - SentryRootCaSslSocketFactory.createTrustStore(emptyArray()).let { store -> - store.aliases().toList().map { store.getCertificate(it).sha256() } - } - ) + assertThat(accepted).contains(ISRG_ROOT_X1_SHA256) + } + + @Test + fun `Let's Encrypt chain only validates with the bundled root`() { + val chain = letsEncryptChain() + + // Without any ISRG root, like Android 7.1.0 and older. + val ex = + assertFailsWith { + validate(chain, plainKeyStore(systemIssuersWithoutIsrgRoots())) + } + assertThat(ex.message).contains("trust anchor") + + // The same system CAs plus the bundled root. + validate(chain, SentryRootCaSslSocketFactory.createTrustStore(systemIssuersWithoutIsrgRoots())) + // Only the bundled root. + validate(chain, SentryRootCaSslSocketFactory.createTrustStore(emptyArray())) + } + + @Test + fun `falls back to the default SSLSocketFactory once when building the delegate throws a RuntimeException`() { + val logger = mock() + val calls = AtomicInteger() + val sut = + SentryRootCaSslSocketFactory(logger) { + calls.incrementAndGet() + throw IllegalStateException("broken OEM trust store") + } + + assertThat(sut.delegate).isSameInstanceAs(HttpsURLConnection.getDefaultSSLSocketFactory()) + assertThat(sut.supportedCipherSuites).isNotEmpty() + assertThat(sut.delegate).isSameInstanceAs(HttpsURLConnection.getDefaultSSLSocketFactory()) + + assertThat(calls.get()).isEqualTo(1) + verify(logger).log(eq(SentryLevel.ERROR), any(), any()) } @Test @@ -78,6 +122,56 @@ class SentryRootCaSslSocketFactoryTest { .isFalse() } + /** The JVM's system CAs without ISRG Root X1 and any other ISRG root, such as Root YR. */ + private fun systemIssuersWithoutIsrgRoots(): Array = + defaultTrustManager(null) + .acceptedIssuers + .filter { + val subject = it.subjectX500Principal.name + it.sha256() != ISRG_ROOT_X1_SHA256 && + !subject.contains("O=ISRG") && + !subject.contains("O=Internet Security Research Group") + } + .toTypedArray() + + private fun plainKeyStore(certificates: Array): KeyStore = + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + certificates.forEachIndexed { i, certificate -> setCertificateEntry("ca-$i", certificate) } + } + + /** + * A real chain served by valid-isrgrootx1.letsencrypt.org: leaf, YR1 intermediate and Root YR + * cross-signed by ISRG Root X1. + */ + private fun letsEncryptChain(): List { + val factory = CertificateFactory.getInstance("X.509") + return listOf( + "letsencrypt-leaf.pem", + "letsencrypt-yr1.pem", + "letsencrypt-root-yr-cross-signed-by-isrg-root-x1.pem", + ) + .map { name -> + javaClass.classLoader!!.getResourceAsStream("root-ca/$name").use { + factory.generateCertificate(it) as X509Certificate + } + } + } + + /** + * Validates the chain with PKIX. Validates at a fixed date within the leaf's validity, so the + * test doesn't break when the leaf certificate expires. + */ + private fun validate(chain: List, trustStore: KeyStore) { + val params = + PKIXParameters(trustStore).apply { + isRevocationEnabled = false + date = Date(VALIDATION_DATE_MILLIS) + } + CertPathValidator.getInstance("PKIX") + .validate(CertificateFactory.getInstance("X.509").generateCertPath(chain), params) + } + private fun defaultTrustManager(keyStore: KeyStore?): X509TrustManager { val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) factory.init(keyStore) @@ -86,4 +180,12 @@ class SentryRootCaSslSocketFactoryTest { private fun Certificate.sha256(): String = MessageDigest.getInstance("SHA-256").digest(encoded).joinToString(":") { "%02X".format(it) } + + companion object { + private const val ISRG_ROOT_X1_SHA256 = + "96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6" + + // 2026-10-14T00:00:00Z, within the validity of all certificates in the Let's Encrypt chain. + private const val VALIDATION_DATE_MILLIS = 1791936000000L + } } diff --git a/sentry-android-core/src/test/resources/root-ca/letsencrypt-leaf.pem b/sentry-android-core/src/test/resources/root-ca/letsencrypt-leaf.pem new file mode 100644 index 00000000000..f8fff5c8970 --- /dev/null +++ b/sentry-android-core/src/test/resources/root-ca/letsencrypt-leaf.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFGzCCBAOgAwIBAgISBq7WJGYYt4Q8Ccz1ozUhHgUtMA0GCSqGSIb3DQEBCwUA +MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD +EwNZUjEwHhcNMjYxMDA3MTczODM3WhcNMjcwMTA1MTczODM2WjArMSkwJwYDVQQD +EyB2YWxpZC1pc3Jncm9vdHgxLmxldHNlbmNyeXB0Lm9yZzCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAJegq3LAK4w4Je/obWixFZkCkxAJT1r1bfIQhyPj +Wn805cO7xuZ5RMfhGMZbKMlAHo+qzYZ2U1vrOFvnVWX7F3YpHP3KlIpFfRPLvOZp +Sliqs/PPtB2AUjQuiJ4HSySdtQfEoLwbiIqaxOJ39uofDlsOYDtFbN6IbQO8ThDQ +0d7pfUbRJ65+p7evlkHqCOSrOl8Z5ZGq3OdAIqapU5azR1TzWHDXiB8a4TPkat+L +U7jzG6QkEOy9I2XRQG446F2uTQNELUDkI+hihBu8zrHGYJqVegRbVM0CqeD7qMlB +GGGJj51HRbQ9+GjC7FmKO8Mrgko02vQKf1XqREANIc/HMIUCAwEAAaOCAi8wggIr +MA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8E +AjAAMB0GA1UdDgQWBBSshwnlgVMBW7xOwxUy7sA16PafjDAfBgNVHSMEGDAWgBQf +LzW+RhSCzUCxrnksVXj699Ro+zAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAKG +F2h0dHA6Ly95cjEuaS5sZW5jci5vcmcvMCsGA1UdEQQkMCKCIHZhbGlkLWlzcmdy +b290eDEubGV0c2VuY3J5cHQub3JnMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC4GA1Ud +HwQnMCUwI6AhoB+GHWh0dHA6Ly95cjEuYy5sZW5jci5vcmcvNDYuY3JsMIIBDQYK +KwYBBAHWeQIEAgSB/gSB+wD5AHYARqI5Z8YNtkaHxm89+ZmUdpOmphEghFfVVefj +0KHZtkYAAAGhF6euqgAABAMARzBFAiBaxfd0LR2834Tq6CCmSa/g4ntvW2WPLxcT +y18xxUdJigIhANdm6yaOkq57UZrO5l4idTDkTTp/0D09lcADS7ponaLvAH8AjspH +C6zeavOiBrCkeoS3Rv4fxr+VPiXmm07kAkjzxugAAAGhF6evlQAIAAAFAB6q81IE +AwBIMEYCIQDIFPqkL/e1jgvJ1D/Gzn+NFfI3o3a7WHaUu/+c/h+ujgIhAIlx829E +YwDmgN7YNWDuUEeuqcoK/MlleU4K8Y/uYbfwMA0GCSqGSIb3DQEBCwUAA4IBAQBu +CYe3oorT9pbKsO/4Dy4nDxfVRM0r+eAbJcdIeTAZR4e6bIqdi28ynejflcdHTBip +hUkX1LUC2tTVpRMpo4CL+cLOUWNiDpy197ESv8suW0yF/P0/BnJWuyhaN6zvE7yi +kC9osdCretyxVBu17gZfYI6+RvJaUcmoOJDf6GZYx8ZNezsvo37K8toiOZcK+5Zd +jtjf5HvJWrtoptkNXpIMEgG9DNVGUzk/bjmDWOIYPeXNkMAkRczslg0iVqAjkZmV +N24GQ6xv5SRq8zTUDCld4d91izVOeLoq+p2cOprtCGvsrEa5LDzi6R0z/od7v4c2 +py2xTDhZOI36OVxyuzO4 +-----END CERTIFICATE----- diff --git a/sentry-android-core/src/test/resources/root-ca/letsencrypt-root-yr-cross-signed-by-isrg-root-x1.pem b/sentry-android-core/src/test/resources/root-ca/letsencrypt-root-yr-cross-signed-by-isrg-root-x1.pem new file mode 100644 index 00000000000..dd14377992b --- /dev/null +++ b/sentry-android-core/src/test/resources/root-ca/letsencrypt-root-yr-cross-signed-by-isrg-root-x1.pem @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/sentry-android-core/src/test/resources/root-ca/letsencrypt-yr1.pem b/sentry-android-core/src/test/resources/root-ca/letsencrypt-yr1.pem new file mode 100644 index 00000000000..eb3040fb203 --- /dev/null +++ b/sentry-android-core/src/test/resources/root-ca/letsencrypt-yr1.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE2zCCAsOgAwIBAgIRAKICU/FfJpHAXcHOE7m8yk4wDQYJKoZIhvcNAQELBQAw +LjELMAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxEDAOBgNVBAMTB1Jvb3QgWVIw +HhcNMjUwOTAzMDAwMDAwWhcNMjgwOTAyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEW +MBQGA1UEChMNTGV0J3MgRW5jcnlwdDEMMAoGA1UEAxMDWVIxMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoVi8X2xCYgMXvJxNPKp/oF13UMgmPABB07VC +LNDtoXmt9luEZNJSBV10VyT1Pz6LD8Zq1d2gc43WNl1AdRrj4sEnazbOiz0nPpmG +Bp2hui49oZtDIY6wdKeZAi5BbNU20CH6RSBBMLSQ9cXrH8dxdv4PAJ45ssGML68U +SE3BsjC2a6cAN9L5CgXVIQi5tfNiTPoFZZ3S0OlXqLmmtdV95udWAb5b6e/F49Di +CsH0Y00Ag72BVIb1hzynmKe+X0mERBTtsb3BwmpV9ipeBjMLoR/D9cHxHQCWoi5l +TmXwY015J5rGelz1nZjJuxc2kioaX29XJBnhMkP531rSdG5uMwIDAQABo4HuMIHr +MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDATASBgNVHRMBAf8E +CDAGAQH/AgEAMB0GA1UdDgQWBBQfLzW+RhSCzUCxrnksVXj699Ro+zAfBgNVHSME +GDAWgBTe51tg0CJtQCh9Pw0B/qS1UrRRlDAyBggrBgEFBQcBAQQmMCQwIgYIKwYB +BQUHMAKGFmh0dHA6Ly95ci5pLmxlbmNyLm9yZy8wEwYDVR0gBAwwCjAIBgZngQwB +AgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3lyLmMubGVuY3Iub3JnLzANBgkq +hkiG9w0BAQsFAAOCAgEA0+zvMq3kHig1ddTmmm+RibTr9/RpX7k4buanMMRqbV/y +IvP82zAHN3mvaw+cASuVsdpd0ikjhr4hnhJQLQOzOp2ccKrsdGOAgo0vddeISFAq +EWEV4lmUM3vFF796up+bSgmJ1u6RupDCMxDgF8M3eLvGuj6L0lu3zkQ0KuQLnKxL +tB0oQqn1Idg5CuuGpMvQzk29Pa3D/qHurc0EIM9SxukQuJqq63lxsYyRQFU8yMBO +hq1w5LbfaWNRrz1uklOfI/pYkAb2E2MTZrAMQkBIE2S8Jt1F8gRc96o/xOsrgvSk +a84AisX6xq1lz1Z7jGvrnXc4TMcjxZTjiTaihcYI1JIXZiLtEMSCa5l3cu8YWd6z +dLRQlqRdclVjuQfNHawRJ6GWlkK0QJosivTKwdBw3KxEtzGo8yMHERbsy57gP1UX +HOMcmZYQC0gtyR3SxfenIM/MxC3Ia2Ypab/kQ/CTnlIn2KQ5JUC6NYrGCbhFN9bp +5lKJStEwCUnLpntcrXk5XVDCNv/5RyWpRThkGOV7GetKkQ0qAY8hCzWK6oqnAhDZ +cjlYVdWfqOw3DIOX6EDNBgAqHarRVxyF9QZdOaXSyPJ0ueD2BYJEBgaCGQ8rAaU/ +Qc123V5LTXDZW4CcsPBDyhy4v+c8hClAyw/IkJlfBqxB9D+/wvIMHgECZ4ptP6o= +-----END CERTIFICATE----- diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 813a4691e83..abf8fc28526 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -508,6 +508,7 @@ public final class io/sentry/DiagnosticLogger : io/sentry/ILogger { public final class io/sentry/DsnUtil { public fun ()V + public static fun getHost (Ljava/lang/String;)Ljava/lang/String; public static fun urlContainsDsnHost (Lio/sentry/SentryOptions;Ljava/lang/String;)Z } diff --git a/sentry/src/main/java/io/sentry/Dsn.java b/sentry/src/main/java/io/sentry/Dsn.java index 15aea2a064c..1e4b49aed82 100644 --- a/sentry/src/main/java/io/sentry/Dsn.java +++ b/sentry/src/main/java/io/sentry/Dsn.java @@ -87,7 +87,7 @@ URI getSentryUri() { final String hostPort = hostAndPath.substring(0, firstSlash); final int portColon = portSeparatorIndex(hostPort); - final String host = portColon < 0 ? hostPort : hostPort.substring(0, portColon); + final String host = hostOf(hostPort, portColon); final int port = portColon < 0 ? -1 : parsePort(hostPort.substring(portColon + 1)); final String rawPath = stripTrailingSlash(collapseSlashes(hostAndPath.substring(firstSlash))); @@ -105,6 +105,38 @@ URI getSentryUri() { } } + /** + * Returns the host of the given DSN, parsed the same way as the constructor does, or {@code null} + * if no host can be found. Only the parts needed to find the host are checked, so a non-null + * result doesn't mean the DSN is valid. Doesn't create a {@link URI}, so it's cheap to call. + */ + static @Nullable String parseHost(final @Nullable String dsn) { + if (dsn == null) { + return null; + } + final String dsnString = dsn.trim(); + final int schemeEnd = dsnString.indexOf("://"); + if (schemeEnd < 0) { + return null; + } + final int atIndex = dsnString.indexOf('@', schemeEnd + 3); + if (atIndex < 0) { + return null; + } + final String hostAndPath = stripQueryAndFragment(dsnString, atIndex + 1); + final int firstSlash = hostAndPath.indexOf('/'); + if (firstSlash < 0) { + return null; + } + final String hostPort = hostAndPath.substring(0, firstSlash); + final String host = hostOf(hostPort, portSeparatorIndex(hostPort)); + return host.isEmpty() ? null : host; + } + + private static @NotNull String hostOf(final @NotNull String hostPort, final int portColon) { + return portColon < 0 ? hostPort : hostPort.substring(0, portColon); + } + private static int parsePort(final @NotNull String portString) { try { return Integer.parseInt(portString); diff --git a/sentry/src/main/java/io/sentry/DsnUtil.java b/sentry/src/main/java/io/sentry/DsnUtil.java index f31d1c286af..28f12d8aaa1 100644 --- a/sentry/src/main/java/io/sentry/DsnUtil.java +++ b/sentry/src/main/java/io/sentry/DsnUtil.java @@ -9,6 +9,15 @@ @ApiStatus.Internal public final class DsnUtil { + /** + * Returns the host of the given DSN, parsed the same way as the DSN the SDK sends envelopes to, + * or {@code null} if no host can be found. Unlike {@link SentryOptions#retrieveParsedDsn()}, this + * doesn't create a {@link URI}, so it's cheap enough to call on the main thread. + */ + public static @Nullable String getHost(final @Nullable String dsn) { + return Dsn.parseHost(dsn); + } + public static boolean urlContainsDsnHost(@Nullable SentryOptions options, @Nullable String url) { if (options == null) { return false; diff --git a/sentry/src/test/java/io/sentry/DsnTest.kt b/sentry/src/test/java/io/sentry/DsnTest.kt index f8195d16af6..24cd35adc76 100644 --- a/sentry/src/test/java/io/sentry/DsnTest.kt +++ b/sentry/src/test/java/io/sentry/DsnTest.kt @@ -249,4 +249,29 @@ class DsnTest { assertThat(dsn.sentryUri.toURL().toString()) .isEqualTo("https://o123.ingest.sentry.io/api/1234567") } + + @Test + fun `parseHost returns the same host as the parsed DSN`() { + listOf( + "https://key@o1.ingest.us.sentry.io/1", + "https://key:secret@sentry.io:443/path/1", + " https://key@host/id ", + "http://key@localhost:9000/456", + "https://key@host:443/id?foo=bar&baz=1", + "https://key@host/id#fragment", + "http://key@[::1]:9000/1", + "https://key@evil.com/x@o1.ingest.sentry.io/1", + ) + .forEach { dsn -> assertThat(Dsn.parseHost(dsn)).isEqualTo(Dsn(dsn).sentryUri.host) } + } + + @Test + fun `parseHost returns null when there is no host`() { + assertThat(Dsn.parseHost(null)).isNull() + assertThat(Dsn.parseHost("")).isNull() + assertThat(Dsn.parseHost("host/id")).isNull() + assertThat(Dsn.parseHost("https://host/id")).isNull() + assertThat(Dsn.parseHost("https://key@host")).isNull() + assertThat(Dsn.parseHost("https://key@/id")).isNull() + } } From d3c0252965b577ca84671fe50d1136e4a2cecd94 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:47:28 +0000 Subject: [PATCH 09/11] ref(android): Catch Exception instead of Throwable when building the delegate Errors such as OutOfMemoryError are no longer swallowed, as AGENTS.md requires. RuntimeExceptions from OEM trust store code still fall back to the default SSLSocketFactory. Co-Authored-By: roman --- .../sentry/android/core/SentryRootCaSslSocketFactory.java | 7 ++++--- .../android/core/SentryRootCaSslSocketFactoryTest.kt | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java index e12cb8f3c08..00e4ec17696 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java @@ -104,11 +104,12 @@ static boolean isSentrySaasDsn(final @Nullable String dsn) { final @NotNull SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustManagerFactory.getTrustManagers(), null); return sslContext.getSocketFactory(); - } catch (Throwable e) { - // Catches everything, not only GeneralSecurityException and IOException: some OEM builds + } catch (Exception e) { + // Catches Exception, not only GeneralSecurityException and IOException: some OEM builds // throw RuntimeExceptions from their trust store or certificate code. An exception escaping // here would make every envelope upload fail without being retried, and the LazyEvaluator - // would try to build the delegate again on every send. + // would try to build the delegate again on every send. Errors such as OutOfMemoryError are + // not caught on purpose, the process can't recover from them. logger.log( SentryLevel.ERROR, "Failed to create SSLSocketFactory with bundled Sentry root CAs, using the default one.", diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt index e55eb5508fc..c9a0613a15e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -122,6 +122,14 @@ class SentryRootCaSslSocketFactoryTest { .isFalse() } + @Test + fun `does not swallow Errors when building the delegate`() { + val sut = + SentryRootCaSslSocketFactory(mock()) { throw OutOfMemoryError("low memory device") } + + assertFailsWith { sut.delegate } + } + /** The JVM's system CAs without ISRG Root X1 and any other ISRG root, such as Root YR. */ private fun systemIssuersWithoutIsrgRoots(): Array = defaultTrustManager(null) From d22430aa388203611d7731e3d80af76543e1dbb7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:00:38 +0000 Subject: [PATCH 10/11] test(android): Drop OutOfMemoryError test Co-Authored-By: roman --- .../android/core/SentryRootCaSslSocketFactoryTest.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt index c9a0613a15e..e55eb5508fc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -122,14 +122,6 @@ class SentryRootCaSslSocketFactoryTest { .isFalse() } - @Test - fun `does not swallow Errors when building the delegate`() { - val sut = - SentryRootCaSslSocketFactory(mock()) { throw OutOfMemoryError("low memory device") } - - assertFailsWith { sut.delegate } - } - /** The JVM's system CAs without ISRG Root X1 and any other ISRG root, such as Root YR. */ private fun systemIssuersWithoutIsrgRoots(): Array = defaultTrustManager(null) From 0f67e7fdd8ea83da64f979c48aa5685cf8b2a301 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:20:28 +0000 Subject: [PATCH 11/11] changelog Co-Authored-By: roman --- CHANGELOG.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdfe1072dbb..6b500f873d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +### Fixes + +- Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) + - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). + - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. + - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry SaaS (DSNs on `sentry.io`), not to self-hosted Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. + - Apps need to update to an SDK version with this fix to keep sending events from these devices. + +### Internal + +- Measure the hostname cache TTL on the monotonic ticker the options provide, so that a device time change no longer shortens or extends it ([#6100](https://github.com/getsentry/sentry-java/pull/6100)) + ## 8.61.0 ### Fixes @@ -23,7 +37,6 @@ ### Internal - Hold the hostname cache on `SentryOptions` instead of a static singleton ([#6117](https://github.com/getsentry/sentry-java/pull/6117)) -- Measure the hostname cache TTL on the monotonic ticker the options provide, so that a device time change no longer shortens or extends it ([#6100](https://github.com/getsentry/sentry-java/pull/6100)) ### Dependencies @@ -33,14 +46,6 @@ ## 8.60.0 -### Fixes - -- Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. - - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry SaaS (DSNs on `sentry.io`), not to self-hosted Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. - - Apps need to update to an SDK version with this fix to keep sending events from these devices. - ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233))