@cap-kit/tls-fingerprint 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapKitTlsFingerprint.podspec +17 -0
- package/LICENSE +21 -0
- package/Package.swift +25 -0
- package/README.md +427 -0
- package/android/build.gradle +103 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/io/capkit/settings/TLSFingerprintImpl.kt +333 -0
- package/android/src/main/java/io/capkit/settings/TLSFingerprintPlugin.kt +342 -0
- package/android/src/main/java/io/capkit/settings/config/TLSFingerprintConfig.kt +102 -0
- package/android/src/main/java/io/capkit/settings/error/TLSFingerprintError.kt +114 -0
- package/android/src/main/java/io/capkit/settings/error/TLSFingerprintErrorMessages.kt +27 -0
- package/android/src/main/java/io/capkit/settings/logger/TLSFingerprintLogger.kt +85 -0
- package/android/src/main/java/io/capkit/settings/model/TLSFingerprintResultModel.kt +32 -0
- package/android/src/main/java/io/capkit/settings/utils/TLSFingerprintUtils.kt +91 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/cli/fingerprint.js +163 -0
- package/dist/cli/fingerprint.js.map +1 -0
- package/dist/docs.json +386 -0
- package/dist/esm/cli/fingerprint.d.ts +1 -0
- package/dist/esm/cli/fingerprint.js +161 -0
- package/dist/esm/cli/fingerprint.js.map +1 -0
- package/dist/esm/definitions.d.ts +244 -0
- package/dist/esm/definitions.js +42 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +13 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/version.d.ts +1 -0
- package/dist/esm/version.js +3 -0
- package/dist/esm/version.js.map +1 -0
- package/dist/esm/web.d.ts +33 -0
- package/dist/esm/web.js +47 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs +107 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.js +110 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/TLSFingerprintPlugin/TLSFingerprintDelegate.swift +365 -0
- package/ios/Sources/TLSFingerprintPlugin/TLSFingerprintImpl.swift +275 -0
- package/ios/Sources/TLSFingerprintPlugin/TLSFingerprintPlugin.swift +219 -0
- package/ios/Sources/TLSFingerprintPlugin/Version.swift +16 -0
- package/ios/Sources/TLSFingerprintPlugin/config/TLSFingerprintConfig.swift +114 -0
- package/ios/Sources/TLSFingerprintPlugin/error/TLSFingerprintError.swift +107 -0
- package/ios/Sources/TLSFingerprintPlugin/error/TLSFingerprintErrorMessages.swift +30 -0
- package/ios/Sources/TLSFingerprintPlugin/logger/TLSFingerprintLogger.swift +69 -0
- package/ios/Sources/TLSFingerprintPlugin/model/TLSFingerprintResult.swift +76 -0
- package/ios/Sources/TLSFingerprintPlugin/utils/TLSFingerprintUtils.swift +79 -0
- package/ios/Tests/TLSFingerprintPluginTests/TLSFingerprintPluginTests.swift +15 -0
- package/package.json +131 -0
- package/scripts/chmod.mjs +34 -0
- package/scripts/sync-version.mjs +68 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.js","sources":["esm/definitions.js","esm/index.js","esm/version.js","esm/web.js"],"sourcesContent":["/// <reference types=\"@capacitor/cli\" />\n// -----------------------------------------------------------------------------\n// Enums\n// -----------------------------------------------------------------------------\n/**\n * Standardized error codes for programmatic handling of TLS fingerprint failures.\n *\n * Errors are delivered via Promise rejection as `CapacitorException`\n * with one of the following codes.\n *\n * @since 8.0.0\n */\nexport var TLSFingerprintErrorCode;\n(function (TLSFingerprintErrorCode) {\n /** Required data is missing or the feature is not available. */\n TLSFingerprintErrorCode[\"UNAVAILABLE\"] = \"UNAVAILABLE\";\n /** The user cancelled an interactive flow. */\n TLSFingerprintErrorCode[\"CANCELLED\"] = \"CANCELLED\";\n /** The user denied a required permission or the feature is disabled. */\n TLSFingerprintErrorCode[\"PERMISSION_DENIED\"] = \"PERMISSION_DENIED\";\n /** The TLS fingerprint operation failed due to a runtime or initialization error. */\n TLSFingerprintErrorCode[\"INIT_FAILED\"] = \"INIT_FAILED\";\n /** The input provided to the plugin method is invalid, missing, or malformed. */\n TLSFingerprintErrorCode[\"INVALID_INPUT\"] = \"INVALID_INPUT\";\n /** Invalid or unsupported input was provided. */\n TLSFingerprintErrorCode[\"UNKNOWN_TYPE\"] = \"UNKNOWN_TYPE\";\n /** The requested resource does not exist. */\n TLSFingerprintErrorCode[\"NOT_FOUND\"] = \"NOT_FOUND\";\n /** The operation conflicts with the current state. */\n TLSFingerprintErrorCode[\"CONFLICT\"] = \"CONFLICT\";\n /** The operation did not complete within the expected time. */\n TLSFingerprintErrorCode[\"TIMEOUT\"] = \"TIMEOUT\";\n /** The server certificate fingerprint did not match any expected fingerprint. */\n TLSFingerprintErrorCode[\"PINNING_FAILED\"] = \"PINNING_FAILED\";\n /** The request host matched an excluded domain. */\n TLSFingerprintErrorCode[\"EXCLUDED_DOMAIN\"] = \"EXCLUDED_DOMAIN\";\n /** Network connectivity or TLS handshake error. */\n TLSFingerprintErrorCode[\"NETWORK_ERROR\"] = \"NETWORK_ERROR\";\n /** SSL/TLS specific error (certificate expired, handshake failure, etc.). */\n TLSFingerprintErrorCode[\"SSL_ERROR\"] = \"SSL_ERROR\";\n})(TLSFingerprintErrorCode || (TLSFingerprintErrorCode = {}));\n//# sourceMappingURL=definitions.js.map","import { registerPlugin } from '@capacitor/core';\n/**\n * The TLSFingerprint plugin instance.\n * It lazily loads the Web implementation when running in a browser.\n */\nconst TLSFingerprint = registerPlugin('TLSFingerprint', {\n web: () => import('./web').then((m) => new m.TLSFingerprintWeb()),\n});\nexport * from './definitions';\nexport { TLSFingerprint };\n//# sourceMappingURL=index.js.map","// This file is automatically generated. Do not modify manually.\nexport const PLUGIN_VERSION = '8.0.0';\n//# sourceMappingURL=version.js.map","import { WebPlugin } from '@capacitor/core';\nimport { PLUGIN_VERSION } from './version';\n/**\n * Web implementation of the TLSFingerprint plugin.\n *\n * This implementation exists to satisfy Capacitor's multi-platform contract.\n * TLS fingerprinting is not supported in web browsers.\n *\n * All methods follow the standard Promise rejection model:\n * - unsupported features reject with UNAVAILABLE\n */\nexport class TLSFingerprintWeb extends WebPlugin {\n constructor() {\n super();\n }\n /**\n * Checks a single SSL certificate against the expected fingerprint.\n * @param options - Unused on web platform.\n * @throws CapacitorException indicating unimplemented functionality.\n */\n async checkCertificate(options) {\n void options;\n throw this.unimplemented();\n }\n /**\n * Checks multiple SSL certificates against their expected fingerprints.\n * @param options - Unused on web platform.\n * @throws CapacitorException indicating unimplemented functionality.\n */\n async checkCertificates(options) {\n void options;\n throw this.unimplemented();\n }\n // -----------------------------------------------------------------------------\n // Plugin Info\n // -----------------------------------------------------------------------------\n /**\n * Returns the plugin version.\n *\n * On the Web, this value represents the JavaScript package version\n * rather than a native implementation.\n */\n async getPluginVersion() {\n return { version: PLUGIN_VERSION };\n }\n}\n//# sourceMappingURL=web.js.map"],"names":["TLSFingerprintErrorCode","registerPlugin","WebPlugin"],"mappings":";;;IAAA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;AACWA;IACX,CAAC,UAAU,uBAAuB,EAAE;IACpC;IACA,IAAI,uBAAuB,CAAC,aAAa,CAAC,GAAG,aAAa;IAC1D;IACA,IAAI,uBAAuB,CAAC,WAAW,CAAC,GAAG,WAAW;IACtD;IACA,IAAI,uBAAuB,CAAC,mBAAmB,CAAC,GAAG,mBAAmB;IACtE;IACA,IAAI,uBAAuB,CAAC,aAAa,CAAC,GAAG,aAAa;IAC1D;IACA,IAAI,uBAAuB,CAAC,eAAe,CAAC,GAAG,eAAe;IAC9D;IACA,IAAI,uBAAuB,CAAC,cAAc,CAAC,GAAG,cAAc;IAC5D;IACA,IAAI,uBAAuB,CAAC,WAAW,CAAC,GAAG,WAAW;IACtD;IACA,IAAI,uBAAuB,CAAC,UAAU,CAAC,GAAG,UAAU;IACpD;IACA,IAAI,uBAAuB,CAAC,SAAS,CAAC,GAAG,SAAS;IAClD;IACA,IAAI,uBAAuB,CAAC,gBAAgB,CAAC,GAAG,gBAAgB;IAChE;IACA,IAAI,uBAAuB,CAAC,iBAAiB,CAAC,GAAG,iBAAiB;IAClE;IACA,IAAI,uBAAuB,CAAC,eAAe,CAAC,GAAG,eAAe;IAC9D;IACA,IAAI,uBAAuB,CAAC,WAAW,CAAC,GAAG,WAAW;IACtD,CAAC,EAAEA,+BAAuB,KAAKA,+BAAuB,GAAG,EAAE,CAAC,CAAC;;ICvC7D;IACA;IACA;IACA;AACK,UAAC,cAAc,GAAGC,mBAAc,CAAC,gBAAgB,EAAE;IACxD,IAAI,GAAG,EAAE,MAAM,mDAAe,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,iBAAiB,EAAE,CAAC;IACrE,CAAC;;ICPD;IACO,MAAM,cAAc,GAAG,OAAO;;ICCrC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACO,MAAM,iBAAiB,SAASC,cAAS,CAAC;IACjD,IAAI,WAAW,GAAG;IAClB,QAAQ,KAAK,EAAE;IACf,IAAI;IACJ;IACA;IACA;IACA;IACA;IACA,IAAI,MAAM,gBAAgB,CAAC,OAAO,EAAE;IAEpC,QAAQ,MAAM,IAAI,CAAC,aAAa,EAAE;IAClC,IAAI;IACJ;IACA;IACA;IACA;IACA;IACA,IAAI,MAAM,iBAAiB,CAAC,OAAO,EAAE;IAErC,QAAQ,MAAM,IAAI,CAAC,aAAa,EAAE;IAClC,IAAI;IACJ;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,IAAI,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE;IAC1C,IAAI;IACJ;;;;;;;;;;;;;;;"}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
URLSession delegate responsible for performing
|
|
6
|
+
TLS fingerprint validation during the TLS handshake phase.
|
|
7
|
+
|
|
8
|
+
SECURITY MODEL:
|
|
9
|
+
This plugin validates fingerprint equality only and does not
|
|
10
|
+
enforce system trust evaluation.
|
|
11
|
+
|
|
12
|
+
Responsibilities:
|
|
13
|
+
- Intercept TLS authentication challenges
|
|
14
|
+
- Evaluate the server trust object
|
|
15
|
+
- Apply TLS fingerprint strategies
|
|
16
|
+
- Return structured validation results
|
|
17
|
+
|
|
18
|
+
Supported strategies (evaluation order):
|
|
19
|
+
1. Excluded domain bypass
|
|
20
|
+
2. Fingerprint-based validation (leaf comparison)
|
|
21
|
+
|
|
22
|
+
Forbidden:
|
|
23
|
+
- Referencing Capacitor APIs
|
|
24
|
+
- Throwing JavaScript-facing errors
|
|
25
|
+
- Performing configuration lookups
|
|
26
|
+
- Managing bridge lifecycle
|
|
27
|
+
|
|
28
|
+
Architectural notes:
|
|
29
|
+
- This class operates purely at the networking layer.
|
|
30
|
+
- It is stateless beyond the injected configuration.
|
|
31
|
+
- It must always call the completion handler exactly once.
|
|
32
|
+
*/
|
|
33
|
+
final class TLSFingerprintDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
|
|
34
|
+
|
|
35
|
+
// MARK: - Properties
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
The URLSession that owns this delegate and executes the request.
|
|
39
|
+
Set via setSession() after delegate initialization.
|
|
40
|
+
*/
|
|
41
|
+
private var session: URLSession?
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
Tracks whether the completion handler has already been called.
|
|
45
|
+
Used to prevent double-calling on timeout or other terminal errors.
|
|
46
|
+
*/
|
|
47
|
+
private var hasCompleted = false
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
Lock for thread-safe completion tracking.
|
|
51
|
+
*/
|
|
52
|
+
private let completionLock = NSLock()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
Normalized expected fingerprints.
|
|
56
|
+
|
|
57
|
+
All fingerprints are normalized at initialization time
|
|
58
|
+
to guarantee deterministic comparison.
|
|
59
|
+
*/
|
|
60
|
+
private let expectedFingerprints: [String]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
Lowercased list of excluded domains.
|
|
64
|
+
|
|
65
|
+
Matching rules:
|
|
66
|
+
- Exact hostname match
|
|
67
|
+
- Subdomain match
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
- excluded: example.com
|
|
71
|
+
- matches: example.com, api.example.com
|
|
72
|
+
*/
|
|
73
|
+
private let excludedDomains: [String]
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
Completion handler returning the raw result
|
|
77
|
+
of the SSL pinning operation.
|
|
78
|
+
|
|
79
|
+
The delegate must ALWAYS invoke this exactly once.
|
|
80
|
+
*/
|
|
81
|
+
private let completion: (TLSFingerprintResult) -> Void
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
Controls verbose native logging.
|
|
85
|
+
|
|
86
|
+
Logging decisions are made outside this class.
|
|
87
|
+
*/
|
|
88
|
+
private let verboseLogging: Bool
|
|
89
|
+
|
|
90
|
+
// MARK: - Initialization
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
Initializes the TLSFingerprintDelegate.
|
|
94
|
+
|
|
95
|
+
- Parameters:
|
|
96
|
+
- expectedFingerprints: Allowed SHA-256 fingerprints.
|
|
97
|
+
- excludedDomains: Hostnames that bypass SSL pinning.
|
|
98
|
+
- completion: Completion handler returning structured result data.
|
|
99
|
+
- verboseLogging: Enables verbose native logging.
|
|
100
|
+
|
|
101
|
+
Design notes:
|
|
102
|
+
- All inputs are normalized and stored.
|
|
103
|
+
- No external configuration access is performed.
|
|
104
|
+
- Session must be set via setSession() before use.
|
|
105
|
+
*/
|
|
106
|
+
init(
|
|
107
|
+
expectedFingerprints: [String],
|
|
108
|
+
excludedDomains: [String] = [],
|
|
109
|
+
completion: @escaping (TLSFingerprintResult) -> Void,
|
|
110
|
+
verboseLogging: Bool
|
|
111
|
+
) {
|
|
112
|
+
self.expectedFingerprints =
|
|
113
|
+
expectedFingerprints.map {
|
|
114
|
+
TLSFingerprintUtils.normalizeFingerprint($0)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
self.excludedDomains =
|
|
118
|
+
excludedDomains.map { $0.lowercased() }
|
|
119
|
+
|
|
120
|
+
self.verboseLogging = verboseLogging
|
|
121
|
+
|
|
122
|
+
self.completion = completion
|
|
123
|
+
super.init()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
Sets the URLSession that owns this delegate.
|
|
128
|
+
|
|
129
|
+
Must be called before the session executes any task.
|
|
130
|
+
The session will be invalidated when completion is called.
|
|
131
|
+
*/
|
|
132
|
+
func setSession(_ session: URLSession) {
|
|
133
|
+
self.session = session
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MARK: - Completion Handler
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
Attempts to mark completion atomically.
|
|
140
|
+
Returns true if this call successfully marked completion (no prior completion).
|
|
141
|
+
Used by timeout handlers to prevent race conditions.
|
|
142
|
+
*/
|
|
143
|
+
func trySetCompleted() -> Bool {
|
|
144
|
+
completionLock.lock()
|
|
145
|
+
defer { completionLock.unlock() }
|
|
146
|
+
|
|
147
|
+
if hasCompleted {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
hasCompleted = true
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
Helper method to complete the request and invalidate the session.
|
|
156
|
+
Ensures session is invalidated exactly once.
|
|
157
|
+
*/
|
|
158
|
+
private func completeWithResult(_ result: TLSFingerprintResult) {
|
|
159
|
+
completionLock.lock()
|
|
160
|
+
guard !hasCompleted else {
|
|
161
|
+
completionLock.unlock()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
hasCompleted = true
|
|
165
|
+
completionLock.unlock()
|
|
166
|
+
|
|
167
|
+
session?.invalidateAndCancel()
|
|
168
|
+
completion(result)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// MARK: - URLSessionDelegate
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
Intercepts the TLS authentication challenge
|
|
175
|
+
and applies the configured TLS fingerprint strategy.
|
|
176
|
+
|
|
177
|
+
IMPORTANT SECURITY NOTES:
|
|
178
|
+
|
|
179
|
+
- The delegate controls whether the connection proceeds.
|
|
180
|
+
- If validation fails, the challenge is cancelled.
|
|
181
|
+
- The completion handler MUST always be invoked.
|
|
182
|
+
- Trust evaluation behavior depends on selected mode.
|
|
183
|
+
|
|
184
|
+
This plugin validates fingerprint equality only and does not
|
|
185
|
+
enforce system trust evaluation.
|
|
186
|
+
|
|
187
|
+
Mode behavior:
|
|
188
|
+
|
|
189
|
+
1. Excluded Mode
|
|
190
|
+
- Bypasses fingerprint validation entirely.
|
|
191
|
+
- Uses permissive trust handling.
|
|
192
|
+
|
|
193
|
+
2. Fingerprint Mode
|
|
194
|
+
- Does NOT evaluate system trust.
|
|
195
|
+
- Compares only the leaf certificate fingerprint.
|
|
196
|
+
*/
|
|
197
|
+
func urlSession(
|
|
198
|
+
_ session: URLSession,
|
|
199
|
+
didReceive challenge: URLAuthenticationChallenge,
|
|
200
|
+
completionHandler: @escaping (
|
|
201
|
+
URLSession.AuthChallengeDisposition,
|
|
202
|
+
URLCredential?
|
|
203
|
+
) -> Void
|
|
204
|
+
) {
|
|
205
|
+
|
|
206
|
+
guard let trust = challenge.protectionSpace.serverTrust else {
|
|
207
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
208
|
+
completeWithResult(TLSFingerprintResult(
|
|
209
|
+
fingerprintMatched: false,
|
|
210
|
+
error: TLSFingerprintErrorMessages.internalError,
|
|
211
|
+
errorCode: "INIT_FAILED"
|
|
212
|
+
))
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let host = challenge.protectionSpace.host.lowercased()
|
|
217
|
+
|
|
218
|
+
// =========================================================
|
|
219
|
+
// EXCLUDED DOMAIN MODE (bypass fingerprint validation)
|
|
220
|
+
// =========================================================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
If the current host matches an excluded domain,
|
|
224
|
+
fingerprint validation is bypassed.
|
|
225
|
+
The connection uses a permissive trust manager.
|
|
226
|
+
System trust chain is NOT explicitly evaluated.
|
|
227
|
+
We still compute and return the actual fingerprint for parity.
|
|
228
|
+
*/
|
|
229
|
+
if excludedDomains.contains(where: {
|
|
230
|
+
host == $0 || host.hasSuffix("." + $0)
|
|
231
|
+
}) {
|
|
232
|
+
|
|
233
|
+
TLSFingerprintLogger.debug("TLSFingerprint excluded domain:", host)
|
|
234
|
+
|
|
235
|
+
guard let certificate =
|
|
236
|
+
TLSFingerprintUtils.leafCertificate(from: trust)
|
|
237
|
+
else {
|
|
238
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
239
|
+
completeWithResult(TLSFingerprintResult(
|
|
240
|
+
fingerprintMatched: false,
|
|
241
|
+
error: TLSFingerprintErrorMessages.internalError,
|
|
242
|
+
errorCode: "INIT_FAILED"
|
|
243
|
+
))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let actualFingerprint =
|
|
248
|
+
TLSFingerprintUtils.normalizeFingerprint(
|
|
249
|
+
TLSFingerprintUtils.sha256Fingerprint(from: certificate)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
completionHandler(.useCredential, URLCredential(trust: trust))
|
|
253
|
+
|
|
254
|
+
completeWithResult(TLSFingerprintResult(
|
|
255
|
+
actualFingerprint: actualFingerprint,
|
|
256
|
+
fingerprintMatched: true,
|
|
257
|
+
excludedDomain: true,
|
|
258
|
+
mode: "excluded",
|
|
259
|
+
error: TLSFingerprintErrorMessages.excludedDomain,
|
|
260
|
+
errorCode: "EXCLUDED_DOMAIN"
|
|
261
|
+
))
|
|
262
|
+
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
guard let certificate =
|
|
267
|
+
TLSFingerprintUtils.leafCertificate(from: trust)
|
|
268
|
+
else {
|
|
269
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
270
|
+
completeWithResult(TLSFingerprintResult(
|
|
271
|
+
fingerprintMatched: false,
|
|
272
|
+
error: TLSFingerprintErrorMessages.internalError,
|
|
273
|
+
errorCode: "INIT_FAILED"
|
|
274
|
+
))
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let actualFingerprint =
|
|
279
|
+
TLSFingerprintUtils.normalizeFingerprint(
|
|
280
|
+
TLSFingerprintUtils.sha256Fingerprint(from: certificate)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
let matchedFingerprint =
|
|
284
|
+
expectedFingerprints.first {
|
|
285
|
+
$0 == actualFingerprint
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let matched = matchedFingerprint != nil
|
|
289
|
+
|
|
290
|
+
TLSFingerprintLogger.debug(
|
|
291
|
+
"TLSFingerprint matched:",
|
|
292
|
+
"\(matched)"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
completionHandler(
|
|
296
|
+
matched
|
|
297
|
+
? .useCredential
|
|
298
|
+
: .cancelAuthenticationChallenge,
|
|
299
|
+
matched
|
|
300
|
+
? URLCredential(trust: trust)
|
|
301
|
+
: nil
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
completeWithResult(TLSFingerprintResult(
|
|
305
|
+
actualFingerprint: actualFingerprint,
|
|
306
|
+
fingerprintMatched: matched,
|
|
307
|
+
matchedFingerprint: matchedFingerprint,
|
|
308
|
+
mode: "fingerprint",
|
|
309
|
+
error: matched ? "" : TLSFingerprintErrorMessages.pinningFailed,
|
|
310
|
+
errorCode: matched ? "" : "PINNING_FAILED"
|
|
311
|
+
))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// MARK: - URLSessionTaskDelegate
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
Handles task-level events including timeouts.
|
|
318
|
+
|
|
319
|
+
This method is called when the task completes, allowing us
|
|
320
|
+
to detect and handle timeout errors that may occur before
|
|
321
|
+
or during the authentication challenge.
|
|
322
|
+
*/
|
|
323
|
+
func urlSession(
|
|
324
|
+
_ session: URLSession,
|
|
325
|
+
task: URLSessionTask,
|
|
326
|
+
didCompleteWithError error: Error?
|
|
327
|
+
) {
|
|
328
|
+
guard trySetCompleted() else { return }
|
|
329
|
+
|
|
330
|
+
if let error = error {
|
|
331
|
+
let nsError = error as NSError
|
|
332
|
+
|
|
333
|
+
// Timeout
|
|
334
|
+
if nsError.code == NSURLErrorTimedOut {
|
|
335
|
+
session.invalidateAndCancel()
|
|
336
|
+
completion(TLSFingerprintResult(
|
|
337
|
+
fingerprintMatched: false,
|
|
338
|
+
error: TLSFingerprintErrorMessages.timeout,
|
|
339
|
+
errorCode: "TIMEOUT"
|
|
340
|
+
))
|
|
341
|
+
}
|
|
342
|
+
// Common network errors (non-timeout)
|
|
343
|
+
else if nsError.domain == NSURLErrorDomain,
|
|
344
|
+
[NSURLErrorNotConnectedToInternet,
|
|
345
|
+
NSURLErrorNetworkConnectionLost,
|
|
346
|
+
NSURLErrorCannotFindHost,
|
|
347
|
+
NSURLErrorCannotConnectToHost].contains(nsError.code) {
|
|
348
|
+
session.invalidateAndCancel()
|
|
349
|
+
completion(TLSFingerprintResult(
|
|
350
|
+
fingerprintMatched: false,
|
|
351
|
+
error: TLSFingerprintErrorMessages.networkError,
|
|
352
|
+
errorCode: "NETWORK_ERROR"
|
|
353
|
+
))
|
|
354
|
+
} else {
|
|
355
|
+
// Fallback for other errors
|
|
356
|
+
session.invalidateAndCancel()
|
|
357
|
+
completion(TLSFingerprintResult(
|
|
358
|
+
fingerprintMatched: false,
|
|
359
|
+
error: TLSFingerprintErrorMessages.networkError,
|
|
360
|
+
errorCode: "NETWORK_ERROR"
|
|
361
|
+
))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
Native iOS implementation for the TLSFingerprint plugin.
|
|
5
|
+
|
|
6
|
+
Responsibilities:
|
|
7
|
+
- Perform platform-specific operations
|
|
8
|
+
- Throw typed TLSFingerprintError values on failure
|
|
9
|
+
|
|
10
|
+
Forbidden:
|
|
11
|
+
- Accessing CAPPluginCall
|
|
12
|
+
- Referencing Capacitor APIs
|
|
13
|
+
- Constructing JS payloads
|
|
14
|
+
*/
|
|
15
|
+
@objc public final class TLSFingerprintImpl: NSObject {
|
|
16
|
+
|
|
17
|
+
// MARK: - Constants
|
|
18
|
+
|
|
19
|
+
private static let timeoutSeconds: TimeInterval = 10
|
|
20
|
+
|
|
21
|
+
// MARK: - Properties
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
Static plugin configuration.
|
|
25
|
+
|
|
26
|
+
Injected once during plugin initialization.
|
|
27
|
+
Must not be mutated after being set.
|
|
28
|
+
*/
|
|
29
|
+
private var config: TLSFingerprintConfig?
|
|
30
|
+
|
|
31
|
+
// Initializer
|
|
32
|
+
override init() {
|
|
33
|
+
super.init()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: - Configuration
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
Applies static plugin configuration.
|
|
40
|
+
|
|
41
|
+
This method MUST be called exactly once
|
|
42
|
+
from the Plugin layer during `load()`.
|
|
43
|
+
|
|
44
|
+
Responsibilities:
|
|
45
|
+
- Store immutable configuration
|
|
46
|
+
- Configure runtime logging behavior
|
|
47
|
+
*/
|
|
48
|
+
func applyConfig(_ config: TLSFingerprintConfig) {
|
|
49
|
+
precondition(
|
|
50
|
+
self.config == nil,
|
|
51
|
+
"TLSFingerprintImpl.applyConfig(_:) must be called exactly once"
|
|
52
|
+
)
|
|
53
|
+
self.config = config
|
|
54
|
+
TLSFingerprintLogger.verbose = config.verboseLogging
|
|
55
|
+
|
|
56
|
+
TLSFingerprintLogger.debug(
|
|
57
|
+
"Configuration applied. Verbose logging:",
|
|
58
|
+
config.verboseLogging
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Single fingerprint
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
Validates the SSL certificate of a HTTPS endpoint
|
|
66
|
+
using a single SHA-256 fingerprint.
|
|
67
|
+
|
|
68
|
+
Resolution order:
|
|
69
|
+
1. Runtime fingerprint argument
|
|
70
|
+
2. Static configuration fingerprint
|
|
71
|
+
|
|
72
|
+
- Throws: `TLSFingerprintError.unavailable`
|
|
73
|
+
if no fingerprint is available.
|
|
74
|
+
*/
|
|
75
|
+
func checkCertificate(
|
|
76
|
+
urlString: String,
|
|
77
|
+
fingerprintFromArgs: String?
|
|
78
|
+
) async throws -> TLSFingerprintResult {
|
|
79
|
+
|
|
80
|
+
let fingerprint =
|
|
81
|
+
fingerprintFromArgs ??
|
|
82
|
+
config?.fingerprint
|
|
83
|
+
|
|
84
|
+
guard let expectedFingerprint = fingerprint else {
|
|
85
|
+
throw TLSFingerprintError.unavailable(
|
|
86
|
+
TLSFingerprintErrorMessages.noFingerprintsProvided
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return try await performCheck(
|
|
91
|
+
urlString: urlString,
|
|
92
|
+
fingerprints: [expectedFingerprint]
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// MARK: - Multiple fingerprints
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
Validates the SSL certificate of a HTTPS endpoint
|
|
100
|
+
using multiple allowed SHA-256 fingerprints.
|
|
101
|
+
|
|
102
|
+
A match is considered valid if ANY provided
|
|
103
|
+
fingerprint matches the server certificate.
|
|
104
|
+
|
|
105
|
+
- Throws: `TLSFingerprintError.unavailable`
|
|
106
|
+
if no fingerprints are available.
|
|
107
|
+
*/
|
|
108
|
+
func checkCertificates(
|
|
109
|
+
urlString: String,
|
|
110
|
+
fingerprintsFromArgs: [String]?
|
|
111
|
+
) async throws -> TLSFingerprintResult {
|
|
112
|
+
|
|
113
|
+
let fingerprints =
|
|
114
|
+
fingerprintsFromArgs ??
|
|
115
|
+
config?.fingerprints
|
|
116
|
+
|
|
117
|
+
guard let fps = fingerprints, !fps.isEmpty else {
|
|
118
|
+
throw TLSFingerprintError.unavailable(
|
|
119
|
+
TLSFingerprintErrorMessages.noFingerprintsProvided
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return try await performCheck(
|
|
124
|
+
urlString: urlString,
|
|
125
|
+
fingerprints: fps
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// MARK: - Shared implementation
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
Performs SSL fingerprint validation for a given HTTPS URL.
|
|
133
|
+
|
|
134
|
+
Evaluation order:
|
|
135
|
+
|
|
136
|
+
1. Excluded domains → bypass validation entirely.
|
|
137
|
+
2. Fingerprint-based validation.
|
|
138
|
+
|
|
139
|
+
This method uses async/await and wraps the URLSession
|
|
140
|
+
delegate flow inside a continuation.
|
|
141
|
+
|
|
142
|
+
- Throws:
|
|
143
|
+
- `TLSFingerprintError.unknownType`
|
|
144
|
+
- `TLSFingerprintError.initFailed`
|
|
145
|
+
*/
|
|
146
|
+
private func performCheck(
|
|
147
|
+
urlString: String,
|
|
148
|
+
fingerprints: [String]
|
|
149
|
+
) async throws -> TLSFingerprintResult {
|
|
150
|
+
|
|
151
|
+
guard let url = TLSFingerprintUtils.httpsURL(from: urlString) else {
|
|
152
|
+
throw TLSFingerprintError.unknownType(
|
|
153
|
+
TLSFingerprintErrorMessages.invalidUrlMustBeHttps
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let host = url.host?.lowercased() ?? ""
|
|
158
|
+
|
|
159
|
+
// -----------------------------------------------------------------------
|
|
160
|
+
// EXCLUDED DOMAIN MODE
|
|
161
|
+
// -----------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
If the request host matches an excluded domain,
|
|
165
|
+
we still perform the connection to retrieve the actual fingerprint
|
|
166
|
+
for parity with Android behavior.
|
|
167
|
+
|
|
168
|
+
The connection uses a permissive trust manager.
|
|
169
|
+
System trust chain is NOT explicitly evaluated.
|
|
170
|
+
*/
|
|
171
|
+
if isExcludedDomain(host) {
|
|
172
|
+
TLSFingerprintLogger.debug("TLSFingerprint excluded domain:", host)
|
|
173
|
+
|
|
174
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
175
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
176
|
+
configuration.timeoutIntervalForRequest = Self.timeoutSeconds
|
|
177
|
+
configuration.timeoutIntervalForResource = Self.timeoutSeconds
|
|
178
|
+
|
|
179
|
+
let delegate = TLSFingerprintDelegate(
|
|
180
|
+
expectedFingerprints: [],
|
|
181
|
+
excludedDomains: config?.excludedDomains ?? [],
|
|
182
|
+
completion: { result in
|
|
183
|
+
continuation.resume(returning: result)
|
|
184
|
+
},
|
|
185
|
+
verboseLogging: config?.verboseLogging ?? false
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
let session = URLSession(
|
|
189
|
+
configuration: configuration,
|
|
190
|
+
delegate: delegate,
|
|
191
|
+
delegateQueue: nil
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
delegate.setSession(session)
|
|
195
|
+
|
|
196
|
+
let timeoutWorkItem = DispatchWorkItem { [weak delegate] in
|
|
197
|
+
guard let delegate = delegate else { return }
|
|
198
|
+
let shouldTimeout = delegate.trySetCompleted()
|
|
199
|
+
if shouldTimeout {
|
|
200
|
+
session.invalidateAndCancel()
|
|
201
|
+
continuation.resume(returning: TLSFingerprintResult(
|
|
202
|
+
fingerprintMatched: false,
|
|
203
|
+
error: TLSFingerprintErrorMessages.timeout,
|
|
204
|
+
errorCode: "TIMEOUT"
|
|
205
|
+
))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + Self.timeoutSeconds, execute: timeoutWorkItem)
|
|
210
|
+
|
|
211
|
+
session.dataTask(with: url).resume()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
216
|
+
|
|
217
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
218
|
+
configuration.timeoutIntervalForRequest = 10
|
|
219
|
+
configuration.timeoutIntervalForResource = 10
|
|
220
|
+
|
|
221
|
+
let delegate = TLSFingerprintDelegate(
|
|
222
|
+
expectedFingerprints: fingerprints,
|
|
223
|
+
excludedDomains: config?.excludedDomains ?? [],
|
|
224
|
+
completion: { result in
|
|
225
|
+
continuation.resume(returning: result)
|
|
226
|
+
},
|
|
227
|
+
verboseLogging: config?.verboseLogging ?? false
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
let session = URLSession(
|
|
231
|
+
configuration: configuration,
|
|
232
|
+
delegate: delegate,
|
|
233
|
+
delegateQueue: nil
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
delegate.setSession(session)
|
|
237
|
+
|
|
238
|
+
let timeoutWorkItem = DispatchWorkItem { [weak delegate] in
|
|
239
|
+
guard let delegate = delegate else { return }
|
|
240
|
+
let shouldTimeout = delegate.trySetCompleted()
|
|
241
|
+
if shouldTimeout {
|
|
242
|
+
session.invalidateAndCancel()
|
|
243
|
+
continuation.resume(returning: TLSFingerprintResult(
|
|
244
|
+
fingerprintMatched: false,
|
|
245
|
+
error: TLSFingerprintErrorMessages.timeout,
|
|
246
|
+
errorCode: "TIMEOUT"
|
|
247
|
+
))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + Self.timeoutSeconds, execute: timeoutWorkItem)
|
|
252
|
+
|
|
253
|
+
session.dataTask(with: url).resume()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
Determines whether the given host
|
|
259
|
+
matches one of the configured excluded domains.
|
|
260
|
+
|
|
261
|
+
Matching rules:
|
|
262
|
+
- Exact match
|
|
263
|
+
- Subdomain match
|
|
264
|
+
|
|
265
|
+
Matching is case-insensitive.
|
|
266
|
+
*/
|
|
267
|
+
private func isExcludedDomain(_ host: String) -> Bool {
|
|
268
|
+
let hostLower = host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
269
|
+
return config?.excludedDomains.contains { excluded in
|
|
270
|
+
let excludedLower = excluded.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
271
|
+
// Match exact domain or any subdomain (e.g., api.example.com matches example.com)
|
|
272
|
+
return hostLower == excludedLower || hostLower.hasSuffix("." + excludedLower)
|
|
273
|
+
} ?? false
|
|
274
|
+
}
|
|
275
|
+
}
|