#!/bin/bash # # generateCertificatePins.sh # # Prints the SSL certificate pins used for certificate pinning (see # config/certificatePinning/pins.json). Every pin is a ROOT CA SPKI hash. # # WHY ROOTS: leaves are re-keyed on every renewal and every CA in play issues # from a rotating pool of intermediates - both have already broken # leaf/intermediate pins in production (the 2026-07-07 Let's Encrypt -> GTS # edge rotation on Cloudflare, and the 2026-07 Amazon M01 -> M04 intermediate # rotation on CloudFront). The CA roots are the only durable pin target. # # A TLS server never sends its root certificate, so pins can NOT be derived # from a live handshake. Instead, this script downloads the root certificates # from the Mozilla CA bundle (via curl.se) into a gitignored cache # (config/certificatePinning/roots/) and verifies each one against the # SHA-256 certificate fingerprints committed in ROOT_MANIFEST below. The # fingerprints - not the downloaded bytes - are the source of trust: a # tampered or wrong download fails loudly. Use --verify to confirm each # pinned domain's live chain actually builds up to one of its pinned roots. # # Usage: # ./scripts/generateCertificatePins.sh # print the root pins per group and per domain # ./scripts/generateCertificatePins.sh --android # also print the network_security_config blocks # ./scripts/generateCertificatePins.sh --verify # also check each live chain anchors at a pinned root # # (exits 1 if any host FAILs or is UNREACHABLE) # ./scripts/generateCertificatePins.sh --refresh # force re-download of the cached roots; a cached # # root the bundle no longer carries is removed # # ROOTS_BUNDLE=/path/to/bundle.pem ./scripts/generateCertificatePins.sh # Use a local CA bundle instead of downloading (offline/CI use). The # fingerprint verification still applies. # ROOTS_DIR=/path/to/cache ./scripts/generateCertificatePins.sh # Use a different root cache directory (default: # config/certificatePinning/roots/). The regression tests point this at # a temp dir. # # Regression tests: tests/unit/generateCertificatePinsTest.ts covers clean # generation, --refresh, unreachable hosts under --verify, and pins.json # staying in sync with the manifest. # # After running, sync the values into: # - config/certificatePinning/pins.json (canonical source of truth) # - android/app/src/main/res/xml/network_security_config_enforce.xml # - android/app/src/main/java/com/expensify/chat/CertificatePinning.kt # - ios/CertificatePinning.swift # - patches/react-native-nitro-fetch/react-native-nitro-fetch+1.6.2+001+certificate-pinning.patch # - Mobile-Expensify/Android/res/xml/network_security_config_enforce.xml # - Mobile-Expensify/Android/src/yapl/android/http/ExpensifyCertificatePinner.java # - Mobile-Expensify/iOS/Expensify/ExpensifyAppDelegate.m (TrustKit kTSKPublicKeyHashes) # # To add or replace a root (e.g. a CDN starts using a new CA): look up the # root's SHA-256 certificate fingerprint in the CA's official repository, add # a "|" entry to ROOT_MANIFEST and the name to the relevant # group below, and re-run this script. The root must be in the Mozilla CA # program (all publicly trusted TLS roots are); if the bundle no longer # carries a pinned root, that is a distrust signal to investigate - but it is # NOT on its own a reason to unpin it, because the device trust stores that # actually select the anchor lag Mozilla (see the GTS Root R2 note below). # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOTS_DIR="${ROOTS_DIR:-${SCRIPT_DIR}/../config/certificatePinning/roots}" MOZILLA_BUNDLE_URL="https://curl.se/ca/cacert.pem" # Trust-anchor manifest: "|". # The committed fingerprints are what anchor trust in the downloaded roots. # When adding an entry, verify the fingerprint against the CA's published # value (letsencrypt.org/certificates, pki.goog/repository, ssl.com/repository, # sectigo.com/knowledge-base, amazontrust.com/repository). # # A root here must be in the current Mozilla bundle. One that has left the bundle # but is still trusted on devices belongs in RETAINED_PINS below instead. ROOT_MANIFEST=( "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" "ISRG_Root_X2|69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70" "GTS_Root_R1|D9:47:43:2A:BD:E7:B7:FA:90:FC:2E:6B:59:10:1B:12:80:E0:E1:C7:E4:E4:0F:A3:C6:88:7F:FF:57:A7:F4:CF" "GTS_Root_R3|34:D8:A7:3E:E2:08:D9:BC:DB:0D:95:65:20:93:4B:4E:40:E6:94:82:59:6E:8B:6F:73:C8:42:6B:01:0A:6F:48" "GTS_Root_R4|34:9D:FA:40:58:C5:E2:63:12:3B:39:8A:E7:95:57:3C:4E:13:13:C8:3F:E6:8F:93:55:6C:D5:E8:03:1B:3C:7D" "SSL.com_TLS_ECC_Root_CA_2022|C3:2F:FD:9F:46:F9:36:D1:6C:36:73:99:09:59:43:4B:9A:D6:0A:AF:BB:9E:7C:F3:36:54:F1:44:CC:1B:A1:43" "SSL.com_TLS_RSA_Root_CA_2022|8F:AF:7D:2E:2C:B4:70:9B:B8:E0:B3:36:66:BF:75:A5:DD:45:B5:DE:48:0F:8E:A8:D4:BF:E6:BE:BC:17:F2:ED" "USERTrust_RSA_Certification_Authority|E7:93:C9:B0:2F:D8:AA:13:E2:1C:31:22:8A:CC:B0:81:19:64:3B:74:9C:89:89:64:B1:74:6D:46:C3:D4:CB:D2" "USERTrust_ECC_Certification_Authority|4F:F4:60:D5:4B:9C:86:DA:BF:BC:FC:57:12:E0:40:0D:2B:ED:3F:BC:4D:4F:BD:AA:86:E0:6A:DC:D2:A9:AD:7A" "Sectigo_Public_Server_Authentication_Root_R46|7B:B6:47:A6:2A:EE:AC:88:BF:25:7A:A5:22:D0:1F:FE:A3:95:E0:AB:45:C7:3F:93:F6:56:54:EC:38:F2:5A:06" "Sectigo_Public_Server_Authentication_Root_E46|C9:0F:26:F0:FB:1B:40:18:B2:22:27:51:9B:5C:A2:B5:3E:2C:A5:B3:BE:5C:F1:8E:FE:1B:EF:47:38:0C:53:83" "Amazon_Root_CA_1|8E:CD:E6:88:4F:3D:87:B1:12:5B:A3:1A:C3:FC:B1:3D:70:16:DE:7F:57:CC:90:4F:E1:CB:97:C6:AE:98:19:6E" "Amazon_Root_CA_2|1B:A5:B2:AA:8C:65:40:1A:82:96:01:18:F8:0B:EC:4F:62:30:4D:83:CE:C4:71:3A:19:C3:9C:01:1E:A4:6D:B4" "Amazon_Root_CA_3|18:CE:6C:FE:7B:F1:4E:60:B2:E3:47:B8:DF:E8:68:CB:31:D0:2E:BB:3A:DA:27:15:69:F5:03:43:B4:6D:B3:A4" "Amazon_Root_CA_4|E3:5D:28:41:9E:D0:20:25:CF:A6:90:38:CD:62:39:62:45:8D:A5:C6:95:FB:DE:A3:C2:2B:0B:FB:25:89:70:92" "Starfield_Services_Root_CA_G2|56:8D:69:05:A2:C8:87:08:A4:B3:02:51:90:ED:CF:ED:B1:97:4A:60:6A:13:C6:E5:29:0F:CB:2A:E6:3E:DA:B5" ) # Pins that ship without a certificate behind them. Pins are matched against the # anchor the DEVICE trust store selected, not against Mozilla's bundle: Android # ships CA updates on its own cadence (old OS versions keep roots for years) and # Apple removes roots on Apple's schedule, so a root Mozilla has dropped can still # be what a phone anchors at. Such a connection validates at the TLS layer and # would then MISS a root-only pin set that omits it - monitor noise now, blocked # requests once enforce mode is on. # # The bundle no longer carries the certificate, so its SPKI hash cannot be # re-derived here. The hash below is the value this script generated while the # root was still in the bundle; `git log -S` on it in config/certificatePinning/ # pins.json shows when and from what it came. The fingerprint identifies that # certificate and the URL is where to obtain it if you want to re-check the hash # by hand: # # curl -s | openssl x509 -noout -fingerprint -sha256 # curl -s | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER \ # | openssl dgst -sha256 -binary | openssl enc -base64 # # Entries are "||||