Skip to content
Open
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
122 changes: 107 additions & 15 deletions android/app/src/main/java/com/expensify/chat/CertificatePinning.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.expensify.chat

import android.net.http.X509TrustManagerExtensions
import com.facebook.react.modules.network.OkHttpClientProvider
import io.sentry.Sentry
import io.sentry.SentryLevel
import okhttp3.CertificatePinner
import okhttp3.Interceptor
import okhttp3.Response
import java.security.KeyStore
import java.security.cert.Certificate
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager

/**
* Certificate pinning for React Native's shared OkHttp client (Iteration 1 - NewDot).
Expand Down Expand Up @@ -46,20 +52,43 @@ object CertificatePinning {
private const val CERTIFICATE_PINNING_MODE_TAG = "certificate_pinning_mode"
private const val CERTIFICATE_PINNING_CHANNEL_TAG = "certificate_pinning_channel"

/**
* Cloudflare can issue the expensify.com edge certificate from any of the CAs it uses -
* Let's Encrypt, Google Trust Services or SSL.com - and can rotate between them without notice
* (the unannounced 2026-07-07 Let's Encrypt -> Google Trust Services rotation is what broke us).
* To survive leaf rotation, intermediate rotation AND a CA switch without an emergency release, the
* Cloudflare-fronted hosts (Groups A & B) pin the SPKI of the ROOT of each of those three CAs, plus
* the live issuing intermediate for each. Any of these appearing in the served chain satisfies the
* pin. Regenerate with scripts/generateCertificatePins.sh (which also prints these CA pins).
*/
private val CLOUDFLARE_EXPENSIFY_PINS: List<String> = listOf(
// Let's Encrypt - live CA for www/secure/staging (reverted to Let's Encrypt after the 2026-07-07 GTS rotation)
"sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", // ISRG Root X1
"sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", // ISRG Root X2
"sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=", // Let's Encrypt YE1 intermediate (live ECDSA)
// Google Trust Services - backup CA
"sha256/hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", // GTS Root R1
"sha256/Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=", // GTS Root R2
"sha256/QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=", // GTS Root R3
"sha256/mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=", // GTS Root R4
"sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=", // Google Trust Services WE1 intermediate (live ECDSA)
// SSL.com - backup CA
"sha256/G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=", // SSL.com TLS ECC Root CA 2022
"sha256/K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=", // SSL.com TLS RSA Root CA 2022
)

/**
* Canonical pin data: domain → list of "sha256/<base64>" pin strings.
* The first hash is the leaf SPKI, the second is the issuing intermediate CA SPKI.
* Keep in sync with config/certificatePinning/pins.json.
*/
private val PINNED_DOMAINS: Map<String, List<String>> = mapOf(
// Group A: leaf CN=expensify.com + Let's Encrypt YE1 intermediate
"www.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="),
"secure.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="),
"staging.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="),
"staging-secure.expensify.com" to listOf("sha256/cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=", "sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4="),
// Group B: leaf CN=expensify.com + Google Trust Services WE1 intermediate
"new.expensify.com" to listOf("sha256/G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="),
"staging.new.expensify.com" to listOf("sha256/G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=", "sha256/kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4="),
// Groups A & B: Cloudflare-fronted expensify.com hosts - multi-CA root + intermediate pinning (see above)
"www.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS,
"secure.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS,
"staging.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS,
"staging-secure.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS,
"new.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS,
"staging.new.expensify.com" to CLOUDFLARE_EXPENSIFY_PINS,
// Group C: integrations leaf + Let's Encrypt R13 intermediate
"integrations.expensify.com" to listOf("sha256/7D0dEgdEKEMYRTgVwvnhJv19B4apk0QM/GPnRAKRGUs=", "sha256/AlSQhgtJirc8ahLyekmtX+Iw+v46yPYRLJt9Cq1GlB0="),
// Group D: travel leaf + Google Trust Services WE1 intermediate
Expand All @@ -77,6 +106,62 @@ object CertificatePinning {
return builder.build()
}

/**
* System trust manager used to rebuild the validated chain up to its trust anchor. Lazily
* initialized; null if the platform trust manager is unavailable.
*/
private val trustManagerExtensions: X509TrustManagerExtensions? by lazy {
try {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?)
val tm = tmf.trustManagers
.filterIsInstance<X509TrustManager>()
.firstOrNull() ?: return@lazy null
X509TrustManagerExtensions(tm)
} catch (_: Exception) {
null
}
}

/**
* Returns the fully validated chain (leaf → intermediates → trust-anchor ROOT) for the raw
* certificates a server presented during the handshake.
*
* A TLS server sends only its leaf and intermediates — never the root — so calling
* [CertificatePinner.check] directly on the raw peer list can only ever match a pinned leaf or
* intermediate, never a pinned ROOT. Because our durable, rotation-proof pins are the CA ROOTs
* (a leaf/intermediate rotation must not require an app update), those pins would silently fail
* to match on the raw peer chain and produce false pin-mismatch reports whenever a CA issues from
* an intermediate we don't happen to pin. Rebuilding the chain via the system trust manager
* appends the anchor, so the root pins are actually evaluated — matching how the platform
* `<pin-set>` and OkHttp's own enforce-mode check behave. Mirrors
* [WebViewCertificateMonitor]'s chain reconstruction. Falls back to the raw peer certificates if
* reconstruction is unavailable or fails, so behaviour is never worse than before.
*/
private fun anchoredChain(peerCertificates: List<Certificate>, host: String): List<Certificate> {
val extensions = trustManagerExtensions ?: return peerCertificates
val x509Chain = peerCertificates.filterIsInstance<X509Certificate>()
val leaf = x509Chain.firstOrNull() ?: return peerCertificates

val authTypes = if (leaf.publicKey.algorithm == "EC") {
arrayOf("ECDHE_ECDSA", "ECDSA")
} else {
arrayOf("RSA", "ECDHE_RSA")
}

for (authType in authTypes) {
try {
val fullChain = extensions.checkServerTrusted(x509Chain.toTypedArray(), authType, host)
if (fullChain.isNotEmpty()) {
return fullChain
}
} catch (_: Exception) {
// Try the next authType; fall back to the raw peer chain if all fail.
}
}
return peerCertificates
}

/**
* Install the pinned OkHttp client factory. Must be called before any networking (i.e. early in
* [MainApplication.onCreate]). Pinning is disabled in debug builds so local dev keeps working.
Expand Down Expand Up @@ -107,15 +192,17 @@ object CertificatePinning {
* Installs a wrapping [javax.net.ssl.HostnameVerifier] on [HttpsURLConnection] that validates
* certificate pins after the platform hostname verifier succeeds. This covers native code and
* third-party libraries that use [java.net.URL] / [HttpsURLConnection] instead of OkHttp.
* Mismatches are reported to Sentry without failing the connection (monitor mode only).
* The served chain is passed through [anchoredChain] first so root pins are evaluated, not just
* the leaf/intermediate the server sent. Mismatches are reported to Sentry without failing the
* connection (monitor mode only).
*/
private fun installHttpsURLConnectionMonitor(certificatePinner: CertificatePinner) {
val originalVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
HttpsURLConnection.setDefaultHostnameVerifier { hostname, session ->
val result = originalVerifier.verify(hostname, session)
if (result && PINNED_DOMAINS.containsKey(hostname)) {
try {
certificatePinner.check(hostname, session.peerCertificates.toList())
certificatePinner.check(hostname, anchoredChain(session.peerCertificates.toList(), hostname))
} catch (error: SSLPeerUnverifiedException) {
reportPinningFailure(
hostname = hostname,
Expand Down Expand Up @@ -185,22 +272,27 @@ object CertificatePinning {

/**
* Validates certificate pins after the TLS handshake completes without blocking the request.
* Used during the monitor-only rollout phase.
* Used during the monitor-only rollout phase. The served chain is passed through [anchoredChain]
* first so the trust-anchor ROOT is included and our root pins are actually evaluated (see
* [anchoredChain]); otherwise only a pinned leaf/intermediate could ever match here.
*/
private class CertificatePinningMonitorInterceptor(
private val certificatePinner: CertificatePinner,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val host = request.url.host
val handshake = chain.connection()?.handshake()

if (handshake != null) {
// Only the pinned expensify hosts are validated (and only they pay the chain-rebuild
// cost in anchoredChain); all other traffic passes through untouched.
if (handshake != null && PINNED_DOMAINS.containsKey(host)) {
try {
certificatePinner.check(request.url.host, handshake.peerCertificates)
certificatePinner.check(host, anchoredChain(handshake.peerCertificates, host))
} catch (error: SSLPeerUnverifiedException) {
reportPinningFailure(
hostname = request.url.host,
hostname = host,
url = request.url,
channel = "OkHttp",
message = error.message ?: "Certificate pinning validation failed",
Expand Down
30 changes: 19 additions & 11 deletions android/app/src/main/res/xml/network_security_config_enforce.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,33 @@
pin-set silently disables pinning; rotation is managed through app updates instead.
-->
<network-security-config>
<!-- Group A: leaf CN=expensify.com + Let's Encrypt YE1 intermediate -->
<!-- Groups A & B: Cloudflare-fronted expensify.com hosts. Cloudflare can rotate the edge cert
between Let's Encrypt, Google Trust Services and SSL.com without notice (the 2026-07-07
Let's Encrypt -> GTS rotation broke us; www has since reverted to Let's Encrypt). To survive
leaf rotation, intermediate rotation AND a CA switch without an emergency release, we pin the
SPKI of the ROOT of all three CAs plus the live issuing intermediate of each. Regenerate via
scripts/generateCertificatePins.sh. -->
<domain-config>
<domain includeSubdomains="false">www.expensify.com</domain>
<domain includeSubdomains="false">secure.expensify.com</domain>
<domain includeSubdomains="false">staging.expensify.com</domain>
<domain includeSubdomains="false">staging-secure.expensify.com</domain>
<pin-set>
<pin digest="SHA-256">cSP5K9Slk59AgwZPst+dLPuNE+ZhypUlYRQNW1XC/fc=</pin>
<pin digest="SHA-256">brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=</pin>
</pin-set>
</domain-config>

<!-- Group B: leaf CN=expensify.com + Google Trust Services WE1 intermediate -->
<domain-config>
<domain includeSubdomains="false">new.expensify.com</domain>
<domain includeSubdomains="false">staging.new.expensify.com</domain>
<pin-set>
<pin digest="SHA-256">G2v6PWWl92F5vVHCtAYwScBHqNtPMkxb++SFoBJq5F4=</pin>
<pin digest="SHA-256">kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=</pin>
<!-- Let's Encrypt (live CA for these hosts) -->
<pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin> <!-- ISRG Root X1 -->
<pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin> <!-- ISRG Root X2 -->
<pin digest="SHA-256">brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=</pin> <!-- Let's Encrypt YE1 intermediate -->
<!-- Google Trust Services (backup) -->
<pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin> <!-- GTS Root R1 -->
<pin digest="SHA-256">Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=</pin> <!-- GTS Root R2 -->
<pin digest="SHA-256">QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=</pin> <!-- GTS Root R3 -->
<pin digest="SHA-256">mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=</pin> <!-- GTS Root R4 -->
<pin digest="SHA-256">kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=</pin> <!-- Google Trust Services WE1 intermediate -->
<!-- SSL.com (backup) -->
<pin digest="SHA-256">G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=</pin> <!-- SSL.com TLS ECC Root CA 2022 -->
<pin digest="SHA-256">K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=</pin> <!-- SSL.com TLS RSA Root CA 2022 -->
</pin-set>
</domain-config>

Expand Down
42 changes: 42 additions & 0 deletions config/certificatePinning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,53 @@ When pins change, update **all** of them. Each domain pins:
Both production and staging hosts are pinned in every release build, because beta/TestFlight builds
resolve their runtime environment to STAGING and hit `staging.*` APIs while still being non-debug.

## Cloudflare-fronted hosts: multi-CA root + intermediate pinning

The `expensify.com` edge certificates for `www`, `secure`, `staging`, `staging-secure`, `new` and
`staging.new` are issued by **Cloudflare**, which can pick — and rotate between — any of the CAs it
uses (**Let's Encrypt**, **Google Trust Services**, **SSL.com**) without notice. An unannounced
Let's Encrypt → Google Trust Services rotation on 2026-07-07 is what broke pinning; `www` has since
reverted to Let's Encrypt. Cloudflare explicitly documents that you should **not** pin a single CA's
chain ([SSL/TLS docs](https://developers.cloudflare.com/ssl/reference/certificate-pinning/)).

To keep the app working across leaf rotation, intermediate rotation, **and** a switch between those
three CAs — without shipping an emergency release each time — these six hosts (Groups A & B) share
one pin set that pins the **SPKI of the ROOT** of all three CAs plus each CA's **live issuing
intermediate**. Pinning the roots is what survives an intermediate rotation (the failure mode that
hit us). Any one of these appearing in the served chain satisfies the pin:

| CA | Pin (base64 SHA-256 of SPKI) | Certificate |
|----|------------------------------|-------------|
| Let's Encrypt | `C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=` | ISRG Root X1 (RSA 4096) |
| Let's Encrypt | `diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=` | ISRG Root X2 (ECDSA P-384) |
| Let's Encrypt | `brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=` | Let's Encrypt YE1 (live ECDSA intermediate) |
| Google Trust Services | `hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=` | GTS Root R1 (RSA 4096) |
| Google Trust Services | `Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=` | GTS Root R2 (RSA 4096) |
| Google Trust Services | `QXnt2YHvdHR3tJYmQIr0Paosp6t/nggsEGD4QJZ3Q0g=` | GTS Root R3 (ECDSA P-384) |
| Google Trust Services | `mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=` | GTS Root R4 (ECDSA P-384) |
| Google Trust Services | `kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=` | GTS WE1 (live ECDSA intermediate) |
| SSL.com | `G/ANXI8TwJTdF+AFBM8IiIUPEv0Gf6H5LA/b9guG4yE=` | SSL.com TLS ECC Root CA 2022 (ECDSA P-384) |
| SSL.com | `K89VOmb1cJAN3TK6bf4ezAbJGC1mLcG2Dh97dnwr3VQ=` | SSL.com TLS RSA Root CA 2022 (RSA 4096) |

These root/intermediate pins are broad by design (they trust each CA's whole hierarchy), which is the
tightest safe posture for a host whose CA is controlled by Cloudflare. The other groups
(`integrations`, `travel`, CloudFront) are single-CA and keep the tighter leaf + issuing-intermediate
pinning. Trade-off accepted per the incident: resilience over a narrower trust set for the Cloudflare
hosts.

**Before flipping these hosts to enforce mode**, re-run `scripts/generateCertificatePins.sh
--ca-pins` on a networked machine to confirm each root/intermediate SPKI still matches (roots are
stable for years, but confirm) and that the live `www`/`new` chains still terminate in one of the
pinned CAs.

## Regenerating pins

```bash
./scripts/generateCertificatePins.sh # prints leaf + intermediate hashes per domain
./scripts/generateCertificatePins.sh --android # also prints the network_security_config <pin-set>
./scripts/generateCertificatePins.sh --ca-pins # prints the multi-CA root+intermediate pins for the
# Cloudflare-fronted expensify.com hosts (Groups A & B),
# computed from each CA's official published certificate
```

## Rotation runbook
Expand Down
Loading
Loading