Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
43 changes: 42 additions & 1 deletion config/certificatePinning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ Pinning is **disabled in debug builds** on every layer (Android `BuildConfig.DEB
## Sentry reporting

Pin failures are reported from the **native** pinning layer (TrustKit callback on iOS, monitor
interceptors on Android), tagged with:
interceptors on Android), one report per failed validation: HybridApp (Mobile-Expensify) sends a Sentry
**log** (message `Certificate pinning validation failed`) in monitor mode and a captured error in
enforce mode; the standalone NewDot layers here send a captured error in both modes. The attributes
below ride along either way, as log attributes or as tags (long values as extras):
- `certificate_pinning_host` — the hostname that failed validation
- `certificate_pinning_mode` — `monitor` or `enforce`
- `certificate_pinning_channel` — (Android only) the networking channel: `OkHttp`, `HttpURLConnection`, `WebView`, or `cronet`
Expand All @@ -89,6 +92,44 @@ interceptors on Android), tagged with:
event is still worth investigating before flipping to enforce mode: OkHttp and the platform rebuild the
chain the same way, so enforce mode would block that connection.

On Android the OkHttp `SSLPeerUnverifiedException` message already lists the served chain (subject and
SPKI hash per certificate). On iOS, TrustKit only reports *that* no pin matched, so the iOS reporters
(`ios/CertificatePinning.swift` here, `iOS/Expensify/CertificatePinningReporter.m` in Mobile-Expensify)
add what the device actually validated. Every value is typed so the column never flaps between boolean
and number in Sentry — the old numeric `certificate_pinning_evaluation_result` landed as `true` for
TrustKit result 1 and as `2` for result 2 and is gone:
- `certificate_pinning_result` — `no_matching_pin` (the device trusted the chain but no pinned root was
in it) or `invalid_certificate_chain` (the device itself rejected the chain; this fails with or without
pinning). Rarely `invalid_parameters` / `could_not_generate_spki_hash`.
- `certificate_pinning_sec_trust_result` — how the device classified the anchor: `proceed` means a root the
user or an MDM profile explicitly trusts (a TLS-intercepting proxy), `unspecified` a system root with no
user override (a public CA that is not pinned). This is the single most useful field for triage.
- `certificate_pinning_chain_subjects` / `certificate_pinning_chain_fingerprints` — the validated chain,
leaf first, anchor last; subjects joined with ` > `, SHA-256 certificate fingerprints (upper-case hex,
no colons — strip the colons from a `ROOT_MANIFEST` entry in `scripts/generateCertificatePins.sh` to
compare) joined with `,`. `certificate_pinning_chain_length` is the number of certificates, as a string.
- `certificate_pinning_anchor_subject` / `certificate_pinning_anchor_fingerprint` /
`certificate_pinning_anchor_self_signed` — the last certificate in that chain. A fingerprint that is not
in `ROOT_MANIFEST` and a subject naming a proxy vendor or a company is interception; a public root that
is not in `pins.json` is a pin-list gap (look the fingerprint up on crt.sh to get the certificate and
derive its SPKI pin). Certificates themselves are deliberately not sent: the fingerprint identifies the
anchor, and a PEM would embed a customer's proxy CA verbatim in every such report.
- `certificate_pinning_trust_errors` — for `invalid_certificate_chain`, the system's reasons ("expired",
"not trusted", hostname mismatch…).
- `certificate_pinning_trust_decision` — TrustKit's `allow` / `block`, confirming monitor mode did not block.
- `certificate_pinning_http_proxy_configured` / `certificate_pinning_pac_configured` /
`certificate_pinning_vpn_interface_present` / `certificate_pinning_interface` — network context.
Presence only; the proxy host and PAC URL are never sent.
- `certificate_pinning_pin_set_version` — the `_lastGenerated` value of the `pins.json` the client's
hashes were copied from, so a report can be matched to the exact pin set it was evaluated against.
- `certificate_pinning_occurrence` — per-session count of reports with the same host, anchor and result
(as a string). One device can fire several identical reports per second from parallel image loads.
- `certificate_pinning_validation_duration_us` — TrustKit's validation time in microseconds.

Before reading these numbers, check `find_dropped_events` / the Stats page for the `logs` category: when
the log quota is exceeded Sentry drops these reports along with everything else, and a flat chart may
mean "nothing landed" rather than "nothing failed".

Reporting requires early native Sentry initialization via `SentryNativeSDKManager` in
`AppDelegate.swift` / `MainApplication.kt` (standalone NewDot) or
`ExpensifyAppDelegate.m` / `Expensify.java` (HybridApp), before certificate pinning. JS
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,7 @@
"useblacksmith",
"usernotifications",
"utilise",
"utun",
"vcfanzfav",
"vcfcitibank",
"vcfmock",
Expand Down
257 changes: 251 additions & 6 deletions ios/CertificatePinning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
// Regenerate via scripts/generateCertificatePins.sh.
//

import CFNetwork
import CommonCrypto
import Foundation
import Network
import Security
import Sentry
import TrustKit

Expand All @@ -26,6 +30,11 @@ enum CertificatePinning {
/// Keep in sync with `enforcePinning` in config/certificatePinning/pins.json and CertificatePinning.kt.
private static let enforcePinning = false

/// Identifies the pin list a client shipped with, independent of the app version: the `_lastGenerated`
/// value of config/certificatePinning/pins.json these hashes were copied from. Update it together with
/// the hashes. Reported as `certificate_pinning_pin_set_version`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Identifies the pin list a client shipped with, independent of the app version: the `_lastGenerated`
/// value of config/certificatePinning/pins.json these hashes were copied from. Update it together with
/// the hashes. Reported as `certificate_pinning_pin_set_version`.
/// Identifies the shipped pin set independently of the app version using `_lastGenerated`
/// from config/certificatePinning/pins.json. Update it with the hashes.
/// Reported as `certificate_pinning_pin_set_version`.

private static let pinSetVersion = "2026-09-14"

private static let certificatePinningHostTag = "certificate_pinning_host"
private static let certificatePinningModeTag = "certificate_pinning_mode"

Expand Down Expand Up @@ -103,26 +112,262 @@ enum CertificatePinning {

TrustKit.initSharedInstance(withConfiguration: trustKitConfig)

// Reporting rebuilds the validated chain from TrustKit's PEM array and re-runs a trust
// evaluation to classify the anchor, so it runs on its own serial queue rather than
// TrustKit's default (main).
TrustKit.sharedInstance().pinningValidatorCallbackQueue = reportingQueue
TrustKit.sharedInstance().pinningValidatorCallback = { result, hostname, _ in
guard result.evaluationResult != .success else {
return
}
reportPinningFailure(hostname: hostname, evaluationResult: result.evaluationResult)
reportPinningFailure(result: result, hostname: hostname)
}
#endif
}

private static func reportPinningFailure(hostname: String, evaluationResult: TSKTrustEvaluationResult) {
// MARK: - Reporting
//
// TrustKit only says *that* a pin did not match; the attributes below record *what* iOS validated
// (the full chain, the trust anchor, how the device trusts that anchor, and the network context) so
// a mismatch in Sentry can be classified as a CA we do not pin versus a TLS-intercepting proxy
// without a device in hand. Keep this set in sync with Mobile-Expensify's
// iOS/Expensify/CertificatePinningReporter.m and the table in config/certificatePinning/README.md.
//
// Sentry's attribute converter matches Bool before Int, so any number holding 0 or 1 lands as a
// boolean and the column type flaps between releases. Enum values are therefore strings, counts
// that can be 0 or 1 are strings, real flags are Bool, and the validation duration is reported in
// microseconds (never 0 or 1 in practice).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// MARK: - Reporting
//
// TrustKit only says *that* a pin did not match; the attributes below record *what* iOS validated
// (the full chain, the trust anchor, how the device trusts that anchor, and the network context) so
// a mismatch in Sentry can be classified as a CA we do not pin versus a TLS-intercepting proxy
// without a device in hand. Keep this set in sync with Mobile-Expensify's
// iOS/Expensify/CertificatePinningReporter.m and the table in config/certificatePinning/README.md.
//
// Sentry's attribute converter matches Bool before Int, so any number holding 0 or 1 lands as a
// boolean and the column type flaps between releases. Enum values are therefore strings, counts
// that can be 0 or 1 are strings, real flags are Bool, and the validation duration is reported in
// microseconds (never 0 or 1 in practice).
// MARK: - Reporting
//
// Record TrustKit's certificate chain, a fresh system trust result, and network settings
// to help investigate pin failures in Sentry. These fields help distinguish an unpinned
// CA from a proxy, but do not establish the cause on their own.
// Keep these fields in sync with Mobile-Expensify's iOS/Expensify/CertificatePinningReporter.m
// and the field descriptions in config/certificatePinning/README.md.
//
// In attributes, enum names and counts are strings, flags are Bool, and validation
// duration is an integer number of microseconds. Send fields listed in extraKeys as
// extras and convert all other values to strings for tags.


/// Serial queue TrustKit invokes the callback on. Everything below that mutates static state runs here.
private static let reportingQueue = DispatchQueue(label: "com.expensify.certificatePinning.reporting", qos: .utility)

private static var occurrenceCounts: [String: Int] = [:]

/// Tags are capped at 200 characters; these ride along as extras instead.
private static let extraKeys: Set<String> = [
"certificate_pinning_chain_subjects",
"certificate_pinning_chain_fingerprints",
]
private static var currentInterfaceType = "unknown"
private static let pathMonitor: NWPathMonitor = {
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.usesInterfaceType(.wifi) {
currentInterfaceType = "wifi"
} else if path.usesInterfaceType(.cellular) {
currentInterfaceType = "cellular"
} else if path.usesInterfaceType(.wiredEthernet) {
currentInterfaceType = "wired"
} else if path.usesInterfaceType(.other) {
currentInterfaceType = "other" // VPN or tunnel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
currentInterfaceType = "other" // VPN or tunnel
currentInterfaceType = "other" // Virtual or otherwise unknown interface

} else {
currentInterfaceType = "unknown"
}
}
monitor.start(queue: reportingQueue)
return monitor
}()

private static func reportPinningFailure(result: TSKPinningValidatorResult, hostname: String) {
let chain = certificates(fromPEMChain: result.certificateChain)
let subjects = chain.map(subjectSummary)
let fingerprints = chain.map(fingerprint)
let anchor = chain.last
let anchorFingerprint = fingerprints.last ?? ""
let (trustResult, trustErrors) = evaluate(chain, hostname: hostname)

let resultName = name(of: result.evaluationResult)
let dedupKey = "\(hostname)|\(anchorFingerprint)|\(resultName)"
let occurrence = (occurrenceCounts[dedupKey] ?? 0) + 1
occurrenceCounts[dedupKey] = occurrence

var attributes: [String: Any] = [
// Existing attributes, unchanged

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Existing attributes, unchanged

certificatePinningHostTag: hostname,
certificatePinningModeTag: enforcePinning ? "enforce" : "monitor",
// Typed replacement for the old numeric `certificate_pinning_evaluation_result`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Typed replacement for the old numeric `certificate_pinning_evaluation_result`
// TrustKit's evaluation result and connection decision, plus the system trust result

"certificate_pinning_result": resultName,
"certificate_pinning_trust_decision": name(of: result.finalTrustDecision),
"certificate_pinning_sec_trust_result": name(of: trustResult),
// What iOS validated, leaf first
"certificate_pinning_chain_length": String(chain.count),
"certificate_pinning_chain_subjects": subjects.joined(separator: " > "),
"certificate_pinning_chain_fingerprints": fingerprints.joined(separator: ","),
"certificate_pinning_anchor_subject": subjects.last ?? "",
"certificate_pinning_anchor_fingerprint": anchorFingerprint,
"certificate_pinning_anchor_self_signed": anchor.map(isSelfSigned) ?? false,
// Context

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Context

"certificate_pinning_pin_set_version": pinSetVersion,
"certificate_pinning_validation_duration_us": Int((result.validationDuration * 1_000_000).rounded()),
"certificate_pinning_interface": interfaceType(),
"certificate_pinning_occurrence": String(occurrence),
]
attributes.merge(networkContext()) { current, _ in current }

if !trustErrors.isEmpty {
attributes["certificate_pinning_trust_errors"] = trustErrors
}

// One captured error per failed validation in both modes, exactly as before; the detail above
// rides along as tags and extras.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// One captured error per failed validation in both modes, exactly as before; the detail above
// rides along as tags and extras.
// Capture one error for each failed validation in either mode, with these attributes
// attached as tags and extras.

let error = NSError(
domain: "CertificatePinning",
code: evaluationResult.rawValue,
code: result.evaluationResult.rawValue,
userInfo: [NSLocalizedDescriptionKey: "Certificate pinning validation failed for \(hostname)"]
)

SentrySDK.capture(error: error) { scope in
scope.setTag(value: hostname, key: certificatePinningHostTag)
scope.setTag(value: enforcePinning ? "enforce" : "monitor", key: certificatePinningModeTag)
for (key, value) in attributes {
if extraKeys.contains(key) {
scope.setExtra(value: value, key: key)
} else {
scope.setTag(value: String(describing: value), key: key)
}
}
scope.setFingerprint(["certificate-pinning", hostname])
}
}

// MARK: Enum -> string (stable attribute types)

private static func name(of result: TSKTrustEvaluationResult) -> String {
switch result {
case .success: return "success"
case .failedNoMatchingPin: return "no_matching_pin"
case .failedInvalidCertificateChain: return "invalid_certificate_chain"
case .errorInvalidParameters: return "invalid_parameters"
case .errorCouldNotGenerateSpkiHash: return "could_not_generate_spki_hash"
default: return "unknown_\(result.rawValue)"
}
}

private static func name(of decision: TSKTrustDecision) -> String {
switch decision {
case .shouldAllowConnection: return "allow"
case .shouldBlockConnection: return "block"
case .domainNotPinned: return "domain_not_pinned"
default: return "unknown_\(decision.rawValue)"
}
}

/// `proceed` means the anchor is explicitly trusted by the user or an MDM profile ("Enable Full Trust for
/// Root Certificates"); `unspecified` means a system root with no user override. This is the cheapest
/// single signal separating a TLS-intercepting proxy from a public CA we do not pin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// `proceed` means the anchor is explicitly trusted by the user or an MDM profile ("Enable Full Trust for
/// Root Certificates"); `unspecified` means a system root with no user override. This is the cheapest
/// single signal separating a TLS-intercepting proxy from a public CA we do not pin.
/// `proceed` indicates a successful evaluation based on a user trust decision;
/// `unspecified` indicates success without an explicit user decision.
/// Neither result alone identifies a proxy or proves the anchor is a public CA.

private static func name(of result: SecTrustResultType) -> String {
switch result {
case .invalid: return "invalid"
case .proceed: return "proceed"
case .deny: return "deny"
case .unspecified: return "unspecified"
case .recoverableTrustFailure: return "recoverable_trust_failure"
case .fatalTrustFailure: return "fatal_trust_failure"
case .otherError: return "other_error"
default: return "unknown_\(result.rawValue)"
}
}

// MARK: Certificate helpers

/// TrustKit builds `certificateChain` from the evaluated SecTrust, so it is leaf-first and includes the
/// anchor whenever iOS found one.
private static func certificates(fromPEMChain chain: [Any]) -> [SecCertificate] {
chain.compactMap { entry in
guard let pem = entry as? String else {
return nil
}
let base64 = pem
.replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "")
.replacingOccurrences(of: "-----END CERTIFICATE-----", with: "")
guard let der = Data(base64Encoded: base64, options: .ignoreUnknownCharacters), !der.isEmpty else {
return nil
}
return SecCertificateCreateWithData(nil, der as CFData)
}
}

/// SHA-256 of the DER certificate, upper-case hex, no colons. Strip the colons from a ROOT_MANIFEST entry
/// in scripts/generateCertificatePins.sh and it is directly comparable.
private static func fingerprint(_ certificate: SecCertificate) -> String {
let der = SecCertificateCopyData(certificate) as Data
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
der.withUnsafeBytes { buffer in
_ = CC_SHA256(buffer.baseAddress, CC_LONG(der.count), &digest)
}
return digest.map { String(format: "%02X", $0) }.joined()
}

private static func subjectSummary(_ certificate: SecCertificate) -> String {
let summary = SecCertificateCopySubjectSummary(certificate) as String?
return summary.flatMap { $0.isEmpty ? nil : $0 } ?? "<no subject>"
}

private static func isSelfSigned(_ certificate: SecCertificate) -> Bool {
guard let subject = SecCertificateCopyNormalizedSubjectSequence(certificate) as Data?,
let issuer = SecCertificateCopyNormalizedIssuerSequence(certificate) as Data? else {
return false
}
return subject == issuer
}

/// Re-runs the system trust evaluation on the chain TrustKit validated. TrustKit 3.x does not expose the
/// original SecTrust, so this is the only way to learn how the device classified the anchor
/// (`SecTrustResultType`) and, for an untrusted chain, why ("expired", "not trusted", hostname mismatch...).
/// Revocation checking is best-effort and may touch the network, which is why this never runs on the
/// main queue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Re-runs the system trust evaluation on the chain TrustKit validated. TrustKit 3.x does not expose the
/// original SecTrust, so this is the only way to learn how the device classified the anchor
/// (`SecTrustResultType`) and, for an untrusted chain, why ("expired", "not trusted", hostname mismatch...).
/// Revocation checking is best-effort and may touch the network, which is why this never runs on the
/// main queue.
/// Evaluates TrustKit's certificate chain with an SSL policy for hostname.
/// Returns the new evaluation's SecTrustResultType and any error messages.
/// TrustKit does not expose its original SecTrust in the callback, so this evaluation
/// may differ from the original. It runs on reportingQueue because trust evaluation
/// can block while fetching certificates or checking revocation.

private static func evaluate(_ certificates: [SecCertificate], hostname: String) -> (SecTrustResultType, String) {
guard !certificates.isEmpty else {
return (.invalid, "")
}
let policy = SecPolicyCreateSSL(true, hostname as CFString)
var createdTrust: SecTrust?
guard SecTrustCreateWithCertificates(certificates as CFArray, policy, &createdTrust) == errSecSuccess,
let trust = createdTrust else {
return (.invalid, "")
}

var errors: [String] = []
var evaluationError: CFError?
_ = SecTrustEvaluateWithError(trust, &evaluationError)
if let evaluationError {
let description = (evaluationError as Error).localizedDescription
if !description.isEmpty {
errors.append(description)
}
}
var trustResult = SecTrustResultType.invalid
SecTrustGetTrustResult(trust, &trustResult)

if let properties = SecTrustCopyProperties(trust) as? [[String: Any]] {
for property in properties {
if let message = property[kSecPropertyTypeError as String] as? String, !message.isEmpty, !errors.contains(message) {
errors.append(message)
}
}
}
return (trustResult, errors.joined(separator: " | "))
}

// MARK: Network context

/// Presence only: never the proxy host or PAC URL, which would identify the customer's network. Per-interface
/// settings under `__SCOPED__` reveal tunnels (utun/ipsec/ppp/tap), which is where per-app VPN and TLS
/// inspection usually live.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Presence only: never the proxy host or PAC URL, which would identify the customer's network. Per-interface
/// settings under `__SCOPED__` reveal tunnels (utun/ipsec/ppp/tap), which is where per-app VPN and TLS
/// inspection usually live.
/// Reports whether HTTP proxy and PAC settings are enabled, and whether `__SCOPED__`
/// contains an interface whose name starts with utun, tun, tap, ppp, or ipsec.
/// These names suggest a tunnel but do not prove this connection uses a VPN.
/// The proxy host and PAC URL are omitted to protect network details.

private static func networkContext() -> [String: Any] {
let proxy = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] ?? [:]
let httpProxy = (proxy[kCFNetworkProxiesHTTPEnable as String] as? Bool) ?? false
let pac = (proxy[kCFNetworkProxiesProxyAutoConfigEnable as String] as? Bool) ?? false
let scoped = proxy["__SCOPED__"] as? [String: Any] ?? [:]
let tunnelPrefixes = ["utun", "tun", "tap", "ppp", "ipsec"]
let tunnel = scoped.keys.contains { interface in tunnelPrefixes.contains { interface.hasPrefix($0) } }
return [
"certificate_pinning_http_proxy_configured": httpProxy,
"certificate_pinning_pac_configured": pac,
"certificate_pinning_vpn_interface_present": tunnel,
]
}

/// Current interface type, cached from a path monitor started on first use so the lookup stays synchronous.
private static func interfaceType() -> String {
_ = pathMonitor
return currentInterfaceType
}
}
Loading
Loading