scripts/generateCertificatePins.sh
Rama main · réplica de lectura del repositorio original.
#!/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 <pin-set> 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 "<Name>|<FINGERPRINT>" 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: "<name>|<SHA-256 certificate fingerprint>".
# 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 <url> | openssl x509 -noout -fingerprint -sha256
# curl -s <url> | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER \
# | openssl dgst -sha256 -binary | openssl enc -base64
#
# Entries are "<name>|<SPKI pin>|<SHA-256 fingerprint>|<source URL>|<label>" and
# keep their place in the group lists below, so the generated order still matches
# pins.json. --verify cannot check a chain against a retained root (there is no
# certificate to build a path to); it says so and verifies the rest.
#
# Remove an entry only once the supported platform trust stores no longer trust
# the root - see step 4 of the rotation runbook in config/certificatePinning/README.md.
RETAINED_PINS=(
"GTS_Root_R2|Vfd95BwDeSQo+NUYxVEEIlvkOlWY2SalKK1lPhzOx78=|8D:25:CD:97:22:9D:BF:70:35:6B:DA:4E:B3:CC:73:40:31:E2:4C:F0:0F:AF:CF:D3:2D:C7:6E:B5:84:1C:7E:A8|https://pki.goog/repo/certs/gtsr2.pem|GTS Root R2 - retained, dropped from the Mozilla bundle in 2026"
)
# Groups A-D: Cloudflare-fronted expensify.com hosts. Cloudflare can rotate the
# edge certificate between Let's Encrypt, Google Trust Services and SSL.com
# without notice, and its backup certificates - deployed automatically on a
# revocation or key compromise - can also be issued by Sectigo
# (developers.cloudflare.com/ssl/reference/certificate-authorities/), so the
# roots of all four CAs are pinned. Sectigo issues from two hierarchies
# (USERTrust RSA/ECC and Sectigo Public Server Authentication Root R46/E46),
# so both are included.
CLOUDFLARE_ROOTS=(
"ISRG_Root_X1"
"ISRG_Root_X2"
"GTS_Root_R1"
"GTS_Root_R2"
"GTS_Root_R3"
"GTS_Root_R4"
"SSL.com_TLS_ECC_Root_CA_2022"
"SSL.com_TLS_RSA_Root_CA_2022"
"USERTrust_RSA_Certification_Authority"
"USERTrust_ECC_Certification_Authority"
"Sectigo_Public_Server_Authentication_Root_R46"
"Sectigo_Public_Server_Authentication_Root_E46"
)
CLOUDFLARE_DOMAINS=(
"www.expensify.com"
"secure.expensify.com"
"staging.expensify.com"
"staging-secure.expensify.com"
"new.expensify.com"
"staging.new.expensify.com"
"integrations.expensify.com"
"travel.expensify.com"
"staging.travel.expensify.com"
)
# Group E: CloudFront CDN. AWS documents the Amazon Trust Services roots as the
# only stable pin targets for ACM-issued certificates.
CLOUDFRONT_ROOTS=(
"Amazon_Root_CA_1"
"Amazon_Root_CA_2"
"Amazon_Root_CA_3"
"Amazon_Root_CA_4"
"Starfield_Services_Root_CA_G2"
)
CLOUDFRONT_DOMAINS=(
"d2k5nsl2zxldvw.cloudfront.net"
)
ANDROID=0
VERIFY=0
REFRESH=0
for arg in "$@"; do
case "$arg" in
--android) ANDROID=1 ;;
--verify) VERIFY=1 ;;
--refresh) REFRESH=1 ;;
*)
echo "Unknown option: $arg" >&2
echo "Usage: $0 [--android] [--verify] [--refresh]" >&2
exit 1
;;
esac
done
fingerprint_of() {
# $1 = path to a PEM certificate. Prints the SHA-256 certificate fingerprint.
openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//'
}
manifest_fingerprint() {
# $1 = root name. Prints its expected fingerprint, failing loudly if unknown.
local entry name fingerprint
for entry in "${ROOT_MANIFEST[@]}"; do
IFS='|' read -r name fingerprint <<< "$entry"
if [ "$name" = "$1" ]; then
echo "$fingerprint"
return 0
fi
done
echo "ERROR: root '$1' is not in ROOT_MANIFEST" >&2
exit 1
}
retained_field() {
# $1 = root name, $2 = field index in the RETAINED_PINS entry. Prints nothing
# when the root is not retained, which is how callers tell the two apart.
local entry name pin fingerprint url label
for entry in "${RETAINED_PINS[@]}"; do
IFS='|' read -r name pin fingerprint url label <<< "$entry"
if [ "$name" = "$1" ]; then
case "$2" in
2) echo "$pin" ;;
3) echo "$fingerprint" ;;
4) echo "$url" ;;
5) echo "$label" ;;
esac
return 0
fi
done
}
retained_pin() {
# $1 = root name. Prints the committed SPKI pin for a retained root, or nothing.
retained_field "$1" 2
}
cached_root_ok() {
# $1 = root name. Succeeds if the cached PEM exists and matches its fingerprint.
local path="${ROOTS_DIR}/$1.pem"
[ -s "$path" ] || return 1
[ "$(fingerprint_of "$path")" = "$(manifest_fingerprint "$1")" ]
}
ensure_roots() {
# Downloads (or reads $ROOTS_BUNDLE), fingerprint-verifies, and caches every
# root in ROOT_MANIFEST under $ROOTS_DIR. No-op when the cache is already
# complete and verified, unless --refresh was passed.
mkdir -p "$ROOTS_DIR"
local needed=() entry name
for entry in "${ROOT_MANIFEST[@]}"; do
name="${entry%%|*}"
if [ "$REFRESH" = "1" ] || ! cached_root_ok "$name"; then
needed+=("$name")
fi
done
if [ "${#needed[@]}" -eq 0 ]; then
return
fi
local tmpdir bundle
tmpdir="$(mktemp -d)"
if [ -n "${ROOTS_BUNDLE:-}" ]; then
bundle="$ROOTS_BUNDLE"
echo "Using local CA bundle: ${bundle}" >&2
else
bundle="${tmpdir}/cacert.pem"
echo "Downloading Mozilla CA bundle from ${MOZILLA_BUNDLE_URL} ..." >&2
if ! curl -fsSL "$MOZILLA_BUNDLE_URL" -o "$bundle"; then
echo "ERROR: could not download the CA bundle. Either retry with network access" >&2
echo "or point ROOTS_BUNDLE at a local copy (e.g. from another checkout or" >&2
echo "your system's ca-certificates package). Missing roots: ${needed[*]}" >&2
rm -rf "$tmpdir"
exit 1
fi
fi
# Split the bundle into individual certificates.
awk -v dir="$tmpdir" '
/BEGIN CERTIFICATE/ { c++; f = dir "/split_" c ".pem" }
c > 0 { print >> f }
/END CERTIFICATE/ { close(f) }
' "$bundle"
# Extract every needed root into a staging directory by matching fingerprints
# (identity = fingerprint, never the bundle's own labels). Nothing touches the
# cache until the whole bundle has been scanned.
local staging="${tmpdir}/roots"
mkdir -p "$staging"
local split fp
for split in "$tmpdir"/split_*.pem; do
[ -s "$split" ] || continue
fp="$(fingerprint_of "$split")"
for name in "${needed[@]}"; do
if [ "$fp" = "$(manifest_fingerprint "$name")" ]; then
openssl x509 -in "$split" -out "${staging}/${name}.pem" 2>/dev/null
fi
done
done
# Install what the bundle provided. A needed root that the bundle does NOT
# carry is an error even when a previously cached copy exists: that copy is
# stale (this happens on --refresh, or when the cached PEM failed its
# fingerprint check), so it is removed rather than silently kept - otherwise a
# --refresh could never surface a root that Mozilla has since dropped.
local failed=0
for name in "${needed[@]}"; do
if [ -s "${staging}/${name}.pem" ]; then
mv -f "${staging}/${name}.pem" "${ROOTS_DIR}/${name}.pem"
continue
fi
if [ -e "${ROOTS_DIR}/${name}.pem" ]; then
rm -f "${ROOTS_DIR}/${name}.pem"
echo "Removed stale cached root ${ROOTS_DIR}/${name}.pem (not in the current CA bundle)." >&2
fi
echo "ERROR: root '${name}' was not found in the CA bundle. If Mozilla no longer" >&2
echo "ships this root, that is a distrust signal - investigate before pinning it." >&2
echo "Do NOT unpin it on that basis alone: pins are matched against the anchor the" >&2
echo "DEVICE trust store selected, and Android/Apple drop roots later than Mozilla," >&2
echo "so a still-trusted root missing from the pin set breaks live traffic. To keep" >&2
echo "shipping the pin, move the root from ROOT_MANIFEST to RETAINED_PINS (see the" >&2
echo "comment above it) - its committed hash is then carried forward as-is." >&2
failed=1
done
rm -rf "$tmpdir"
[ "$failed" = "0" ] || exit 1
# Every root must now be cached and fingerprint-verified.
for name in "${needed[@]}"; do
if ! cached_root_ok "$name"; then
echo "ERROR: cached root ${ROOTS_DIR}/${name}.pem failed fingerprint verification." >&2
exit 1
fi
done
}
spki_hash_from_pem() {
# $1 = path to a PEM certificate. Prints base64(SHA-256(SPKI)).
openssl x509 -in "$1" -pubkey -noout 2>/dev/null \
| openssl pkey -pubin -outform DER 2>/dev/null \
| openssl dgst -sha256 -binary \
| openssl enc -base64
}
root_pem_path() {
# $1 = root name. Prints the cached PEM path, failing loudly if missing.
local path="${ROOTS_DIR}/$1.pem"
if [ ! -s "$path" ]; then
echo "ERROR: missing root certificate ${path}" >&2
exit 1
fi
echo "$path"
}
print_group() {
# $1 = group label; remaining args = root names.
local label="$1"
shift
echo "=== ${label} ==="
local root
for root in "$@"; do
local pem hash subject retained
retained="$(retained_pin "$root")"
if [ -n "$retained" ]; then
printf ' sha256/%s [%s]\n' "$retained" "$(retained_field "$root" 5)"
continue
fi
pem="$(root_pem_path "$root")"
hash="$(spki_hash_from_pem "$pem")"
subject="$(openssl x509 -in "$pem" -noout -subject 2>/dev/null | sed 's/^subject=//')"
printf ' sha256/%s [%s]\n' "$hash" "$subject"
done
echo
}
openssl_isolation_flags() {
# Flags that stop `openssl verify` consulting the system trust store. Without
# them the default CA directory (SSL_CERT_DIR, /etc/ssl/certs) is still
# searched for an anchor, so a chain that anchors at ANY locally trusted CA
# verifies and --verify reports "anchors at a pinned root" for a chain that
# does nothing of the sort. The pinned roots passed with -CAfile must be the
# only anchors considered.
local help flag
help="$(openssl verify -help 2>&1 || true)"
ISOLATION_FLAGS=()
for flag in -no-CAfile -no-CApath -no-CAstore; do
case "$help" in
*"$flag"*) ISOLATION_FLAGS+=("$flag") ;;
esac
done
# -no-CApath is the one that matters (the default directory); refuse to run
# rather than report a verification this openssl cannot actually isolate.
case " ${ISOLATION_FLAGS[*]} " in
*" -no-CApath "*) ;;
*)
echo "ERROR: this openssl ($(openssl version)) does not support -no-CApath, so" >&2
echo "--verify cannot keep the system trust store out of the chain check and would" >&2
echo "report a pass for a chain anchored at any locally trusted CA. Re-run with a" >&2
echo "modern OpenSSL (3.x)." >&2
exit 1
;;
esac
}
verify_retained_pins() {
# --verify is the one networked command, so it is where a retained pin gets
# checked against the CA's published certificate: fetch it, confirm the
# fingerprint, re-derive the SPKI hash and compare it with the value shipped in
# RETAINED_PINS. A verified certificate then also serves as a trust anchor
# below, so a chain that legitimately builds to a retained root verifies
# instead of being reported as a failure.
RETAINED_ANCHOR_DIR="$(mktemp -d)"
local entry name pin fingerprint url label pem got_fp got_pin
for entry in "${RETAINED_PINS[@]}"; do
IFS='|' read -r name pin fingerprint url label <<< "$entry"
pem="${RETAINED_ANCHOR_DIR}/${name}.pem"
if ! curl -fsSL "$url" -o "$pem" 2>/dev/null; then
rm -f "$pem"
printf ' %-34s UNREACHABLE (could not fetch %s - pin not verified)\n' "$name" "$url"
VERIFY_FAILED=1
continue
fi
got_fp="$(fingerprint_of "$pem")"
if [ "$got_fp" != "$fingerprint" ]; then
rm -f "$pem"
printf ' %-34s FAIL (certificate at %s has fingerprint %s, expected %s)\n' "$name" "$url" "$got_fp" "$fingerprint"
VERIFY_FAILED=1
continue
fi
got_pin="$(spki_hash_from_pem "$pem")"
if [ "$got_pin" != "$pin" ]; then
rm -f "$pem"
printf ' %-34s FAIL (published certificate yields sha256/%s but sha256/%s is pinned)\n' "$name" "$got_pin" "$pin"
VERIFY_FAILED=1
continue
fi
printf ' %-34s OK (published certificate matches the committed fingerprint and pin)\n' "$name"
done
}
verify_domain() {
# $1 = hostname; remaining args = root names the domain may anchor at.
# Fetches the served chain and verifies it builds to one of the pinned roots.
local host="$1"
shift
local tmpdir
tmpdir="$(mktemp -d)"
echo | openssl s_client -connect "${host}:443" -servername "${host}" -showcerts 2>/dev/null \
| awk -v dir="$tmpdir" 'BEGIN{c=0} /BEGIN CERTIFICATE/{c++} c>0{print > (dir "/cert_" c ".pem")}' || true
if [ ! -s "${tmpdir}/cert_1.pem" ]; then
# Nothing was verified for this host, so the run must not succeed: an
# unreachable endpoint would otherwise hide a pin that no longer matches.
printf ' %-34s UNREACHABLE (could not fetch the served chain - nothing verified)\n' "$host"
VERIFY_FAILED=1
rm -rf "$tmpdir"
return
fi
local roots_bundle="${tmpdir}/roots.pem"
local root retained_anchor
for root in "$@"; do
if [ -n "$(retained_pin "$root")" ]; then
# Usable as an anchor only if verify_retained_pins fetched and verified it.
retained_anchor="${RETAINED_ANCHOR_DIR:-}/${root}.pem"
if [ -n "${RETAINED_ANCHOR_DIR:-}" ] && [ -s "$retained_anchor" ]; then
cat "$retained_anchor" >> "$roots_bundle"
fi
continue
fi
cat "$(root_pem_path "$root")" >> "$roots_bundle"
done
local untrusted="${tmpdir}/untrusted.pem"
cat "${tmpdir}"/cert_*.pem > "$untrusted"
# ISOLATION_FLAGS keep the system trust store out of this: the pinned roots in
# $roots_bundle must be the only anchors, or a chain anchored at any locally
# trusted CA would pass and be reported as anchoring at a pinned root.
if openssl verify -CAfile "$roots_bundle" "${ISOLATION_FLAGS[@]}" -untrusted "$untrusted" "${tmpdir}/cert_1.pem" >/dev/null 2>&1; then
local leaf_subject
leaf_subject="$(openssl x509 -in "${tmpdir}/cert_1.pem" -noout -subject 2>/dev/null | sed 's/^subject=//')"
printf ' %-34s OK (chain anchors at a pinned root) [%s]\n' "$host" "$leaf_subject"
else
printf ' %-34s FAIL (served chain does NOT build to a pinned root - a new CA may be in play)\n' "$host"
VERIFY_FAILED=1
fi
rm -rf "$tmpdir"
}
print_pin_set() {
# Args = root names. Prints an Android <pin-set> block.
echo ' <pin-set>'
local root
for root in "$@"; do
local pem hash cn retained
retained="$(retained_pin "$root")"
if [ -n "$retained" ]; then
printf ' <pin digest="SHA-256">%s</pin> <!-- %s -->\n' "$retained" "$(retained_field "$root" 5)"
continue
fi
pem="$(root_pem_path "$root")"
hash="$(spki_hash_from_pem "$pem")"
cn="$(openssl x509 -in "$pem" -noout -subject 2>/dev/null | sed 's/.*CN *= *//')"
printf ' <pin digest="SHA-256">%s</pin> <!-- %s -->\n' "$hash" "$cn"
done
echo ' </pin-set>'
}
ensure_roots
echo "Root CA certificate pins (generated $(date +%Y-%m-%d); roots fetched and fingerprint-verified per ROOT_MANIFEST):"
echo
print_group "Groups A-D: Cloudflare-fronted expensify.com hosts" "${CLOUDFLARE_ROOTS[@]}"
print_group "Group E: CloudFront CDN" "${CLOUDFRONT_ROOTS[@]}"
echo "Domain -> group:"
for d in "${CLOUDFLARE_DOMAINS[@]}"; do
printf ' %-34s Groups A-D (Cloudflare roots)\n' "$d"
done
for d in "${CLOUDFRONT_DOMAINS[@]}"; do
printf ' %-34s Group E (CloudFront roots)\n' "$d"
done
echo
if [ "$ANDROID" = "1" ]; then
echo "----- network_security_config_enforce.xml <pin-set> blocks -----"
echo "Cloudflare-fronted expensify.com hosts:"
print_pin_set "${CLOUDFLARE_ROOTS[@]}"
echo
echo "CloudFront CDN:"
print_pin_set "${CLOUDFRONT_ROOTS[@]}"
echo
fi
if [ "$VERIFY" = "1" ]; then
VERIFY_FAILED=0
echo "----- live chain verification -----"
openssl_isolation_flags
if [ "${#RETAINED_PINS[@]}" -gt 0 ]; then
verify_retained_pins
fi
for d in "${CLOUDFLARE_DOMAINS[@]}"; do
verify_domain "$d" "${CLOUDFLARE_ROOTS[@]}"
done
for d in "${CLOUDFRONT_DOMAINS[@]}"; do
verify_domain "$d" "${CLOUDFRONT_ROOTS[@]}"
done
rm -rf "${RETAINED_ANCHOR_DIR:-}"
if [ "$VERIFY_FAILED" != "0" ]; then
echo "Verification FAILED: at least one host did not anchor at a pinned root, a retained" >&2
echo "pin could not be checked against its published certificate, or a host was unreachable." >&2
exit 1
fi
fi