@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.
Files changed (51) hide show
  1. package/CapKitTlsFingerprint.podspec +17 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +25 -0
  4. package/README.md +427 -0
  5. package/android/build.gradle +103 -0
  6. package/android/src/main/AndroidManifest.xml +3 -0
  7. package/android/src/main/java/io/capkit/settings/TLSFingerprintImpl.kt +333 -0
  8. package/android/src/main/java/io/capkit/settings/TLSFingerprintPlugin.kt +342 -0
  9. package/android/src/main/java/io/capkit/settings/config/TLSFingerprintConfig.kt +102 -0
  10. package/android/src/main/java/io/capkit/settings/error/TLSFingerprintError.kt +114 -0
  11. package/android/src/main/java/io/capkit/settings/error/TLSFingerprintErrorMessages.kt +27 -0
  12. package/android/src/main/java/io/capkit/settings/logger/TLSFingerprintLogger.kt +85 -0
  13. package/android/src/main/java/io/capkit/settings/model/TLSFingerprintResultModel.kt +32 -0
  14. package/android/src/main/java/io/capkit/settings/utils/TLSFingerprintUtils.kt +91 -0
  15. package/android/src/main/res/.gitkeep +0 -0
  16. package/dist/cli/fingerprint.js +163 -0
  17. package/dist/cli/fingerprint.js.map +1 -0
  18. package/dist/docs.json +386 -0
  19. package/dist/esm/cli/fingerprint.d.ts +1 -0
  20. package/dist/esm/cli/fingerprint.js +161 -0
  21. package/dist/esm/cli/fingerprint.js.map +1 -0
  22. package/dist/esm/definitions.d.ts +244 -0
  23. package/dist/esm/definitions.js +42 -0
  24. package/dist/esm/definitions.js.map +1 -0
  25. package/dist/esm/index.d.ts +13 -0
  26. package/dist/esm/index.js +11 -0
  27. package/dist/esm/index.js.map +1 -0
  28. package/dist/esm/version.d.ts +1 -0
  29. package/dist/esm/version.js +3 -0
  30. package/dist/esm/version.js.map +1 -0
  31. package/dist/esm/web.d.ts +33 -0
  32. package/dist/esm/web.js +47 -0
  33. package/dist/esm/web.js.map +1 -0
  34. package/dist/plugin.cjs +107 -0
  35. package/dist/plugin.cjs.map +1 -0
  36. package/dist/plugin.js +110 -0
  37. package/dist/plugin.js.map +1 -0
  38. package/ios/Sources/TLSFingerprintPlugin/TLSFingerprintDelegate.swift +365 -0
  39. package/ios/Sources/TLSFingerprintPlugin/TLSFingerprintImpl.swift +275 -0
  40. package/ios/Sources/TLSFingerprintPlugin/TLSFingerprintPlugin.swift +219 -0
  41. package/ios/Sources/TLSFingerprintPlugin/Version.swift +16 -0
  42. package/ios/Sources/TLSFingerprintPlugin/config/TLSFingerprintConfig.swift +114 -0
  43. package/ios/Sources/TLSFingerprintPlugin/error/TLSFingerprintError.swift +107 -0
  44. package/ios/Sources/TLSFingerprintPlugin/error/TLSFingerprintErrorMessages.swift +30 -0
  45. package/ios/Sources/TLSFingerprintPlugin/logger/TLSFingerprintLogger.swift +69 -0
  46. package/ios/Sources/TLSFingerprintPlugin/model/TLSFingerprintResult.swift +76 -0
  47. package/ios/Sources/TLSFingerprintPlugin/utils/TLSFingerprintUtils.swift +79 -0
  48. package/ios/Tests/TLSFingerprintPluginTests/TLSFingerprintPluginTests.swift +15 -0
  49. package/package.json +131 -0
  50. package/scripts/chmod.mjs +34 -0
  51. 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
+ }