Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
jobs:
analyze:
name: Analyze
runs-on: macos-15
runs-on: ["bitrise_pool_name:tahoe"]

env:
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/format-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
jobs:
format-code:
name: Format Code
runs-on: ubuntu-latest
runs-on: ["bitrise_pool_name:tahoe"]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/generate-javadocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
jobs:
build-and-deploy-javadocs:
name: Build and deploy Javadocs
runs-on: ubuntu-latest
runs-on: ["bitrise_pool_name:tahoe"]
steps:
- name: Checkout 🛎️
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests-macrobenchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ concurrency:
jobs:
macrobenchmark:
name: Macrobenchmark
runs-on: ubuntu-latest
runs-on: ["bitrise_pool_name:tahoe"]

# we copy the secret to the env variable in order to access it in the workflow
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
jobs:
build:
name: Build and Analyze SDK Size
runs-on: ubuntu-latest
runs-on: ["bitrise_pool_name:tahoe"]

env:
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/integration-tests-ui-critical.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ env:
jobs:
build:
name: Build
runs-on: ubuntu-latest
# Only the APK build runs on Bitrise; the Maestro job below needs KVM for the
# x86_64 emulator, which the macOS Bitrise VMs don't provide.
runs-on: ["bitrise_pool_name:tahoe"]

env:
GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
Expand Down
13 changes: 7 additions & 6 deletions .github/workflows/integration-tests-ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ concurrency:
jobs:
test:
name: Ui tests
runs-on: ubuntu-latest
runs-on: ["bitrise_pool_name:tahoe"]

# we copy the secret to the env variable in order to access it in the workflow
env:
Expand Down Expand Up @@ -81,11 +81,12 @@ jobs:
# Skip on PRs from forks, which don't have access to the upload secret
if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
run: |
shopt -s globstar nullglob
pngs=(artifacts/**/*.png)
if [ ${#pngs[@]} -gt 0 ]; then
mkdir -p replay-snapshots
cp "${pngs[@]}" replay-snapshots/
# find instead of `shopt -s globstar`, which macOS's bash 3.2 doesn't support
mkdir -p replay-snapshots
if [ -d artifacts ]; then
find artifacts -type f -name '*.png' -exec cp {} replay-snapshots/ \;
fi
if [ -n "$(ls -A replay-snapshots)" ]; then
sentry-cli snapshots upload ./replay-snapshots \
--app-id sentry-android-replay
else
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
steps:
- name: Get auth token
id: token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# 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))
- Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090))

## 8.61.0

### Fixes
Expand Down
4 changes: 0 additions & 4 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ plugins {
`kotlin-dsl`
}

repositories {
gradlePluginPortal()
}

dependencies {
implementation(libs.animalsniffer.gradle.plugin)
implementation(libs.spotlessLib)
Expand Down
12 changes: 12 additions & 0 deletions build-logic/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
Expand Down
4 changes: 0 additions & 4 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ plugins {
`kotlin-dsl`
}

repositories {
mavenCentral()
}

tasks.withType<KotlinCompile>().configureEach {
compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
14 changes: 14 additions & 0 deletions buildSrc/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}

rootProject.name = "sentry-buildSrc"
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 {

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");
}

/**
* 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);
}
}
Loading
Loading