Repository navigation
fix(transport): Reject leaked eMudhra intermediate certificates #6336
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鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
runningcode
wants to merge
3
commits into
main
Choose a base branch
from
no/distrust-emudhra-intermediates
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.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
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
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
99 changes: 99 additions & 0 deletions
99
sentry/src/main/java/io/sentry/transport/DistrustedCertificates.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,99 @@ | ||
| package io.sentry.transport; | ||
|
|
||
| import java.net.HttpURLConnection; | ||
| import java.security.MessageDigest; | ||
| import java.security.NoSuchAlgorithmException; | ||
| import java.security.cert.Certificate; | ||
| import java.util.Arrays; | ||
| import java.util.HashSet; | ||
| import java.util.Set; | ||
| import javax.net.ssl.HttpsURLConnection; | ||
| import javax.net.ssl.SSLPeerUnverifiedException; | ||
| import org.jetbrains.annotations.ApiStatus; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
|
|
||
| /** | ||
| * Rejects servers whose certificate chain contains a CA certificate that is known to be compromised | ||
| * but still trusted by platform trust stores. | ||
| * | ||
| * <p>Browsers block such certificates on their own and require Certificate Transparency, but {@link | ||
| * HttpsURLConnection} does neither. Android only updates its CA store with OS updates (or Mainline | ||
| * on Android 14+) and the JVM only with JDK updates, so a distrust by the root programs doesn't | ||
| * reach every device. | ||
| */ | ||
| @ApiStatus.Internal | ||
| public final class DistrustedCertificates { | ||
|
|
||
| /** | ||
| * SHA-256 hashes of the SubjectPublicKeyInfo of the eight eMudhra intermediates whose private | ||
| * keys can be recovered from their certificates (CVE-2026-111943). Each key is in two | ||
| * certificates: one issued by eMudhra and a cross-sign by Sectigo's AAA Certificate Services | ||
| * root, which is trusted on every Android version. Matching the key covers both. | ||
| * | ||
| * @see <a | ||
| * href="https://groups.google.com/a/mozilla.org/g/dev-security-policy/c/0R3XLJL6FuE">Compromised | ||
| * eMudhra Intermediate Certificates</a> | ||
| */ | ||
| private static final Set<String> SPKI_SHA256_HASHES = | ||
| new HashSet<>( | ||
| Arrays.asList( | ||
| "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b", | ||
| "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f", | ||
| "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0", | ||
| "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8", | ||
| "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30", | ||
| "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41", | ||
| "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105", | ||
| "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6")); | ||
|
|
||
| private static final DistrustedCertificates instance = new DistrustedCertificates(); | ||
|
|
||
| public static @NotNull DistrustedCertificates getInstance() { | ||
| return instance; | ||
| } | ||
|
|
||
| private DistrustedCertificates() {} | ||
|
|
||
| /** | ||
| * Disconnects and throws if the server presented a distrusted certificate. | ||
| * | ||
| * <p>Call it right after {@link HttpURLConnection#connect()}: the TLS handshake is done by then, | ||
| * but no part of the request has been written yet. A {@link javax.net.ssl.HostnameVerifier} can't | ||
| * do this check, because the JVM's {@link HttpsURLConnection} only calls it when its own hostname | ||
| * check fails. | ||
| */ | ||
| public void verify(final @NotNull HttpURLConnection connection) | ||
| throws SSLPeerUnverifiedException { | ||
| if (!(connection instanceof HttpsURLConnection)) { | ||
| return; | ||
| } | ||
| final @Nullable Certificate[] certificates = | ||
| ((HttpsURLConnection) connection).getServerCertificates(); | ||
| if (certificates == null) { | ||
| return; | ||
| } | ||
| for (final @NotNull Certificate certificate : certificates) { | ||
| if (SPKI_SHA256_HASHES.contains(spkiSha256(certificate))) { | ||
| connection.disconnect(); | ||
| throw new SSLPeerUnverifiedException( | ||
| "The server's certificate chain contains a distrusted certificate."); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static @NotNull String spkiSha256(final @NotNull Certificate certificate) { | ||
| final byte[] hash; | ||
| try { | ||
| hash = MessageDigest.getInstance("SHA-256").digest(certificate.getPublicKey().getEncoded()); | ||
| } catch (NoSuchAlgorithmException e) { | ||
| // Every Java and Android platform is required to support SHA-256. | ||
| throw new AssertionError(e); | ||
| } | ||
| final StringBuilder hex = new StringBuilder(hash.length * 2); | ||
| for (final byte b : hash) { | ||
| hex.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16)); | ||
| } | ||
| return hex.toString(); | ||
| } | ||
| } |
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 |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ final class HttpConnection { | |
| private final @NotNull RequestDetails requestDetails; | ||
| private final @NotNull SentryOptions options; | ||
| private final @NotNull RateLimiter rateLimiter; | ||
| private final @NotNull DistrustedCertificates distrustedCertificates; | ||
|
|
||
| /** | ||
| * Constructs a new HTTP transport instance. Notably, the provided {@code requestUpdater} must set | ||
|
|
@@ -47,17 +48,24 @@ public HttpConnection( | |
| final @NotNull SentryOptions options, | ||
| final @NotNull RequestDetails requestDetails, | ||
| final @NotNull RateLimiter rateLimiter) { | ||
| this(options, requestDetails, AuthenticatorWrapper.getInstance(), rateLimiter); | ||
| this( | ||
| options, | ||
| requestDetails, | ||
| AuthenticatorWrapper.getInstance(), | ||
| rateLimiter, | ||
| DistrustedCertificates.getInstance()); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here as an alternative design i inject the DistrustedCertificates instance making this easier to test/replace. |
||
| } | ||
|
|
||
| HttpConnection( | ||
| final @NotNull SentryOptions options, | ||
| final @NotNull RequestDetails requestDetails, | ||
| final @NotNull AuthenticatorWrapper authenticatorWrapper, | ||
| final @NotNull RateLimiter rateLimiter) { | ||
| final @NotNull RateLimiter rateLimiter, | ||
| final @NotNull DistrustedCertificates distrustedCertificates) { | ||
| this.requestDetails = requestDetails; | ||
| this.options = options; | ||
| this.rateLimiter = rateLimiter; | ||
| this.distrustedCertificates = distrustedCertificates; | ||
|
|
||
| this.proxy = resolveProxy(options.getProxy()); | ||
|
|
||
|
|
@@ -143,6 +151,7 @@ HttpURLConnection open() throws IOException { | |
| } | ||
|
|
||
| connection.connect(); | ||
| distrustedCertificates.verify(connection); | ||
| return connection; | ||
| } | ||
|
|
||
|
|
||
72 changes: 72 additions & 0 deletions
72
sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.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,72 @@ | ||
| package io.sentry.transport | ||
|
|
||
| import java.net.HttpURLConnection | ||
| import java.security.PublicKey | ||
| import java.security.cert.Certificate | ||
| import java.security.cert.CertificateFactory | ||
| import javax.net.ssl.HttpsURLConnection | ||
| import javax.net.ssl.SSLPeerUnverifiedException | ||
| import kotlin.test.Test | ||
| import kotlin.test.assertFailsWith | ||
| import org.mockito.kotlin.mock | ||
| import org.mockito.kotlin.never | ||
| import org.mockito.kotlin.verify | ||
| import org.mockito.kotlin.whenever | ||
|
|
||
| class DistrustedCertificatesTest { | ||
|
|
||
| private val connection = mock<HttpsURLConnection>() | ||
| private val sut = DistrustedCertificates.getInstance() | ||
|
|
||
| @Test | ||
| fun `throws and disconnects when the chain contains a distrusted certificate`() { | ||
| val certificates = arrayOf(trustedCertificate(), distrustedCertificate()) | ||
| whenever(connection.serverCertificates).thenReturn(certificates) | ||
|
|
||
| assertFailsWith<SSLPeerUnverifiedException> { sut.verify(connection) } | ||
|
|
||
| verify(connection).disconnect() | ||
| } | ||
|
|
||
| @Test | ||
| fun `accepts a chain without distrusted certificates`() { | ||
| val certificates = arrayOf(trustedCertificate()) | ||
| whenever(connection.serverCertificates).thenReturn(certificates) | ||
|
|
||
| sut.verify(connection) | ||
|
|
||
| verify(connection, never()).disconnect() | ||
| } | ||
|
|
||
| @Test | ||
| fun `accepts a connection without server certificates`() { | ||
| whenever(connection.serverCertificates).thenReturn(null) | ||
|
|
||
| sut.verify(connection) | ||
|
|
||
| verify(connection, never()).disconnect() | ||
| } | ||
|
|
||
| @Test | ||
| fun `ignores plain HTTP connections`() { | ||
| val connection = mock<HttpURLConnection>() | ||
|
|
||
| sut.verify(connection) | ||
|
|
||
| verify(connection, never()).disconnect() | ||
| } | ||
|
|
||
| /** EM DV TLS CA - G3B as cross-signed by Sectigo's AAA Certificate Services root. */ | ||
| private fun distrustedCertificate(): Certificate = | ||
| javaClass.classLoader!!.getResourceAsStream("emudhra-em-dv-tls-ca-g3b.pem")!!.use { | ||
| CertificateFactory.getInstance("X.509").generateCertificate(it) | ||
| } | ||
|
|
||
| private fun trustedCertificate(): Certificate { | ||
| val publicKey = mock<PublicKey>() | ||
| whenever(publicKey.encoded).thenReturn(byteArrayOf(1, 2, 3)) | ||
| val certificate = mock<Certificate>() | ||
| whenever(certificate.publicKey).thenReturn(publicKey) | ||
| return certificate | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| -----BEGIN CERTIFICATE----- | ||
| MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw | ||
| ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G | ||
| A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV | ||
| BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y | ||
| ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW | ||
| MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA | ||
| IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO | ||
| L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2 | ||
| PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd | ||
| BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG | ||
| A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC | ||
| MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h | ||
| AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB | ||
| QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF | ||
| BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB | ||
| AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE | ||
| wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM | ||
| QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ | ||
| 7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl | ||
| bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V | ||
| GUyRCEIDhF5TBUuKF4E3kf1h | ||
| -----END CERTIFICATE----- |
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.
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.
right now this is called statically, but we could also put it in SentryOptions and grab it from there but I wanted to avoid making a larger change since this is just for DistributionHttpClient.