diff --git a/CHANGELOG.md b/CHANGELOG.md index 4126d1706d..6151079045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### 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)) + - 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 - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) 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 12ffb90356..ea27a2ba45 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 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())); + } 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 0000000000..12ce5f7646 --- /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.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 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.TestOnly; + +/** + * 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 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. + * + *

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. + */ +@ApiStatus.Internal +final class SentryRootCaSslSocketFactory extends SSLSocketFactory { + + 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(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); + } +} 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 0000000000..f6a53189ad --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java @@ -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}. + * + *

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" + + "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\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}; +} 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 4610e4bbcb..e5b3bae5a5 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 0000000000..c130f6b41f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt @@ -0,0 +1,67 @@ +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 java.security.cert.Certificate +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 `bundles ISRG Root X1 matching the fingerprint published on docs_sentry_io`() { + val keyStore = SentryRootCaSslSocketFactory.createTrustStore(emptyArray()) + + val fingerprints = + keyStore.aliases().toList().map { alias -> keyStore.getCertificate(alias).sha256() } + + 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" + ) + } + + @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 Certificate.sha256(): String = + MessageDigest.getInstance("SHA-256").digest(encoded).joinToString(":") { "%02X".format(it) } +}