@dynamic-labs-sdk/client 1.7.0 → 1.8.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 (118) hide show
  1. package/android/build.gradle +231 -0
  2. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  3. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  4. package/android/gradle.properties +8 -0
  5. package/android/gradlew +251 -0
  6. package/android/gradlew.bat +94 -0
  7. package/android/settings.gradle +30 -0
  8. package/android/src/main/java/xyz/dynamic/client/DynamicClientPackage.kt +52 -0
  9. package/android/src/main/java/xyz/dynamic/client/keychain/KeyStoreKeyManager.kt +147 -0
  10. package/android/src/main/java/xyz/dynamic/client/keychain/KeychainModule.kt +85 -0
  11. package/android/src/main/java/xyz/dynamic/client/manifest/ReactNativeManifestModule.kt +25 -0
  12. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewClient.kt +83 -0
  13. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewHost.kt +518 -0
  14. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewModule.kt +147 -0
  15. package/android/src/test/java/xyz/dynamic/client/keychain/KeyStoreKeyManagerTest.kt +288 -0
  16. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewClientTest.kt +196 -0
  17. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostOpenTest.kt +347 -0
  18. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostTest.kt +171 -0
  19. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewModuleTest.kt +191 -0
  20. package/dist/{InvalidParamError-Crwntjl7.native.esm.js → InvalidParamError-Bc6OZBog.native.esm.js} +3 -3
  21. package/dist/{InvalidParamError-Crwntjl7.native.esm.js.map → InvalidParamError-Bc6OZBog.native.esm.js.map} +1 -1
  22. package/dist/{InvalidParamError-fQLJaGBW.esm.js → InvalidParamError-IHYLdzFd.esm.js} +437 -5
  23. package/dist/InvalidParamError-IHYLdzFd.esm.js.map +1 -0
  24. package/dist/{InvalidParamError-CTssOe86.cjs → InvalidParamError-S56rFFDP.cjs} +550 -4
  25. package/dist/InvalidParamError-S56rFFDP.cjs.map +1 -0
  26. package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js → NotWaasWalletAccountError-BP-bODbJ.native.esm.js} +3 -3
  27. package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js.map → NotWaasWalletAccountError-BP-bODbJ.native.esm.js.map} +1 -1
  28. package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js → NotWaasWalletAccountError-BYEL2l-W.esm.js} +3 -3
  29. package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js.map → NotWaasWalletAccountError-BYEL2l-W.esm.js.map} +1 -1
  30. package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs → NotWaasWalletAccountError-CbhIKUDM.cjs} +3 -3
  31. package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs.map → NotWaasWalletAccountError-CbhIKUDM.cjs.map} +1 -1
  32. package/dist/core.cjs +23 -23
  33. package/dist/core.cjs.map +1 -1
  34. package/dist/core.esm.js +5 -5
  35. package/dist/core.native.esm.js +7 -7
  36. package/dist/core.native.esm.js.map +1 -1
  37. package/dist/errors/WebViewLoadFailedError.d.ts +1 -1
  38. package/dist/errors/WebViewLoadFailedError.d.ts.map +1 -1
  39. package/dist/exports/index.d.ts +2 -0
  40. package/dist/exports/index.d.ts.map +1 -1
  41. package/dist/{getNetworkProviderFromNetworkId-C1p9Rrg3.cjs → getNetworkProviderFromNetworkId-BDdcpGHH.cjs} +11 -317
  42. package/dist/getNetworkProviderFromNetworkId-BDdcpGHH.cjs.map +1 -0
  43. package/dist/{getNetworkProviderFromNetworkId-BWhaD23J.esm.js → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js} +4 -242
  44. package/dist/getNetworkProviderFromNetworkId-D8rWYXlT.esm.js.map +1 -0
  45. package/dist/{getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js → getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js} +6 -6
  46. package/dist/getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js.map +1 -0
  47. package/dist/{getSignedSessionId-CxxYNC_8.cjs → getSignedSessionId-BBaBCi9S.cjs} +3 -3
  48. package/dist/{getSignedSessionId-CxxYNC_8.cjs.map → getSignedSessionId-BBaBCi9S.cjs.map} +1 -1
  49. package/dist/{getSignedSessionId-c7dS4PQ9.esm.js → getSignedSessionId-CND07Eca.esm.js} +3 -3
  50. package/dist/{getSignedSessionId-c7dS4PQ9.esm.js.map → getSignedSessionId-CND07Eca.esm.js.map} +1 -1
  51. package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js → getSignedSessionId-RwV7JPn8.native.esm.js} +3 -3
  52. package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js.map → getSignedSessionId-RwV7JPn8.native.esm.js.map} +1 -1
  53. package/dist/{getVerifiedCredentialForWalletAccount-Cahp763h.esm.js → getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js} +2 -2
  54. package/dist/{getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js.map → getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js.map} +1 -1
  55. package/dist/getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js +269 -0
  56. package/dist/getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js.map +1 -0
  57. package/dist/getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs +347 -0
  58. package/dist/getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs.map +1 -0
  59. package/dist/index.cjs +67 -67
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.esm.js +5 -5
  62. package/dist/index.esm.js.map +1 -1
  63. package/dist/index.native.esm.js +5 -5
  64. package/dist/index.native.esm.js.map +1 -1
  65. package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js +318 -0
  66. package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js.map +1 -0
  67. package/dist/isMfaRequiredForAction-BolTrtCO.cjs +405 -0
  68. package/dist/isMfaRequiredForAction-BolTrtCO.cjs.map +1 -0
  69. package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js → isMfaRequiredForAction-DQDoPsi6.native.esm.js} +2 -2
  70. package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js.map → isMfaRequiredForAction-DQDoPsi6.native.esm.js.map} +1 -1
  71. package/dist/modules/waas/createWaasClient/createWaasClient.d.ts.map +1 -1
  72. package/dist/modules/waas/createWaasClient/translateNativeOpenError.d.ts.map +1 -1
  73. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts +1 -2
  74. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts.map +1 -1
  75. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  76. package/dist/waas.cjs +9 -9
  77. package/dist/waas.cjs.map +1 -1
  78. package/dist/waas.esm.js +3 -3
  79. package/dist/waas.native.esm.js +3 -3
  80. package/dist/waasCore.cjs +8 -5
  81. package/dist/waasCore.cjs.map +1 -1
  82. package/dist/waasCore.esm.js +8 -4
  83. package/dist/waasCore.esm.js.map +1 -1
  84. package/dist/waasCore.native.esm.js +8 -7
  85. package/dist/waasCore.native.esm.js.map +1 -1
  86. package/dynamic-labs-sdk-client.podspec +27 -0
  87. package/ios/DynamicClientKeychain.h +4 -0
  88. package/ios/DynamicClientKeychain.mm +101 -0
  89. package/ios/DynamicClientManifest.h +4 -0
  90. package/ios/DynamicClientManifest.mm +45 -0
  91. package/ios/DynamicClientWebView.h +5 -0
  92. package/ios/DynamicClientWebView.mm +143 -0
  93. package/ios/DynamicWebViewImpl.swift +603 -0
  94. package/ios/LiveSecKeyAPI.swift +86 -0
  95. package/ios/ReactNativeManifestImpl.swift +20 -0
  96. package/ios/SecKeyAPI.swift +57 -0
  97. package/ios/SecureEnclaveKeyManager.swift +191 -0
  98. package/package.json +14 -5
  99. package/react-native.config.cjs +14 -0
  100. package/src/turboModules/NativeDynamicWebView.native.spec.ts +61 -0
  101. package/src/turboModules/NativeDynamicWebView.ts +40 -0
  102. package/src/turboModules/NativeKeychain.native.spec.ts +47 -0
  103. package/src/turboModules/NativeKeychain.ts +19 -0
  104. package/src/turboModules/NativeReactNativeManifest.native.spec.ts +55 -0
  105. package/src/turboModules/NativeReactNativeManifest.ts +28 -0
  106. package/dist/InvalidParamError-CTssOe86.cjs.map +0 -1
  107. package/dist/InvalidParamError-fQLJaGBW.esm.js.map +0 -1
  108. package/dist/getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js.map +0 -1
  109. package/dist/getNetworkProviderFromNetworkId-BWhaD23J.esm.js.map +0 -1
  110. package/dist/getNetworkProviderFromNetworkId-C1p9Rrg3.cjs.map +0 -1
  111. package/dist/getVerifiedCredentialForWalletAccount-Cahp763h.esm.js.map +0 -1
  112. package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs +0 -887
  113. package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs.map +0 -1
  114. package/dist/getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js +0 -701
  115. package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js +0 -79
  116. package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js.map +0 -1
  117. package/dist/isMfaRequiredForAction-hlhj0qAC.cjs +0 -96
  118. package/dist/isMfaRequiredForAction-hlhj0qAC.cjs.map +0 -1
@@ -0,0 +1,603 @@
1
+ import Foundation
2
+ import UIKit
3
+ import WebKit
4
+
5
+ /// Delegate used by the Swift implementation to hand messages
6
+ /// received from the WebView back to the ObjC++ module, which
7
+ /// forwards them to JavaScript via `RCTEventEmitter`.
8
+ @objc public protocol DynamicWebViewImplDelegate: AnyObject {
9
+ func didReceiveMessage(forInstanceId instanceId: String, data: Any)
10
+ }
11
+
12
+ /// JS glue injected into every DynamicWebView instance at document start.
13
+ ///
14
+ /// Purpose: hosted pages written for the browser iframe transport call
15
+ /// `window.parent.postMessage` to reach the SDK host. In a WKWebView
16
+ /// there is no real parent window, so we:
17
+ /// 1. Intercept the page's outbound `window.parent.postMessage` calls
18
+ /// and forward their payload to the native side via
19
+ /// `webkit.messageHandlers.dynamicBridge.postMessage(data)`.
20
+ /// 2. Expose `window.__dynamicReceive(json)` which re-dispatches a
21
+ /// synthetic `MessageEvent` on `window` so the page's existing
22
+ /// `window.addEventListener('message', ...)` code receives host
23
+ /// messages exactly as it would inside a browser iframe.
24
+ private let bridgeUserScript = """
25
+ (function() {
26
+ if (window.__dynamicBridgeInstalled) { return; }
27
+ window.__dynamicBridgeInstalled = true;
28
+
29
+ var post = function(data) {
30
+ try {
31
+ window.webkit.messageHandlers.dynamicBridge.postMessage(data);
32
+ } catch (e) {}
33
+ };
34
+
35
+ // The hosted iframe page calls `window.parent.postMessage(data, hostOrigin)`.
36
+ // In a top-level WKWebView `window.parent === window`, so `window.parent.postMessage`
37
+ // resolves to `window.postMessage`. The browser's `postMessage` performs a
38
+ // targetOrigin check and silently drops the message when the declared hostOrigin
39
+ // (e.g. the RN app's bundler origin) does not match the WebView's own origin,
40
+ // which is always the case here. We override `window.postMessage` to intercept
41
+ // the call *before* the origin check runs and forward the payload to native.
42
+ try {
43
+ Object.defineProperty(window, 'postMessage', {
44
+ configurable: true,
45
+ writable: true,
46
+ value: function(data, targetOrigin, transfer) {
47
+ post(data);
48
+ }
49
+ });
50
+ } catch (e) {
51
+ try { window.postMessage = function(data) { post(data); }; } catch (_) {}
52
+ }
53
+
54
+ // Called by native (via evaluateJavaScript) to deliver a host -> webview message.
55
+ // Re-dispatches a synthetic `MessageEvent` on `window` so the page's existing
56
+ // `window.addEventListener('message', ...)` handler fires exactly as it would
57
+ // in a real iframe.
58
+ window.__dynamicReceive = function(json) {
59
+ try {
60
+ var payload = typeof json === 'string' ? JSON.parse(json) : json;
61
+ var evt = new MessageEvent('message', {
62
+ data: payload,
63
+ origin: window.location.origin,
64
+ source: window
65
+ });
66
+ window.dispatchEvent(evt);
67
+ } catch (e) {}
68
+ };
69
+ })();
70
+ """
71
+
72
+ // `internal` (not `private`) so `shouldDeliverScriptMessage` and the bridge
73
+ // origin-check unit tests can reference the handler name without hardcoding it.
74
+ let bridgeHandlerName = "dynamicBridge"
75
+ private let errorDomain = "DynamicWebView"
76
+
77
+ // Keys read out of NSError.userInfo by the ObjC++ bridge when translating a
78
+ // native error into an RCT reject-block code + JS-accessible metadata.
79
+ // The JS wrapper reads `reason` to build a typed `WebViewLoadFailedError`.
80
+ private let errorReasonKey = "DynamicWebViewReason"
81
+ private let errorStatusCodeKey = "DynamicWebViewStatusCode"
82
+ private let errorUnderlyingDescriptionKey = "DynamicWebViewUnderlying"
83
+
84
+ /// Reasons mirror `WebViewLoadFailureReason` in the JS errors module.
85
+ /// Kept as plain strings rather than an enum so the value can cross
86
+ /// the ObjC/JS bridge unchanged via NSError.userInfo.
87
+ private enum Reason {
88
+ static let invalidUrl = "invalid_url"
89
+ static let noHostView = "no_host_view"
90
+ static let httpError = "http_error"
91
+ static let navigationFailed = "navigation_failed"
92
+ static let cancelled = "cancelled"
93
+ static let instanceBusy = "instance_busy"
94
+ static let contentProcessTerminated = "content_process_terminated"
95
+ }
96
+
97
+ private func makeError(
98
+ reason: String,
99
+ message: String,
100
+ statusCode: Int? = nil,
101
+ underlyingDescription: String? = nil
102
+ ) -> NSError {
103
+ var userInfo: [String: Any] = [
104
+ NSLocalizedDescriptionKey: message,
105
+ errorReasonKey: reason,
106
+ ]
107
+ if let statusCode = statusCode {
108
+ userInfo[errorStatusCodeKey] = statusCode
109
+ }
110
+ if let underlyingDescription = underlyingDescription {
111
+ userInfo[errorUnderlyingDescriptionKey] = underlyingDescription
112
+ }
113
+ return NSError(domain: errorDomain, code: 0, userInfo: userInfo)
114
+ }
115
+
116
+ // MARK: - Origin / trust helpers (pure, unit-testable)
117
+
118
+ /// `scheme://host[:port]` with the scheme-default https port (443) omitted, or
119
+ /// `nil` when scheme/host are missing. Normalizing makes the pinned origin
120
+ /// comparable to both a `WKSecurityOrigin` and a navigation `URL`, and mirrors
121
+ /// the Android `originOf` helper so the two platforms pin identically.
122
+ func normalizedOrigin(scheme: String?, host: String?, port: Int?) -> String? {
123
+ guard let scheme = scheme?.lowercased(), let host = host, !host.isEmpty else {
124
+ return nil
125
+ }
126
+ if let port = port, !(scheme == "https" && port == 443) {
127
+ return "\(scheme)://\(host):\(port)"
128
+ }
129
+ return "\(scheme)://\(host)"
130
+ }
131
+
132
+ /// The normalized origin of a URL.
133
+ func originString(of url: URL) -> String? {
134
+ normalizedOrigin(scheme: url.scheme, host: url.host, port: url.port)
135
+ }
136
+
137
+ /// Navigation is allowed only to https on the pinned origin — blocking any
138
+ /// main-frame navigation that would re-expose the bridge to another origin.
139
+ func isNavigationAllowed(to url: URL?, expectedOrigin: String) -> Bool {
140
+ guard let url = url, url.scheme?.lowercased() == "https" else { return false }
141
+ return originString(of: url) == expectedOrigin
142
+ }
143
+
144
+ /// A bridge message is trusted only from the pinned origin's main frame.
145
+ func isMessageTrusted(
146
+ isMainFrame: Bool,
147
+ messageOrigin: String?,
148
+ expectedOrigin: String
149
+ ) -> Bool {
150
+ isMainFrame && messageOrigin == expectedOrigin
151
+ }
152
+
153
+ // MARK: - Delegate-decision seams (pure, unit-testable)
154
+
155
+ // The WebKit callback argument types (WKNavigationAction / WKNavigationResponse
156
+ // / WKScriptMessage) have no public initializers, so they cannot be constructed
157
+ // in a unit test. These protocols expose only the fields the trust decisions
158
+ // read, letting the decision logic be tested with plain fakes while the real WK
159
+ // types conform via the extensions below. The protocols are deliberately NOT
160
+ // @objc so they never leak into the generated -Swift.h that sibling .mm files
161
+ // import.
162
+
163
+ /// The fields the navigation gate reads off a `WKNavigationAction`.
164
+ protocol NavigationActionLike {
165
+ var requestURL: URL? { get }
166
+ /// `nil` when the action has no target frame (e.g. a new-window request);
167
+ /// `true`/`false` for a main-frame vs sub-frame target.
168
+ var isTargetMainFrame: Bool? { get }
169
+ }
170
+
171
+ /// The fields the HTTP-error gate reads off a `WKNavigationResponse`.
172
+ protocol NavigationResponseLike {
173
+ var isForMainFrame: Bool { get }
174
+ var httpStatusCode: Int? { get }
175
+ }
176
+
177
+ /// The fields the bridge origin check reads off a `WKScriptMessage`.
178
+ protocol ScriptMessageLike {
179
+ var messageName: String { get }
180
+ var isMainFrame: Bool { get }
181
+ var sourceOrigin: String? { get }
182
+ }
183
+
184
+ extension WKNavigationAction: NavigationActionLike {
185
+ var requestURL: URL? { request.url }
186
+ var isTargetMainFrame: Bool? { targetFrame?.isMainFrame }
187
+ }
188
+
189
+ extension WKNavigationResponse: NavigationResponseLike {
190
+ // `isForMainFrame` is already a stored property on WKNavigationResponse and
191
+ // satisfies the protocol requirement directly.
192
+ var httpStatusCode: Int? { (response as? HTTPURLResponse)?.statusCode }
193
+ }
194
+
195
+ extension WKScriptMessage: ScriptMessageLike {
196
+ var messageName: String { name }
197
+ var isMainFrame: Bool { frameInfo.isMainFrame }
198
+ var sourceOrigin: String? {
199
+ let origin = frameInfo.securityOrigin
200
+ return normalizedOrigin(
201
+ scheme: origin.`protocol`,
202
+ host: origin.host,
203
+ // WKSecurityOrigin reports 0 for the scheme-default port.
204
+ port: origin.port == 0 ? nil : origin.port
205
+ )
206
+ }
207
+ }
208
+
209
+ /// Navigation-action policy: sub-frame navigations are always allowed (the WaaS
210
+ /// page has none); a main-frame navigation is allowed only to https on the
211
+ /// pinned origin so the bridge can never be re-injected into another origin.
212
+ func navigationActionPolicy(
213
+ for action: NavigationActionLike,
214
+ expectedOrigin: String
215
+ ) -> WKNavigationActionPolicy {
216
+ if action.isTargetMainFrame == false { return .allow }
217
+ return isNavigationAllowed(to: action.requestURL, expectedOrigin: expectedOrigin)
218
+ ? .allow
219
+ : .cancel
220
+ }
221
+
222
+ /// HTTP-error gate: returns the status code to fail the load with when a
223
+ /// main-frame response is >= 400, or `nil` when the response should be allowed.
224
+ /// Sub-frame responses never fail the load.
225
+ func navigationResponseHttpError(for response: NavigationResponseLike) -> Int? {
226
+ guard response.isForMainFrame,
227
+ let statusCode = response.httpStatusCode,
228
+ statusCode >= 400 else {
229
+ return nil
230
+ }
231
+ return statusCode
232
+ }
233
+
234
+ /// Whether a script message should be delivered to JS: it must target our
235
+ /// bridge handler and pass the main-frame + pinned-origin trust check.
236
+ func shouldDeliverScriptMessage(
237
+ _ message: ScriptMessageLike,
238
+ expectedOrigin: String
239
+ ) -> Bool {
240
+ guard message.messageName == bridgeHandlerName else { return false }
241
+ return isMessageTrusted(
242
+ isMainFrame: message.isMainFrame,
243
+ messageOrigin: message.sourceOrigin,
244
+ expectedOrigin: expectedOrigin
245
+ )
246
+ }
247
+
248
+ // MARK: - Pending-load registry
249
+
250
+ /// Tracks the in-flight `open` completion per instance and guarantees each load
251
+ /// settles exactly once. Extracted from `DynamicWebViewImpl` so the
252
+ /// settle-once state machine (didCommit success; didFail / http_error / cancel /
253
+ /// content-process crash failures; and double-settle no-ops) is unit-testable
254
+ /// without a live WKWebView.
255
+ final class PendingLoadRegistry {
256
+ private var completions: [String: (Error?) -> Void] = [:]
257
+
258
+ /// Registers the completion to invoke when `instanceId`'s load settles.
259
+ func register(instanceId: String, completion: @escaping (Error?) -> Void) {
260
+ completions[instanceId] = completion
261
+ }
262
+
263
+ /// Whether a completion is still pending for `instanceId`.
264
+ func isPending(instanceId: String) -> Bool {
265
+ completions[instanceId] != nil
266
+ }
267
+
268
+ /// Settles the pending completion (removing it first) with `error` — `nil`
269
+ /// means success. Returns `true` if a completion was pending; `false` when it
270
+ /// was already settled, in which case this is a no-op.
271
+ @discardableResult
272
+ func settle(instanceId: String, error: Error?) -> Bool {
273
+ guard let completion = completions.removeValue(forKey: instanceId) else {
274
+ return false
275
+ }
276
+ completion(error)
277
+ return true
278
+ }
279
+ }
280
+
281
+ // MARK: - Per-instance handler shims
282
+
283
+ /// Per-instance message-handler shim. Captures its own `instanceId` and pinned
284
+ /// `expectedOrigin` so message routing and origin trust are enforced
285
+ /// structurally, not by reverse-looking-up the sending WKWebView. Kept as a
286
+ /// separate `NSObject` subclass so `DynamicWebViewImpl` does NOT declare
287
+ /// `WKScriptMessageHandler` conformance (which would leak into the generated
288
+ /// `-Swift.h` and break sibling `.mm` files that don't import WebKit).
289
+ private class MessageBridge: NSObject, WKScriptMessageHandler {
290
+ private let instanceId: String
291
+ private let expectedOrigin: String
292
+ weak var owner: DynamicWebViewImpl?
293
+
294
+ init(instanceId: String, expectedOrigin: String, owner: DynamicWebViewImpl) {
295
+ self.instanceId = instanceId
296
+ self.expectedOrigin = expectedOrigin
297
+ self.owner = owner
298
+ }
299
+
300
+ func userContentController(
301
+ _ userContentController: WKUserContentController,
302
+ didReceive message: WKScriptMessage
303
+ ) {
304
+ // Pin the bridge to the opened origin's main frame: a redirect or a
305
+ // compromised/embedded frame cannot forward attacker messages as trusted.
306
+ // The decision lives in `shouldDeliverScriptMessage` so it is unit-testable
307
+ // without a live WKWebView (WKScriptMessage has no public initializer).
308
+ guard shouldDeliverScriptMessage(message, expectedOrigin: expectedOrigin) else {
309
+ return
310
+ }
311
+ owner?.deliverMessage(instanceId: instanceId, body: message.body)
312
+ }
313
+ }
314
+
315
+ /// Per-instance navigation-delegate shim. Same NSObject-containment pattern as
316
+ /// `MessageBridge`. Captures `instanceId` + pinned `expectedOrigin` to gate
317
+ /// navigation and route lifecycle events without a WebView reverse-lookup.
318
+ private class NavigationBridge: NSObject, WKNavigationDelegate {
319
+ private let instanceId: String
320
+ private let expectedOrigin: String
321
+ weak var owner: DynamicWebViewImpl?
322
+
323
+ init(instanceId: String, expectedOrigin: String, owner: DynamicWebViewImpl) {
324
+ self.instanceId = instanceId
325
+ self.expectedOrigin = expectedOrigin
326
+ self.owner = owner
327
+ }
328
+
329
+ // Fail closed against off-origin / non-https navigation. The initial load is
330
+ // to the pinned origin so it is allowed; any later main-frame navigation to a
331
+ // different origin (open redirect, injected link, scripted location change) is
332
+ // cancelled so the bridge can never be re-injected into an attacker origin.
333
+ // Subframe navigations are left to WebKit (the WaaS page has no subframes).
334
+ func webView(
335
+ _ webView: WKWebView,
336
+ decidePolicyFor navigationAction: WKNavigationAction,
337
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
338
+ ) {
339
+ decisionHandler(navigationActionPolicy(
340
+ for: navigationAction,
341
+ expectedOrigin: expectedOrigin
342
+ ))
343
+ }
344
+
345
+ // Intercept the HTTP response before commit: for a main-frame non-2xx/3xx,
346
+ // cancel and record the status so the owner rejects the pending `open` with a
347
+ // typed `http_error` instead of rendering the error body as a success.
348
+ // Gated on `isForMainFrame` so a sub-resource 4xx/5xx cannot fail the load.
349
+ func webView(
350
+ _ webView: WKWebView,
351
+ decidePolicyFor navigationResponse: WKNavigationResponse,
352
+ decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
353
+ ) {
354
+ if let statusCode = navigationResponseHttpError(for: navigationResponse) {
355
+ owner?.handleHttpError(instanceId: instanceId, statusCode: statusCode)
356
+ decisionHandler(.cancel)
357
+ return
358
+ }
359
+ decisionHandler(.allow)
360
+ }
361
+
362
+ func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
363
+ owner?.handleDidCommit(instanceId: instanceId)
364
+ }
365
+
366
+ func webView(
367
+ _ webView: WKWebView,
368
+ didFailProvisionalNavigation navigation: WKNavigation!,
369
+ withError error: Error
370
+ ) {
371
+ owner?.handleDidFail(instanceId: instanceId, error: error)
372
+ }
373
+
374
+ func webView(
375
+ _ webView: WKWebView,
376
+ didFail navigation: WKNavigation!,
377
+ withError error: Error
378
+ ) {
379
+ owner?.handleDidFail(instanceId: instanceId, error: error)
380
+ }
381
+
382
+ func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
383
+ owner?.handleContentProcessTerminated(instanceId: instanceId)
384
+ }
385
+ }
386
+
387
+ /// Swift implementation of the `DynamicWebView` TurboModule.
388
+ ///
389
+ /// Owns one `WKWebView` per JS `SandboxHost` instance. Each WebView
390
+ /// is attached (hidden, zero-frame, non-interactive) to the foreground-
391
+ /// active window so WKWebView's WebContent process is not suspended by
392
+ /// iOS during long-running MPC ceremonies.
393
+ @objc public class DynamicWebViewImpl: NSObject {
394
+
395
+ @objc public weak var delegate: DynamicWebViewImplDelegate?
396
+
397
+ private var webViews: [String: WKWebView] = [:]
398
+ // Pending completion for `open` — resolved on first terminal navigation
399
+ // event (didCommit / didFail* / HTTP >= 400 / content process crash) or
400
+ // explicit cancel (close called before settle). `internal` (not `private`)
401
+ // so the settle-once state machine can be driven directly in unit tests.
402
+ let pending = PendingLoadRegistry()
403
+ // The origin each instance is pinned to (navigation + message trust).
404
+ private var expectedOrigins: [String: String] = [:]
405
+ // Per-instance handlers. The navigation delegate is held weakly by WKWebView,
406
+ // so it must be retained here; the message handler is retained by the
407
+ // userContentController but is stored for symmetric teardown.
408
+ private var messageBridges: [String: MessageBridge] = [:]
409
+ private var navigationBridges: [String: NavigationBridge] = [:]
410
+
411
+ @objc public override init() {
412
+ super.init()
413
+ }
414
+
415
+ fileprivate func deliverMessage(instanceId: String, body: Any) {
416
+ delegate?.didReceiveMessage(forInstanceId: instanceId, data: body)
417
+ }
418
+
419
+ // `internal` (not `fileprivate`) so the reason-mapping wiring can be driven
420
+ // directly in unit tests after registering a pending completion.
421
+ func handleDidCommit(instanceId: String) {
422
+ pending.settle(instanceId: instanceId, error: nil)
423
+ }
424
+
425
+ func handleDidFail(instanceId: String, error: Error) {
426
+ // `settle` is a no-op when an HTTP-error policy cancel already consumed the
427
+ // completion, so this follow-up failure event cannot double-resolve.
428
+ let nsError = error as NSError
429
+ pending.settle(instanceId: instanceId, error: makeError(
430
+ reason: Reason.navigationFailed,
431
+ message: nsError.localizedDescription,
432
+ underlyingDescription: nsError.localizedDescription
433
+ ))
434
+ }
435
+
436
+ func handleHttpError(instanceId: String, statusCode: Int) {
437
+ pending.settle(instanceId: instanceId, error: makeError(
438
+ reason: Reason.httpError,
439
+ message: "HTTP \(statusCode)",
440
+ statusCode: statusCode
441
+ ))
442
+ }
443
+
444
+ func handleContentProcessTerminated(instanceId: String) {
445
+ pending.settle(instanceId: instanceId, error: makeError(
446
+ reason: Reason.contentProcessTerminated,
447
+ message: "WKWebView WebContent process terminated."
448
+ ))
449
+ }
450
+
451
+ @objc public func open(
452
+ instanceId: String,
453
+ url: String,
454
+ completion: @escaping (Error?) -> Void
455
+ ) {
456
+ DispatchQueue.main.async { [weak self] in
457
+ guard let self = self else { return }
458
+
459
+ if self.webViews[instanceId] != nil {
460
+ completion(makeError(
461
+ reason: Reason.instanceBusy,
462
+ message: "open called on an instanceId that already has an active WebView."
463
+ ))
464
+ return
465
+ }
466
+
467
+ // Reject non-https schemes (file://, about:, javascript:, http://, etc.).
468
+ // The WebView hosts WaaS/MPC content and must never load attacker-controlled
469
+ // local files or pseudo-URL handlers. A missing host is also rejected so a
470
+ // bare scheme like "https://" cannot slip through.
471
+ guard let parsedUrl = URL(string: url),
472
+ parsedUrl.scheme?.lowercased() == "https",
473
+ let host = parsedUrl.host, !host.isEmpty,
474
+ let expectedOrigin = originString(of: parsedUrl) else {
475
+ completion(makeError(
476
+ reason: Reason.invalidUrl,
477
+ message: "Invalid URL: \(url)"
478
+ ))
479
+ return
480
+ }
481
+
482
+ guard let hostView = self.findHostView() else {
483
+ completion(makeError(
484
+ reason: Reason.noHostView,
485
+ message: "No foreground window available to attach WebView."
486
+ ))
487
+ return
488
+ }
489
+
490
+ let config = WKWebViewConfiguration()
491
+ // Ephemeral, in-memory store: cookies/localStorage/IndexedDB written by
492
+ // the hosted page are scoped to this WKWebView and discarded on close.
493
+ // Prevents cross-WebView leakage of WaaS/MPC session state and survives
494
+ // neither app relaunch nor reinstall.
495
+ config.websiteDataStore = .nonPersistent()
496
+ // Dedicated process pool per instance isolates the WebContent process
497
+ // from any other WKWebView in the host app, blocking shared-process
498
+ // side channels (e.g. shared JIT heap, IPC ordering).
499
+ config.processPool = WKProcessPool()
500
+ let userScript = WKUserScript(
501
+ source: bridgeUserScript,
502
+ injectionTime: .atDocumentStart,
503
+ forMainFrameOnly: true
504
+ )
505
+ config.userContentController.addUserScript(userScript)
506
+
507
+ let messageBridge = MessageBridge(
508
+ instanceId: instanceId,
509
+ expectedOrigin: expectedOrigin,
510
+ owner: self
511
+ )
512
+ let navigationBridge = NavigationBridge(
513
+ instanceId: instanceId,
514
+ expectedOrigin: expectedOrigin,
515
+ owner: self
516
+ )
517
+ config.userContentController.add(messageBridge, name: bridgeHandlerName)
518
+
519
+ let webView = WKWebView(frame: .zero, configuration: config)
520
+ webView.navigationDelegate = navigationBridge
521
+ webView.isHidden = true
522
+ webView.isUserInteractionEnabled = false
523
+ // Enables Safari Web Inspector for debug builds (iOS 16.4+).
524
+ // Connect via Safari > Develop > [Device] > [WebView].
525
+ #if DEBUG
526
+ if #available(iOS 16.4, *) {
527
+ webView.isInspectable = true
528
+ }
529
+ #endif
530
+
531
+ hostView.addSubview(webView)
532
+ self.webViews[instanceId] = webView
533
+ self.expectedOrigins[instanceId] = expectedOrigin
534
+ self.messageBridges[instanceId] = messageBridge
535
+ self.navigationBridges[instanceId] = navigationBridge
536
+ self.pending.register(instanceId: instanceId, completion: completion)
537
+
538
+ webView.load(URLRequest(url: parsedUrl))
539
+ }
540
+ }
541
+
542
+ @objc public func sendMessage(instanceId: String, data: NSDictionary) {
543
+ DispatchQueue.main.async { [weak self] in
544
+ guard let self = self else { return }
545
+ guard let webView = self.webViews[instanceId] else { return }
546
+
547
+ // callAsyncJavaScript binds `payload` via WebKit's structured-clone
548
+ // argument map instead of interpolating the value into JS source.
549
+ // Eliminates the entire class of injection bugs that hand-rolled
550
+ // escaping (U+2028 / U+2029 line terminators, quote breakouts, etc.)
551
+ // is exposed to. The iframe contract is fire-and-forget, so the
552
+ // completion is intentionally dropped.
553
+ webView.callAsyncJavaScript(
554
+ "if (window.__dynamicReceive) { window.__dynamicReceive(payload); }",
555
+ arguments: ["payload": data],
556
+ in: nil,
557
+ in: .page,
558
+ completionHandler: nil
559
+ )
560
+ }
561
+ }
562
+
563
+ @objc public func close(instanceId: String) {
564
+ DispatchQueue.main.async { [weak self] in
565
+ guard let self = self else { return }
566
+
567
+ // A close while `open` is pending means the caller intentionally aborted
568
+ // the load. Reject the pending completion so the awaiter doesn't hang.
569
+ // `settle` is a no-op if the load already settled.
570
+ self.pending.settle(instanceId: instanceId, error: makeError(
571
+ reason: Reason.cancelled,
572
+ message: "WebView was closed before the load settled."
573
+ ))
574
+
575
+ self.expectedOrigins.removeValue(forKey: instanceId)
576
+ self.messageBridges.removeValue(forKey: instanceId)
577
+ self.navigationBridges.removeValue(forKey: instanceId)
578
+
579
+ guard let webView = self.webViews.removeValue(forKey: instanceId) else { return }
580
+
581
+ webView.stopLoading()
582
+ webView.navigationDelegate = nil
583
+ webView.configuration.userContentController.removeScriptMessageHandler(forName: bridgeHandlerName)
584
+ webView.configuration.userContentController.removeAllUserScripts()
585
+ webView.removeFromSuperview()
586
+ }
587
+ }
588
+
589
+ // MARK: - Helpers
590
+
591
+ private func findHostView() -> UIView? {
592
+ let scenes = UIApplication.shared.connectedScenes
593
+ .compactMap { $0 as? UIWindowScene }
594
+
595
+ let activeScene = scenes.first(where: { $0.activationState == .foregroundActive })
596
+ ?? scenes.first
597
+
598
+ let window = activeScene?.windows.first(where: { $0.isKeyWindow })
599
+ ?? activeScene?.windows.first
600
+
601
+ return window?.rootViewController?.view ?? window
602
+ }
603
+ }
@@ -0,0 +1,86 @@
1
+ import Foundation
2
+ import Security
3
+ import CryptoKit
4
+
5
+ /// Production implementation of `SecKeyAPI` — each method is a thin
6
+ /// passthrough to the real Security/CryptoKit C call.
7
+ ///
8
+ /// This file is excluded from coverage measurement
9
+ /// (`sonar.coverage.exclusions`) because every method is a one-line
10
+ /// passthrough that cannot be exercised on the iOS Simulator. The
11
+ /// behaviour these calls perform is verified manually on a real
12
+ /// device before each release.
13
+ final class LiveSecKeyAPI: SecKeyAPI {
14
+ func isSecureEnclaveAvailable() -> Bool {
15
+ if #available(iOS 13.0, *) {
16
+ return SecureEnclave.isAvailable
17
+ }
18
+ return false
19
+ }
20
+
21
+ func itemCopyMatching(query: CFDictionary) -> (status: OSStatus, item: CFTypeRef?) {
22
+ var item: CFTypeRef?
23
+ let status = SecItemCopyMatching(query, &item)
24
+ return (status, item)
25
+ }
26
+
27
+ func itemDelete(query: CFDictionary) -> OSStatus {
28
+ return SecItemDelete(query)
29
+ }
30
+
31
+ func createAccessControl(
32
+ protection: CFTypeRef,
33
+ flags: SecAccessControlCreateFlags
34
+ ) -> SecAccessControl? {
35
+ return SecAccessControlCreateWithFlags(kCFAllocatorDefault, protection, flags, nil)
36
+ }
37
+
38
+ func createRandomKey(attributes: CFDictionary) throws -> SecKey {
39
+ var error: Unmanaged<CFError>?
40
+ guard let key = SecKeyCreateRandomKey(attributes, &error) else {
41
+ throw LiveSecKeyAPI.error(from: error)
42
+ }
43
+ return key
44
+ }
45
+
46
+ func copyPublicKey(_ key: SecKey) -> SecKey? {
47
+ return SecKeyCopyPublicKey(key)
48
+ }
49
+
50
+ func createSignature(
51
+ key: SecKey,
52
+ algorithm: SecKeyAlgorithm,
53
+ data: CFData
54
+ ) throws -> Data {
55
+ var error: Unmanaged<CFError>?
56
+ guard let signature = SecKeyCreateSignature(key, algorithm, data, &error) else {
57
+ throw LiveSecKeyAPI.error(from: error)
58
+ }
59
+ return signature as Data
60
+ }
61
+
62
+ func copyExternalRepresentation(_ key: SecKey) throws -> Data {
63
+ var error: Unmanaged<CFError>?
64
+ guard let data = SecKeyCopyExternalRepresentation(key, &error) else {
65
+ throw LiveSecKeyAPI.error(from: error)
66
+ }
67
+ return data as Data
68
+ }
69
+
70
+ // Mirrors the string-interpolation form the original code used so
71
+ // that the NSError's localizedDescription remains byte-identical to
72
+ // pre-refactor output.
73
+ private static func error(from cfError: Unmanaged<CFError>?) -> NSError {
74
+ let description: String
75
+ if let cfError = cfError {
76
+ description = "\(cfError.takeRetainedValue())"
77
+ } else {
78
+ description = "Unknown"
79
+ }
80
+ return NSError(
81
+ domain: "SecKeyAPI",
82
+ code: 0,
83
+ userInfo: [NSLocalizedDescriptionKey: description]
84
+ )
85
+ }
86
+ }