Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this in this PR too as I noticed it accidentally leaked into 8.61.0


## 8.61.0

### Fixes
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ 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 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
&& SentryRootCaSslSocketFactory.isSentrySaasDsn(options.getDsn())) {
options.setSslSocketFactory(new SentryRootCaSslSocketFactory(options.getLogger()));
}

final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance();
options.setAppStartExtender(appStartMetrics.getAppStartExtension());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package io.sentry.android.core;

import io.sentry.DsnUtil;
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.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
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;
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.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
* 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.
*
* <p>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 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.
*
* <p>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.
*
* <p>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 for any reason, the platform default {@link SSLSocketFactory} is used and the
* failure is not retried.
*/
@ApiStatus.Internal
final class SentryRootCaSslSocketFactory extends SSLSocketFactory {
Comment thread
sentry-junior[bot] marked this conversation as resolved.

static final @NotNull String SENTRY_ROOT_CA_ALIAS_PREFIX = "sentry-root-ca-";

private final @NotNull LazyEvaluator<SSLSocketFactory> delegate;

/** Provides the CAs trusted by the platform. */
interface SystemTrustedIssuersProvider {
@NotNull
X509Certificate[] getSystemTrustedIssuers() throws GeneralSecurityException;
}

SentryRootCaSslSocketFactory(final @NotNull ILogger 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. 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) {
@Nullable String host = DsnUtil.getHost(dsn);
if (host == null) {
return false;
}
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,
final @NotNull SystemTrustedIssuersProvider systemTrustedIssuersProvider) {
try {
final @NotNull TrustManagerFactory trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(
createTrustStore(systemTrustedIssuersProvider.getSystemTrustedIssuers()));

final @NotNull SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
return sslContext.getSocketFactory();
} 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. 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.",
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");
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Creates a trust store containing the given system trusted CAs plus the bundled Sentry root CAs.
*/
@VisibleForTesting
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(StandardCharsets.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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package io.sentry.android.core;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

/**
* Root CA certificates the SDK bundles for its own envelope uploads, see {@link
* SentryRootCaSslSocketFactory}.
*
* <p>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.
*/
@ApiStatus.Internal
final class SentryRootCertificates {

private SentryRootCertificates() {}

/**
* 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"
+ "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
+ "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n"
+ "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n"
+ "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n"
+ "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n"
+ "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n"
+ "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n"
+ "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n"
+ "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n"
+ "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n"
+ "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n"
+ "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n"
+ "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n"
+ "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n"
+ "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n"
+ "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n"
+ "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n"
+ "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n"
+ "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n"
+ "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n"
+ "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n"
+ "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n"
+ "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n"
+ "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n"
+ "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n"
+ "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n"
+ "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n"
+ "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n"
+ "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};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -368,6 +370,44 @@ 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(configureOptions = { dsn = SAAS_DSN })

assertIs<SentryRootCaSslSocketFactory>(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(configureOptions = { dsn = SAAS_DSN })

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<javax.net.ssl.SSLSocketFactory>()
fixture.initSut(
configureOptions = {
dsn = SAAS_DSN
this.sslSocketFactory = sslSocketFactory
}
)

assertEquals(sslSocketFactory, fixture.sentryOptions.sslSocketFactory)
}

@Test
fun `init should set Android continuous profiler`() {
fixture.initSut()
Expand Down
Loading
Loading