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
Draft
sentry-junior
wants to merge
5
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.
Draft
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
04d1d78
fix(android): Trust bundled Sentry root CAs on API 25 and lower
sentry-junior[bot] b672c4a
changelog
sentry-junior[bot] a733ad6
ref(android): Only bundle ISRG Root X1
sentry-junior[bot] fc0aa5b
ref(android): Add TODO for GTS roots before 2028 cross-sign expiry
sentry-junior[bot] 7372b1f
ref(android): Address review feedback on bundled root CA
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
160 changes: 160 additions & 0 deletions
160
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,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. | ||
| * | ||
| * <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, so it only | ||
| * applies to the SDK's own envelope uploads and not to any other connection of the app. | ||
| * | ||
| * <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, 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<SSLSocketFactory> 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); | ||
| } | ||
| } | ||
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
67 changes: 67 additions & 0 deletions
67
sentry-android-core/src/test/java/io/sentry/android/core/SentryRootCaSslSocketFactoryTest.kt
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,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<ILogger>()) | ||
|
|
||
| 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<X509TrustManager>().first() | ||
| } | ||
|
|
||
| private fun Certificate.sha256(): String = | ||
| MessageDigest.getInstance("SHA-256").digest(encoded).joinToString(":") { "%02X".format(it) } | ||
| } |
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.
Uh oh!
There was an error while loading. Please reload this page.