Repository navigation
fix(android): Trust bundled ISRG Root X1 on API 25 and lower #6227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sentry-junior
wants to merge
11
commits into
main
Choose a base branch
from
fix/android-bundle-sentry-root-cas
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+689
โ2
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
a10efb3
fix(android): Trust bundled Sentry root CAs on API 25 and lower
sentry-junior[bot] 93b082b
changelog
sentry-junior[bot] 6472eb5
ref(android): Only bundle ISRG Root X1
sentry-junior[bot] de0f67b
ref(android): Add TODO for GTS roots before 2028 cross-sign expiry
sentry-junior[bot] 00f6637
ref(android): Address review feedback on bundled root CA
sentry-junior[bot] d54b415
docs(changelog): Mention Android 7.1.0 (API 25) as affected
sentry-junior[bot] 3f8dd9e
fix(android): Only use bundled root CA for Sentry SaaS DSNs
sentry-junior[bot] 7a4b2ad
fix(android): Harden bundled root CA fallback and tests
sentry-junior[bot] d3c0252
ref(android): Catch Exception instead of Throwable when building the โฆ
sentry-junior[bot] d22430a
test(android): Drop OutOfMemoryError test
sentry-junior[bot] 0f67e7f
changelog
sentry-junior[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
211 changes: 211 additions & 0 deletions
211
sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCaSslSocketFactory.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
|
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"); | ||
| } | ||
|
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); | ||
| } | ||
| } | ||
65 changes: 65 additions & 0 deletions
65
sentry-android-core/src/main/java/io/sentry/android/core/SentryRootCertificates.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| + "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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}; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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