From 5c3bbb8e3380f2e729b547b5ad1e4305e32272bf Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Wed, 16 Apr 2025 17:53:19 -0700 Subject: [PATCH 1/2] Add `privacy` opt to fetch function for NYM support --- CHANGELOG.md | 1 + .../reactnative/core/BundleHTTPServer.java | 298 ++++++++++++++ .../reactnative/core/EdgeCoreWebView.java | 32 +- edge-core-js.podspec | 3 +- ios/BundleHTTPServer.swift | 372 ++++++++++++++++++ ios/EdgeCoreWebView.swift | 52 ++- nym-to-core.md | 172 ++++++++ package.json | 2 + src/index.html | 9 + src/io/browser/browser-io.ts | 9 +- src/io/react-native/react-native-worker.ts | 51 ++- src/types/types.ts | 1 + src/util/nym.ts | 120 ++++++ tsconfig.json | 2 + webpack.config.js | 60 ++- yarn.lock | 58 +++ 16 files changed, 1193 insertions(+), 49 deletions(-) create mode 100644 android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java create mode 100644 ios/BundleHTTPServer.swift create mode 100644 nym-to-core.md create mode 100644 src/index.html create mode 100644 src/util/nym.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e273da4b1..682ca5129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - added: Added change-server subscription timeout fallback. +- added: New `privacy` option argument to EdgeIo fetch function for NYM support. ## 2.39.0 (2026-01-16) diff --git a/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java b/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java new file mode 100644 index 000000000..8f362ee2d --- /dev/null +++ b/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java @@ -0,0 +1,298 @@ +package app.edge.reactnative.core; + +import android.content.Context; +import android.content.res.AssetManager; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A simple HTTP server that serves files from the Android assets folder. + * Includes Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers + * required for SharedArrayBuffer support (needed by mixFetch web workers). + */ +class BundleHTTPServer { + private static final String TAG = "BundleHTTPServer"; + private static final int DEFAULT_PORT = 3693; + + private final Context mContext; + private final int mPort; + private ServerSocket mServerSocket; + private ExecutorService mExecutor; + private final AtomicBoolean mRunning = new AtomicBoolean(false); + + public BundleHTTPServer(Context context) { + this(context, DEFAULT_PORT); + } + + public BundleHTTPServer(Context context, int port) { + mContext = context; + mPort = port; + } + + public void start() { + if (mRunning.get()) { + return; + } + + mExecutor = Executors.newCachedThreadPool(); + mRunning.set(true); + + new Thread(() -> { + try { + mServerSocket = new ServerSocket(mPort); + Log.d(TAG, "HTTP Server ready on localhost:" + mPort); + + while (mRunning.get()) { + try { + Socket clientSocket = mServerSocket.accept(); + mExecutor.execute(() -> handleConnection(clientSocket)); + } catch (IOException e) { + if (mRunning.get()) { + Log.e(TAG, "Error accepting connection: " + e.getMessage()); + } + } + } + } catch (IOException e) { + Log.e(TAG, "Failed to start HTTP server: " + e.getMessage()); + } + }).start(); + } + + public void stop() { + mRunning.set(false); + + if (mServerSocket != null) { + try { + mServerSocket.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing server socket: " + e.getMessage()); + } + mServerSocket = null; + } + + if (mExecutor != null) { + mExecutor.shutdown(); + mExecutor = null; + } + + Log.d(TAG, "HTTP Server stopped"); + } + + private void handleConnection(Socket clientSocket) { + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + OutputStream output = clientSocket.getOutputStream(); + + // Read the request line + String requestLine = reader.readLine(); + if (requestLine == null || requestLine.isEmpty()) { + clientSocket.close(); + return; + } + + // Parse method and path + String[] parts = requestLine.split(" "); + if (parts.length < 2) { + sendResponse(output, 400, "Bad Request", "text/plain", "Bad Request".getBytes()); + clientSocket.close(); + return; + } + + String method = parts[0]; + String path = parts[1]; + + // Read and discard headers + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + // Just consume headers + } + + // Only support GET + if (!method.equals("GET")) { + sendResponse(output, 405, "Method Not Allowed", "text/plain", "Method Not Allowed".getBytes()); + clientSocket.close(); + return; + } + + // Remove query parameters + int queryIndex = path.indexOf('?'); + if (queryIndex > 0) { + path = path.substring(0, queryIndex); + } + + // Serve index.html for root path + if (path.equals("/")) { + path = "/index.html"; + } + + // Handle plugin bundle requests (e.g., /plugin/edge-currency-accountbased.bundle/edge-currency-accountbased.js) + if (path.startsWith("/plugin/")) { + String pluginPath = path.substring("/plugin/".length()); + servePluginFile(output, pluginPath); + clientSocket.close(); + return; + } + + // Remove leading slash and prepend assets path + String assetPath = "edge-core-js" + path; + + // Try to read the asset + try { + AssetManager assetManager = mContext.getAssets(); + InputStream inputStream = assetManager.open(assetPath); + byte[] data = readAllBytes(inputStream); + inputStream.close(); + + String mimeType = getMimeType(path); + sendResponse(output, 200, "OK", mimeType, data); + } catch (IOException e) { + Log.d(TAG, "File not found: " + assetPath); + sendResponse(output, 404, "Not Found", "text/plain", "Not Found".getBytes()); + } + + clientSocket.close(); + } catch (IOException e) { + Log.e(TAG, "Error handling connection: " + e.getMessage()); + try { + clientSocket.close(); + } catch (IOException ignored) { + } + } + } + + private void servePluginFile(OutputStream output, String pluginPath) throws IOException { + AssetManager assetManager = mContext.getAssets(); + byte[] data = null; + + // Plugin path format: "edge-currency-accountbased.bundle/edge-currency-accountbased.js" + // or just: "plugin-bundle.js" + // Try multiple asset path patterns + String[] pathsToTry; + + if (pluginPath.contains(".bundle/")) { + // Extract bundle name and file name + String[] parts = pluginPath.split("\\.bundle/"); + if (parts.length >= 2) { + String bundleName = parts[0]; + String fileName = parts[1]; + pathsToTry = new String[] { + pluginPath, // As-is + bundleName + "/" + fileName, // Without .bundle + bundleName + ".bundle/" + fileName, // With .bundle as folder + fileName // Just the filename + }; + } else { + pathsToTry = new String[] { pluginPath }; + } + } else { + // Just a filename like "plugin-bundle.js" + // Try to find it in a corresponding .bundle folder first + String fileName = pluginPath; + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex > 0) { + String baseName = fileName.substring(0, dotIndex); + pathsToTry = new String[] { + baseName + ".bundle/" + fileName, // e.g., plugin-bundle.bundle/plugin-bundle.js + baseName + "/" + fileName, // e.g., plugin-bundle/plugin-bundle.js + pluginPath // Just the filename + }; + } else { + pathsToTry = new String[] { pluginPath }; + } + } + + for (String assetPath : pathsToTry) { + try { + InputStream inputStream = assetManager.open(assetPath); + data = readAllBytes(inputStream); + inputStream.close(); + Log.d(TAG, "Found plugin at: " + assetPath); + break; + } catch (IOException e) { + // Try next path + Log.d(TAG, "Plugin not found at: " + assetPath); + } + } + + if (data != null) { + String mimeType = getMimeType(pluginPath); + sendResponse(output, 200, "OK", mimeType, data); + } else { + Log.e(TAG, "Plugin file not found: " + pluginPath); + sendResponse(output, 404, "Not Found", "text/plain", ("Not Found: " + pluginPath).getBytes()); + } + } + + private void sendResponse(OutputStream output, int code, String status, String contentType, byte[] body) + throws IOException { + StringBuilder headers = new StringBuilder(); + headers.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n"); + headers.append("Content-Type: ").append(contentType).append("\r\n"); + headers.append("Content-Length: ").append(body.length).append("\r\n"); + headers.append("Connection: close\r\n"); + headers.append("Server: EdgeCoreBundleServer/1.0\r\n"); + // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) + headers.append("Cross-Origin-Opener-Policy: same-origin\r\n"); + headers.append("Cross-Origin-Embedder-Policy: require-corp\r\n"); + headers.append("\r\n"); + + output.write(headers.toString().getBytes("UTF-8")); + output.write(body); + output.flush(); + } + + private byte[] readAllBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(chunk)) != -1) { + buffer.write(chunk, 0, bytesRead); + } + return buffer.toByteArray(); + } + + private String getMimeType(String path) { + String lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) { + return "text/html"; + } else if (lowerPath.endsWith(".js")) { + return "application/javascript"; + } else if (lowerPath.endsWith(".css")) { + return "text/css"; + } else if (lowerPath.endsWith(".json")) { + return "application/json"; + } else if (lowerPath.endsWith(".png")) { + return "image/png"; + } else if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) { + return "image/jpeg"; + } else if (lowerPath.endsWith(".gif")) { + return "image/gif"; + } else if (lowerPath.endsWith(".svg")) { + return "image/svg+xml"; + } else if (lowerPath.endsWith(".woff")) { + return "font/woff"; + } else if (lowerPath.endsWith(".woff2")) { + return "font/woff2"; + } else if (lowerPath.endsWith(".ttf")) { + return "font/ttf"; + } else if (lowerPath.endsWith(".wasm")) { + return "application/wasm"; + } else if (lowerPath.endsWith(".xml")) { + return "application/xml"; + } else if (lowerPath.endsWith(".txt")) { + return "text/plain"; + } + return "application/octet-stream"; + } +} diff --git a/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java b/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java index 8b64bbc32..b01081f26 100644 --- a/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java +++ b/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java @@ -11,9 +11,10 @@ import org.json.JSONArray; class EdgeCoreWebView extends WebView { - private static final String BASE_URL = "file:///android_asset/"; + private static final String BASE_URL = "http://localhost:3693/"; private final ThemedReactContext mContext; private final EdgeNative mNative; + private BundleHTTPServer mHttpServer; // react api-------------------------------------------------------------- @@ -41,6 +42,10 @@ public EdgeCoreWebView(ThemedReactContext context) { mContext = context; mNative = new EdgeNative(mContext.getFilesDir()); + // Start the HTTP server for serving assets with COOP/COEP headers + mHttpServer = new BundleHTTPServer(context); + mHttpServer.start(); + getSettings().setAllowFileAccess(true); getSettings().setJavaScriptEnabled(true); setWebViewClient(new Client()); @@ -50,6 +55,13 @@ public EdgeCoreWebView(ThemedReactContext context) { @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); + + // Stop the HTTP server when view is detached + if (mHttpServer != null) { + mHttpServer.stop(); + mHttpServer = null; + } + destroy(); } @@ -58,7 +70,8 @@ protected void onDetachedFromWindow() { class Client extends WebViewClient { @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { - if (!BASE_URL.equals(url)) visitPage(); + // Only reload if navigating away from our server + if (url != null && !url.startsWith(BASE_URL)) visitPage(); } } @@ -90,18 +103,9 @@ public void scriptError(String source) { // utilities ------------------------------------------------------------- private void visitPage() { - String source = mSource == null ? BASE_URL + "edge-core-js/edge-core.js" : mSource; - String html = - "" - + "" - + "edge-core-js" - + "" - + ""; - loadDataWithBaseURL(BASE_URL, html, "text/html", null, null); + // Load the page from the HTTP server to get COOP/COEP headers + // which are required for SharedArrayBuffer support (needed by mixFetch web workers) + loadUrl(BASE_URL + "index.html"); } private class WebViewPromise implements PendingCall { diff --git a/edge-core-js.podspec b/edge-core-js.podspec index bbe24aca8..29aeb8825 100644 --- a/edge-core-js.podspec +++ b/edge-core-js.podspec @@ -24,6 +24,7 @@ Pod::Spec.new do |s| "android/src/main/cpp/scrypt/sysendian.h", "ios/Disklet.swift", "ios/edge-core-js-Bridging-Header.h", + "ios/BundleHTTPServer.swift", "ios/EdgeCoreWebView.swift", "ios/EdgeCoreWebViewManager.m", "ios/EdgeCoreWebViewManager.swift", @@ -31,7 +32,7 @@ Pod::Spec.new do |s| "ios/PendingCall.swift" s.resource_bundles = { - "edge-core-js" => "android/src/main/assets/edge-core-js/edge-core.js" + "edge-core-js" => "android/src/main/assets/edge-core-js/*" } s.dependency "React-Core" diff --git a/ios/BundleHTTPServer.swift b/ios/BundleHTTPServer.swift new file mode 100644 index 000000000..9a41bc7a9 --- /dev/null +++ b/ios/BundleHTTPServer.swift @@ -0,0 +1,372 @@ +import Foundation +import Network + +class BundleHTTPServer { + private var listener: NWListener? + private let port: UInt16 + private let queue = DispatchQueue(label: "com.edge.bundleserver") + + init(port: UInt16 = 3693) { + self.port = port + } + + func start() { + do { + let port = NWEndpoint.Port(integerLiteral: self.port) + listener = try NWListener(using: .tcp, on: port) + + listener?.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("HTTP Server ready on localhost:\(self?.port ?? 0)") + case .failed(let error): + print("HTTP Server failed with error: \(error)") + case .cancelled: + print("HTTP Server was cancelled") + default: + break + } + } + + listener?.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection) + } + + listener?.start(queue: queue) + } catch { + print("Failed to start HTTP server: \(error)") + } + } + + func stop() { + listener?.cancel() + } + + private func handleConnection(_ connection: NWConnection) { + connection.stateUpdateHandler = { state in + switch state { + case .ready: + self.receiveRequest(on: connection) + case .failed(let error): + print("Connection failed: \(error)") + connection.cancel() + case .cancelled: + break + default: + break + } + } + + connection.start(queue: queue) + } + + private func receiveRequest(on connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 2048) { [weak self] content, _, isComplete, error in + guard let self = self else { return } + + if let error = error { + print("Error receiving request: \(error)") + connection.cancel() + return + } + + guard let content = content, !content.isEmpty else { + if isComplete { + connection.cancel() + } + return + } + + // Parse the request + if let requestString = String(data: content, encoding: .utf8) { + let requestLines = requestString.components(separatedBy: "\r\n") + if let firstLine = requestLines.first { + let components = firstLine.components(separatedBy: " ") + if components.count >= 2 { + let method = components[0] + var path = components[1] + + // Remove query parameters if any + if let queryStart = path.firstIndex(of: "?") { + path = String(path[..= 2 { + let filename = components[0] + let fileExtension = components[1] + url = bundle.url(forResource: filename, withExtension: fileExtension) + } + } else { + // Otherwise, try to find the resource without an extension + url = bundle.url(forResource: path, withExtension: nil) + } + } + + // If not found in the bundle, check the main bundle directly + if url == nil { + let components = path.components(separatedBy: ".") + if components.count >= 2 { + let filename = components[0] + let fileExtension = components[1] + url = Bundle.main.url(forResource: filename, withExtension: fileExtension) + } else { + url = Bundle.main.url(forResource: path, withExtension: nil) + } + } + + if let url = url { + do { + data = try Data(contentsOf: url) + } catch { + print("Error reading file: \(error)") + } + } + + guard let fileData = data else { + sendResponse(code: 404, body: "Not Found", connection: connection) + return + } + + let mimeType = mimeTypeForPath(path) + let headers = [ + "HTTP/1.1 200 OK", + "Content-Type: \(mimeType)", + "Content-Length: \(fileData.count)", + "Connection: close", + "Server: EdgeCoreBundleServer/1.0", + // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) + "Cross-Origin-Opener-Policy: same-origin", + "Cross-Origin-Embedder-Policy: require-corp", + "\r\n" + ].joined(separator: "\r\n") + + let headerData = headers.data(using: .utf8)! + let responseData = NSMutableData() + responseData.append(headerData) + responseData.append(fileData) + + connection.send(content: responseData as Data, completion: .contentProcessed { error in + if let error = error { + print("Error sending response: \(error)") + } + connection.cancel() + }) + } + + private func servePluginFile(_ path: String, method: String, connection: NWConnection) { + // Only support GET requests + guard method == "GET" else { + sendResponse(code: 405, body: "Method Not Allowed", connection: connection) + return + } + + // Get the app's main bundle path - plugins are in edge-core/ subdirectory + let bundlePath = Bundle.main.bundlePath + let edgeCorePath = (bundlePath as NSString).appendingPathComponent("edge-core") + var data: Data? + + // Try multiple path patterns + let pathsToTry: [String] + + if path.contains(".bundle/") { + // Path like: "edge-currency-accountbased.bundle/edge-currency-accountbased.js" + pathsToTry = [path] + } else { + // Path like: "plugin-bundle.js" - try with .bundle folder too + let fileName = (path as NSString).lastPathComponent + let baseName = (fileName as NSString).deletingPathExtension + pathsToTry = [ + path, // plugin-bundle.js + "\(baseName).bundle/\(fileName)" // plugin-bundle.bundle/plugin-bundle.js + ] + } + + for relativePath in pathsToTry { + // Try edge-core/ subdirectory first (for plugin-bundle.js) + let edgeCoreFull = (edgeCorePath as NSString).appendingPathComponent(relativePath) + if FileManager.default.fileExists(atPath: edgeCoreFull) { + do { + data = try Data(contentsOf: URL(fileURLWithPath: edgeCoreFull)) + print("Found plugin file at: \(edgeCoreFull)") + break + } catch { + print("Error reading file at \(edgeCoreFull): \(error)") + } + } + + // Fall back to app bundle root (for .bundle/ plugins) + if data == nil { + let rootFull = (bundlePath as NSString).appendingPathComponent(relativePath) + if FileManager.default.fileExists(atPath: rootFull) { + do { + data = try Data(contentsOf: URL(fileURLWithPath: rootFull)) + print("Found plugin file at: \(rootFull)") + break + } catch { + print("Error reading file at \(rootFull): \(error)") + } + } + } + } + + guard let fileData = data else { + print("Plugin file not found: \(path)") + sendResponse(code: 404, body: "Not Found: \(path)", connection: connection) + return + } + + let mimeType = mimeTypeForPath(path) + let headers = [ + "HTTP/1.1 200 OK", + "Content-Type: \(mimeType)", + "Content-Length: \(fileData.count)", + "Connection: close", + "Server: EdgeCoreBundleServer/1.0", + // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) + "Cross-Origin-Opener-Policy: same-origin", + "Cross-Origin-Embedder-Policy: require-corp", + "\r\n" + ].joined(separator: "\r\n") + + let headerData = headers.data(using: .utf8)! + let responseData = NSMutableData() + responseData.append(headerData) + responseData.append(fileData) + + connection.send(content: responseData as Data, completion: .contentProcessed { error in + if let error = error { + print("Error sending plugin response: \(error)") + } + connection.cancel() + }) + } + + private func sendResponse(code: Int, body: String, connection: NWConnection) { + var status = "" + switch code { + case 200: status = "OK" + case 400: status = "Bad Request" + case 404: status = "Not Found" + case 405: status = "Method Not Allowed" + default: status = "Internal Server Error" + } + + let bodyData = body.data(using: .utf8)! + let headers = [ + "HTTP/1.1 \(code) \(status)", + "Content-Type: text/plain", + "Content-Length: \(bodyData.count)", + "Connection: close", + "Server: EdgeCoreBundleServer/1.0", + // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) + "Cross-Origin-Opener-Policy: same-origin", + "Cross-Origin-Embedder-Policy: require-corp", + "\r\n" + ].joined(separator: "\r\n") + + let headerData = headers.data(using: .utf8)! + let responseData = NSMutableData() + responseData.append(headerData) + responseData.append(bodyData) + + connection.send(content: responseData as Data, completion: .contentProcessed { error in + if let error = error { + print("Error sending response: \(error)") + } + connection.cancel() + }) + } + + private func sendHtmlResponse(html: String, connection: NWConnection) { + let bodyData = html.data(using: .utf8)! + let headers = [ + "HTTP/1.1 200 OK", + "Content-Type: text/html", + "Content-Length: \(bodyData.count)", + "Connection: close", + "Server: EdgeCoreBundleServer/1.0", + // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) + "Cross-Origin-Opener-Policy: same-origin", + "Cross-Origin-Embedder-Policy: require-corp", + "\r\n" + ].joined(separator: "\r\n") + + let headerData = headers.data(using: .utf8)! + let responseData = NSMutableData() + responseData.append(headerData) + responseData.append(bodyData) + + connection.send(content: responseData as Data, completion: .contentProcessed { error in + if let error = error { + print("Error sending response: \(error)") + } + connection.cancel() + }) + } + + private func mimeTypeForPath(_ path: String) -> String { + let ext = (path as NSString).pathExtension.lowercased() + + switch ext { + case "html", "htm": return "text/html" + case "js": return "application/javascript" + case "css": return "text/css" + case "json": return "application/json" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "svg": return "image/svg+xml" + case "woff": return "font/woff" + case "woff2": return "font/woff2" + case "ttf": return "font/ttf" + case "wasm": return "application/wasm" + case "xml": return "application/xml" + case "txt": return "text/plain" + default: return "application/octet-stream" + } + } +} diff --git a/ios/EdgeCoreWebView.swift b/ios/EdgeCoreWebView.swift index 642f30a7a..77214cd5c 100644 --- a/ios/EdgeCoreWebView.swift +++ b/ios/EdgeCoreWebView.swift @@ -1,9 +1,12 @@ import WebKit +import Foundation +import Network class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { var native = EdgeNative() var webView: WKWebView? - + private var httpServer: BundleHTTPServer? + // react api-------------------------------------------------------------- @objc var onMessage: RCTDirectEventBlock? @@ -36,6 +39,11 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { override init(frame: CGRect) { super.init(frame: frame) + // Start the HTTP server + let server = BundleHTTPServer(port: 3693) + server.start() + self.httpServer = server + // Set up our native bridge: let configuration = WKWebViewConfiguration() configuration.userContentController = WKUserContentController() @@ -64,6 +72,10 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { webView.removeFromSuperview() self.webView = nil } + + // Stop the HTTP server when view is removed + httpServer?.stop() + httpServer = nil } // callbacks ------------------------------------------------------------- @@ -127,16 +139,7 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { } func defaultSource() -> String? { - if let bundleUrl = Bundle.main.url( - forResource: "edge-core-js", - withExtension: "bundle" - ), - let bundle = Bundle(url: bundleUrl), - let script = bundle.url(forResource: "edge-core", withExtension: "js") - { - return script.absoluteString - } - return nil + return "http://localhost:3693/edge-core.js" } func stringify(_ raw: Any?) -> String { @@ -154,22 +157,17 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { } func visitPage() { - if let src = source ?? defaultSource() { - webView?.loadHTMLString( - """ - - - edge-core-js - - - """, - baseURL: Bundle.main.bundleURL - ) + // If source is set to webpack dev server (debug mode), load from there + // Otherwise load from the local bundle HTTP server + let baseUrl: String + if let src = source, src.contains("localhost:8080") { + baseUrl = "http://localhost:8080/" + } else { + baseUrl = "http://localhost:3693/" } + + let url = URL(string: baseUrl)! + let request = URLRequest(url: url) + webView?.load(request) } } diff --git a/nym-to-core.md b/nym-to-core.md new file mode 100644 index 000000000..4404a7775 --- /dev/null +++ b/nym-to-core.md @@ -0,0 +1,172 @@ +# Add NYM mixFetch support to edge-core-js + +## Overview + +edge-core-js exposes io.fetch to various plugins which have an opts param that can specify args like corsBypass. + +Add a new opts param mixNet?: boolean which if true will use the NYM mixnet via mixFetch + +Use the nymtest repo as an example of how to implement mixFetch. + +Implement for ios and android (which run in a webview), browser, and nodejs + +Use latest version of mix-fetch + +Ok to change the WebViewAssetLoader approach system wide + +Ensure that the app at least successfull compiles with your changes. + +You can test changes with these steps + +cd edge-core-js +yarn prepare + +cd ../edge-react-gui +yarn updot edge-core-js + +yarn preapre +yarn prepare.ios +yarn ios + +## Type Changes + +Update `EdgeFetchOptions` in `src/types/types.ts`: + +```typescript +export type EdgeFetchOptions = FetchOptions & { + corsBypass?: 'auto' | 'always' | 'never' + mixNet?: boolean // Use NYM mixnet for this request +} +``` + +Update `EdgeIo` in `src/types/types.ts`: + +```typescript +export interface EdgeIo { + // ... existing props ... + + /** + * Initialize the NYM mixnet client. Browser/WebView only. + * Must be called before mixNet: true will work in fetch(). + * Safe to call multiple times (no-op after first success). + * Throws on non-browser platforms or if initialization fails. + */ + readonly startMixnet?: () => Promise +} +``` + +## Initialization + +Expose an io.startMixnet() which when called would import the wasm and start the webworker and call createMixFetch. + +Until createMixFetch returns, any call to io.fetch with `mixNet` set to true will be treated as if `mixNet` were false. + +## Build System + +Update the build system for edge-core-js so that the webworker and wasm files would be properly copied to the assets for ios and android and used when needed. + +Files to copy from `@nymproject/mix-fetch`: + +- `*.wasm` (WebAssembly modules) +- `web-worker-*.js` (Worker scripts) + +## Cross-Origin Isolation for Mobile WebViews + +mixFetch requires SharedArrayBuffer which needs cross-origin isolation. The WebView must serve content with these headers: + +- `Cross-Origin-Opener-Policy: same-origin` +- `Cross-Origin-Embedder-Policy: require-corp` + +Since edge-core-js WebViews use `loadHTMLString`/`loadDataWithBaseURL` (not HTTP), we need to intercept requests: + +### Android + +Use `WebViewAssetLoader` with `shouldInterceptRequest` to serve local assets with proper headers: + +```java +// In EdgeCoreWebView.java +import androidx.webkit.WebViewAssetLoader; + +WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder() + .addPathHandler("/assets/", new WebViewAssetLoader.AssetsPathHandler(context)) + .build(); + +setWebViewClient(new WebViewClient() { + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + WebResourceResponse response = assetLoader.shouldInterceptRequest(request.getUrl()); + if (response != null) { + Map headers = new HashMap<>(response.getResponseHeaders()); + headers.put("Cross-Origin-Opener-Policy", "same-origin"); + headers.put("Cross-Origin-Embedder-Policy", "require-corp"); + return new WebResourceResponse( + response.getMimeType(), + response.getEncoding(), + response.getStatusCode(), + response.getReasonPhrase(), + headers, + response.getData() + ); + } + return null; + } +}); +``` + +Also need to load via HTTPS scheme for cross-origin isolation to work: + +```java +// Change from file:// to https://appassets.androidplatform.net/ +loadUrl("https://appassets.androidplatform.net/assets/edge-core-js/edge-core.html"); +``` + +### iOS + +Use `WKURLSchemeHandler` to serve local content with proper headers: + +```swift +// Create custom URL scheme handler +class AssetSchemeHandler: NSObject, WKURLSchemeHandler { + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + // Load local asset and respond with COOP/COEP headers + let headers = [ + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Content-Type": "application/javascript" + ] + let response = HTTPURLResponse( + url: urlSchemeTask.request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: headers + )! + urlSchemeTask.didReceive(response) + // ... send asset data ... + } +} + +// Register in WKWebViewConfiguration +configuration.setURLSchemeHandler(AssetSchemeHandler(), forURLScheme: "edge-assets") +``` + +## Configuration + +Use these init options: + +```typescript +const mixFetchOptions: SetupMixFetchOps = { + preferredGateway: '2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW', // with WSS + // preferredNetworkRequester: + // "CTDxrcXgrZHWyCWnuCgjpJPghQUcEVz1HkhUr5mGdFnT.3UAww1YWNyVNYNWFQL1LaHYouQtDiXBGK5GiDZgpXkTK@2RFtU5BwxvJJXagAWAEuaPgb5ZVPRoy2542TT93Edw6v", + mixFetchOverride: { + requestTimeoutMs: 10_000 + }, + clientOverride: { + traffic: { + averagePacketDelayMs: 1, + messageSendingAverageDelayMs: 1 + } + }, + forceTls: true // force WSS +} +``` diff --git a/package.json b/package.json index df3c5c833..ada99d674 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "*.{js,jsx,ts,tsx}": "eslint" }, "dependencies": { + "@nymproject/mix-fetch": "^1.4.1", "aes-js": "^3.1.0", "base-x": "^4.0.0", "biggystring": "^4.2.3", @@ -125,6 +126,7 @@ "babel-plugin-transform-fake-error-class": "^1.0.0", "buffer": "^6.0.3", "chai": "^4.2.0", + "copy-webpack-plugin": "^13.0.1", "eslint": "^7.32.0", "eslint-config-standard-kit": "0.15.1", "eslint-plugin-flowtype": "^5.2.0", diff --git a/src/index.html b/src/index.html new file mode 100644 index 000000000..a01299610 --- /dev/null +++ b/src/index.html @@ -0,0 +1,9 @@ + + + + + edge-core-js + + + + diff --git a/src/io/browser/browser-io.ts b/src/io/browser/browser-io.ts index efe24f543..628331da3 100644 --- a/src/io/browser/browser-io.ts +++ b/src/io/browser/browser-io.ts @@ -2,6 +2,7 @@ import { makeLocalStorageDisklet } from 'disklet' import { EdgeFetchOptions, EdgeFetchResponse, EdgeIo } from '../../types/types' import { scrypt } from '../../util/crypto/scrypt' +import { queuedMixFetch } from '../../util/nym' import { fetchCorsProxy } from './fetch-cors-proxy' // Only try CORS proxy/bridge techniques up to 5 times @@ -43,8 +44,14 @@ export function makeBrowserIo(): EdgeIo { uri: string, opts?: EdgeFetchOptions ): Promise { - const { corsBypass = 'auto' } = opts ?? {} + const { corsBypass = 'auto', privacy = 'none' } = opts ?? {} + if (privacy === 'nym') { + return await queuedMixFetch(uri, { + ...opts, + mode: 'unsafe-ignore-cors' as RequestMode + }) + } if (corsBypass === 'always') { return await fetchCorsProxy(uri, opts) } diff --git a/src/io/react-native/react-native-worker.ts b/src/io/react-native/react-native-worker.ts index 4b691510a..f565375b1 100644 --- a/src/io/react-native/react-native-worker.ts +++ b/src/io/react-native/react-native-worker.ts @@ -16,6 +16,7 @@ import { EdgeFetchResponse, EdgeIo } from '../../types/types' +import { initMixFetch, queuedMixFetch } from '../../util/nym' import { hideProperties } from '../hidden-properties' import { makeNativeBridge } from './native-bridge' import { WorkerApi, YAOB_THROTTLE_MS } from './react-native-types' @@ -71,6 +72,35 @@ window.addEdgeCorePlugins = addEdgeCorePlugins window.nativeBridge = nativeBridge window.reactBridge = reactBridge +/** + * Convert a file:// URI to a domain-relative path served by our local bundle server. + * This is needed because cross-origin isolation (COOP/COEP) blocks file:// loads. + * Using domain-relative paths ensures plugins load from the same origin as the page + * (localhost:3693 in production, localhost:8080 in debug mode). + */ +function convertPluginUri(uri: string): string { + // If already an HTTP URI, use as-is (e.g., custom bundles from metro bundler) + if (uri.startsWith('http://') || uri.startsWith('https://')) { + return uri + } + + // Convert file:// URIs to domain-relative paths + // Extract the bundle path (e.g., "edge-currency-accountbased.bundle/edge-currency-accountbased.js") + const bundleMatch = uri.match(/([^/]+\.bundle\/[^/]+\.js)$/) + if (bundleMatch != null) { + return `/plugin/${bundleMatch[1]}` + } + + // Fallback: try to extract just the filename + const fileMatch = uri.match(/([^/]+\.js)$/) + if (fileMatch != null) { + return `/plugin/${fileMatch[1]}` + } + + // If we can't parse it, return as-is (will likely fail, but provides debugging info) + return uri +} + function loadPlugins(pluginUris: string[]): void { const { head } = window.document if (head == null || pluginUris.length === 0) { @@ -89,7 +119,7 @@ function loadPlugins(pluginUris: string[]): void { script.addEventListener('load', handleLoad) script.charset = 'utf-8' script.defer = true - script.src = uri + script.src = convertPluginUri(uri) head.appendChild(script) } } @@ -170,8 +200,25 @@ async function makeIo(): Promise { uri: string, opts?: EdgeFetchOptions ): Promise { - const { corsBypass = 'auto' } = opts ?? {} + const { corsBypass = 'auto', privacy = 'none' } = opts ?? {} + // temporarily enable mixFetch always for development purposes + if (privacy === 'nym') { + try { + // Ensure mixFetch is initialized before use + await initMixFetch() + // Use queued fetch to handle mixFetch's one-request-per-host limitation + const response = await queuedMixFetch(uri, { + ...opts, + mode: 'unsafe-ignore-cors' as RequestMode + }) + console.warn('mixFetch response successful:', uri) + return response + } catch (error) { + console.error('mixFetch error:', error) + throw error + } + } if (corsBypass === 'always') { return await nativeFetch(uri, opts) } diff --git a/src/types/types.ts b/src/types/types.ts index 78619091b..08d2b8402 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -50,6 +50,7 @@ export type EdgeScryptFunction = ( // The subset of the fetch function Edge expects: export type EdgeFetchOptions = FetchOptions & { + privacy?: 'none' | 'nym' corsBypass?: 'auto' | 'always' | 'never' } export type EdgeFetchHeaders = FetchHeaders diff --git a/src/util/nym.ts b/src/util/nym.ts new file mode 100644 index 000000000..e452085d3 --- /dev/null +++ b/src/util/nym.ts @@ -0,0 +1,120 @@ +import { + createMixFetch, + mixFetch, + SetupMixFetchOps +} from '@nymproject/mix-fetch' + +/** + * Configuration options for the NYM mixFetch client. + */ +export const mixFetchOptions: SetupMixFetchOps = { + preferredGateway: '5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8', // with WSS + preferredNetworkRequester: + '5x6q9UfVHs5AohKMUqeivj7a556kVVy7QwoKige8xHxh.6CFoB3kJaDbYz6oafPJxNxNjzahpT2NtgtytcSyN9EvF@5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8', + mixFetchOverride: { + requestTimeoutMs: 60_000 + }, + forceTls: true, // force WSS + extra: {} +} + +// MixFetch initialization state +let mixFetchInitPromise: Promise | null = null +let mixFetchReady = false +let mixFetchError: Error | null = null + +// Per-host request queue to handle mixFetch's one-request-per-host limitation +// Maps host -> Promise that resolves when current request completes (for chaining) +const hostRequestChains = new Map>() + +/** + * Extract the host:port from a URI for queue keying + */ +function getHostKey(uri: string): string { + try { + const url = new URL(uri) + const port = + url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80' + return `${url.hostname}:${port}` + } catch { + return uri + } +} + +/** + * Initialize the NYM mixFetch client. Must be called before using mixFetch. + * Safe to call multiple times - subsequent calls return the same promise. + */ +export async function initMixFetch(): Promise { + // Return existing promise if already initializing + if (mixFetchInitPromise != null) { + return await mixFetchInitPromise + } + + // Already initialized successfully + if (mixFetchReady) { + return + } + + // Previous initialization failed - throw the cached error + if (mixFetchError != null) { + throw mixFetchError + } + + mixFetchInitPromise = (async () => { + try { + console.log('Initializing mixFetch...') + await createMixFetch(mixFetchOptions) + mixFetchReady = true + console.log('mixFetch initialized successfully') + } catch (error) { + mixFetchError = error as Error + console.error('mixFetch initialization failed:', error) + throw error + } + })() + + return await mixFetchInitPromise +} + +/** + * Queue-wrapped mixFetch that serializes requests per host. + * mixFetch only allows one concurrent request per host, so we chain them. + * Key insight: Store our chain promise BEFORE awaiting the previous one. + */ +export async function queuedMixFetch( + uri: string, + opts: RequestInit & { mode?: string } +): Promise { + const hostKey = getHostKey(uri) + + // Get the current chain for this host (or resolved promise if none) + const previousChain = hostRequestChains.get(hostKey) ?? Promise.resolve() + + // Create a deferred that we'll resolve when our request completes + let resolveChain: () => void = () => {} + const ourChain = new Promise(resolve => { + resolveChain = resolve + }) + + // IMMEDIATELY store our chain - this ensures subsequent requests wait for us + hostRequestChains.set(hostKey, ourChain) + + // Now wait for the previous request to complete + try { + await previousChain + } catch { + // Ignore errors from previous request + } + + try { + return await mixFetch(uri, opts, mixFetchOptions) + } finally { + // Signal completion so next request can proceed + resolveChain() + // Clean up if we're still the chain tail + if (hostRequestChains.get(hostKey) === ourChain) { + hostRequestChains.delete(hostKey) + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 9c9ea61ce..3d3bd6c95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,8 @@ "moduleResolution": "node", "target": "es2015", + "skipLibCheck": true, + "strict": true }, "exclude": ["lib"] diff --git a/webpack.config.js b/webpack.config.js index 776dd6a7e..3c95052d6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,6 @@ const { exec } = require('child_process') const path = require('path') +const CopyPlugin = require('copy-webpack-plugin') const TerserPlugin = require('terser-webpack-plugin') const webpack = require('webpack') @@ -32,14 +33,35 @@ const babelOptions = { } module.exports = { - devtool: debug ? 'source-map' : undefined, + devtool: 'source-map', devServer: { allowedHosts: 'all', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': + 'X-Requested-With, content-type, Authorization', + 'Cross-Origin-Resource-Policy': 'cross-origin', + // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp' + }, hot: false, - static: bundlePath + static: bundlePath, + // Proxy /plugin/ requests to BundleHTTPServer since plugins are in the app bundle + proxy: [ + { + context: ['/plugin'], + target: 'http://localhost:3693', + changeOrigin: true + } + ] }, entry: './src/io/react-native/react-native-worker.ts', - mode: debug ? 'development' : 'production', + experiments: { + asyncWebAssembly: true + }, + mode: 'development', module: { rules: [ { @@ -61,6 +83,10 @@ module.exports = { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } + }, + { + test: /\.wasm$/, + type: 'webassembly/async' } ] }, @@ -73,7 +99,33 @@ module.exports = { }, plugins: [ new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), - new webpack.ProvidePlugin({ process: ['process'] }) + new webpack.ProvidePlugin({ process: ['process'] }), + // Copy static files and mix-fetch WASM/worker files + new CopyPlugin({ + patterns: [ + // HTML entry point + { + from: path.resolve(__dirname, 'src/index.html'), + to: 'index.html' + }, + // mix-fetch WASM files for NYM mixnet support + { + from: path.resolve( + __dirname, + 'node_modules/@nymproject/mix-fetch/*.wasm' + ), + to: '[name][ext]' + }, + // mix-fetch web worker files + { + from: path.resolve( + __dirname, + 'node_modules/@nymproject/mix-fetch/web-worker-*.js' + ), + to: '[name][ext]' + } + ] + }) ], performance: { hints: false }, resolve: { diff --git a/yarn.lock b/yarn.lock index df8680ad2..c06e7a1ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1133,6 +1133,11 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@nymproject/mix-fetch@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@nymproject/mix-fetch/-/mix-fetch-1.4.1.tgz#6a923b40e09c4a571fb947297a3afd41cd2d5ea6" + integrity sha512-FN5UeCkje6fauCt2pd8kFGFYXj2kaNewEJVKRLWzI2/suxD+J2bmg/YvXBGLWMWglXNA3+YFHA/1Vjh6OGtgig== + "@pkgr/utils@^2.3.1": version "2.4.2" resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.4.2.tgz#9e638bbe9a6a6f165580dc943f138fd3309a2cbc" @@ -2315,6 +2320,17 @@ cookie@0.6.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== +copy-webpack-plugin@^13.0.1: + version "13.0.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz#fba18c22bcab3633524e1b652580ff4489eddc0d" + integrity sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw== + dependencies: + glob-parent "^6.0.1" + normalize-path "^3.0.0" + schema-utils "^4.2.0" + serialize-javascript "^6.0.2" + tinyglobby "^0.2.12" + core-js-compat@^3.31.0, core-js-compat@^3.32.2: version "3.33.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.33.0.tgz#24aa230b228406450b2277b7c8bfebae932df966" @@ -3064,6 +3080,11 @@ faye-websocket@^0.11.3: dependencies: websocket-driver ">=0.5.1" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + figures@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -3259,6 +3280,13 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" +glob-parent@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" @@ -4605,6 +4633,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pidtree@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a" @@ -5007,6 +5040,16 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +schema-utils@^4.2.0: + version "4.3.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" + integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + scrypt-js@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-2.0.3.tgz#bb0040be03043da9a012a2cea9fc9f852cfc87d4" @@ -5080,6 +5123,13 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -5504,6 +5554,14 @@ thunky@^1.0.2: resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== +tinyglobby@^0.2.12: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + titleize@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53" From c17e799da372c54ecfa38218838ddf2e8ff25806 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Thu, 22 Jan 2026 15:51:23 -0800 Subject: [PATCH 2/2] feat(react-native): add security token verification for BundleHTTPServer Add mutual authentication between the native WebView and BundleHTTPServer to prevent localhost server hijacking attacks where a malicious app could run a server on port 3693 before the legitimate server starts. Security flow: - Native generates a crypto-secure 128-bit token at runtime - BundleHTTPServer injects token into index.html as __EDGE_BUNDLE_SECURITY_TOKEN__ - JavaScript sends token back to native via bridge on startup - Native verifies token before allowing any communication - Mismatched token destroys the WebView and reports error Changes: - iOS/Android BundleHTTPServer: inject security token into index.html - iOS/Android EdgeCoreWebView: generate token, verify on receipt, block communication until verified (including postMessage to prevent bypass) - TypeScript react-native-worker: send token to native on startup - Debug mode (localhost:8080) auto-verifies since webpack dev server is trusted --- .../reactnative/core/BundleHTTPServer.java | 16 ++-- .../reactnative/core/EdgeCoreWebView.java | 77 ++++++++++++++++++- ios/BundleHTTPServer.swift | 43 ++++------- ios/EdgeCoreWebView.swift | 49 +++++++++++- src/io/react-native/react-native-worker.ts | 23 ++++++ 5 files changed, 169 insertions(+), 39 deletions(-) diff --git a/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java b/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java index 8f362ee2d..a21c7fc89 100644 --- a/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java +++ b/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java @@ -27,17 +27,15 @@ class BundleHTTPServer { private final Context mContext; private final int mPort; + private final String mSecurityToken; private ServerSocket mServerSocket; private ExecutorService mExecutor; private final AtomicBoolean mRunning = new AtomicBoolean(false); - public BundleHTTPServer(Context context) { - this(context, DEFAULT_PORT); - } - - public BundleHTTPServer(Context context, int port) { + public BundleHTTPServer(Context context, int port, String securityToken) { mContext = context; mPort = port; + mSecurityToken = securityToken; } public void start() { @@ -154,6 +152,14 @@ private void handleConnection(Socket clientSocket) { byte[] data = readAllBytes(inputStream); inputStream.close(); + // For index.html, inject the security token so the page can verify itself + if (path.equals("/index.html")) { + String html = new String(data, "UTF-8"); + String tokenScript = ""; + html = html.replace("", "\n " + tokenScript); + data = html.getBytes("UTF-8"); + } + String mimeType = getMimeType(path); sendResponse(output, 200, "OK", mimeType, data); } catch (IOException e) { diff --git a/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java b/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java index b01081f26..4ce4dc9f0 100644 --- a/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java +++ b/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java @@ -9,12 +9,30 @@ import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.events.RCTEventEmitter; import org.json.JSONArray; +import org.json.JSONException; +import android.util.Log; +import java.security.SecureRandom; class EdgeCoreWebView extends WebView { + private static final String TAG = "EdgeCoreWebView"; private static final String BASE_URL = "http://localhost:3693/"; private final ThemedReactContext mContext; private final EdgeNative mNative; private BundleHTTPServer mHttpServer; + private final String mSecurityToken; + private volatile boolean mSecurityVerified = false; + + // Generate a crypto-secure 128-bit hex token + private static String generateSecurityToken() { + SecureRandom secureRandom = new SecureRandom(); + byte[] bytes = new byte[16]; // 128 bits = 16 bytes + secureRandom.nextBytes(bytes); + StringBuilder sb = new StringBuilder(32); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } // react api-------------------------------------------------------------- @@ -42,8 +60,11 @@ public EdgeCoreWebView(ThemedReactContext context) { mContext = context; mNative = new EdgeNative(mContext.getFilesDir()); - // Start the HTTP server for serving assets with COOP/COEP headers - mHttpServer = new BundleHTTPServer(context); + // Generate security token for this instance + mSecurityToken = generateSecurityToken(); + + // Start the HTTP server with security token for serving assets with COOP/COEP headers + mHttpServer = new BundleHTTPServer(context, 3693, mSecurityToken); mHttpServer.start(); getSettings().setAllowFileAccess(true); @@ -78,11 +99,54 @@ public void onPageStarted(WebView view, String url, Bitmap favicon) { class JsMethods { @JavascriptInterface public void call(int id, final String name, final String args) { + // Handle security verification (id == 0 for special messages) + if (id == 0 && name.equals("verifySecurityToken")) { + verifySecurityToken(args); + return; + } + + // Block all other communication until security is verified + if (!mSecurityVerified) { + Log.w(TAG, "⚠️ WARNING: Blocked message '" + name + "' - BundleHTTPServer security token not yet verified!"); + return; + } + mNative.call(name, args, new WebViewPromise(id)); } + + private void verifySecurityToken(String argsJson) { + try { + JSONArray args = new JSONArray(argsJson); + String token = args.getString(0); + if (mSecurityToken.equals(token)) { + mSecurityVerified = true; + Log.i(TAG, "Security token verified successfully"); + } else { + Log.e(TAG, "Security token mismatch! Potential attack detected."); + // Destroy the WebView - don't trust it + post(() -> { + stopLoading(); + // Send error event before destroying + RCTEventEmitter emitter = + mContext.getReactApplicationContext().getJSModule(RCTEventEmitter.class); + WritableMap event = Arguments.createMap(); + event.putString("source", "Security verification failed - possible server hijacking"); + emitter.receiveEvent(getId(), "onScriptError", event); + destroy(); + }); + } + } catch (JSONException e) { + Log.e(TAG, "Failed to parse security token", e); + } + } @JavascriptInterface public void postMessage(String message) { + // Block until security is verified to prevent malicious page communication + if (!mSecurityVerified) { + Log.w(TAG, "⚠️ WARNING: Blocked postMessage - BundleHTTPServer security token not yet verified!"); + return; + } RCTEventEmitter emitter = mContext.getReactApplicationContext().getJSModule(RCTEventEmitter.class); WritableMap event = Arguments.createMap(); @@ -105,7 +169,14 @@ public void scriptError(String source) { private void visitPage() { // Load the page from the HTTP server to get COOP/COEP headers // which are required for SharedArrayBuffer support (needed by mixFetch web workers) - loadUrl(BASE_URL + "index.html"); + String url = BASE_URL; + if (mSource != null && mSource.contains("localhost:8080")) { + url = "http://localhost:8080/"; + // Auto-verify in debug mode - security token is only for BundleHTTPServer + mSecurityVerified = true; + Log.i(TAG, "Debug mode detected (localhost:8080) - bypassing BundleHTTPServer security token verification"); + } + loadUrl(url); } private class WebViewPromise implements PendingCall { diff --git a/ios/BundleHTTPServer.swift b/ios/BundleHTTPServer.swift index 9a41bc7a9..4e9ea170e 100644 --- a/ios/BundleHTTPServer.swift +++ b/ios/BundleHTTPServer.swift @@ -5,9 +5,11 @@ class BundleHTTPServer { private var listener: NWListener? private let port: UInt16 private let queue = DispatchQueue(label: "com.edge.bundleserver") + private let securityToken: String - init(port: UInt16 = 3693) { + init(port: UInt16 = 3693, securityToken: String) { self.port = port + self.securityToken = securityToken } func start() { @@ -166,11 +168,21 @@ class BundleHTTPServer { } } - guard let fileData = data else { + guard var fileData = data else { sendResponse(code: 404, body: "Not Found", connection: connection) return } + // For index.html, inject the security token so the page can verify itself + if path == "index.html", + var htmlString = String(data: fileData, encoding: .utf8) { + let tokenScript = "" + htmlString = htmlString.replacingOccurrences(of: "", with: "\n \(tokenScript)") + if let injectedData = htmlString.data(using: .utf8) { + fileData = injectedData + } + } + let mimeType = mimeTypeForPath(path) let headers = [ "HTTP/1.1 200 OK", @@ -321,33 +333,6 @@ class BundleHTTPServer { }) } - private func sendHtmlResponse(html: String, connection: NWConnection) { - let bodyData = html.data(using: .utf8)! - let headers = [ - "HTTP/1.1 200 OK", - "Content-Type: text/html", - "Content-Length: \(bodyData.count)", - "Connection: close", - "Server: EdgeCoreBundleServer/1.0", - // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers) - "Cross-Origin-Opener-Policy: same-origin", - "Cross-Origin-Embedder-Policy: require-corp", - "\r\n" - ].joined(separator: "\r\n") - - let headerData = headers.data(using: .utf8)! - let responseData = NSMutableData() - responseData.append(headerData) - responseData.append(bodyData) - - connection.send(content: responseData as Data, completion: .contentProcessed { error in - if let error = error { - print("Error sending response: \(error)") - } - connection.cancel() - }) - } - private func mimeTypeForPath(_ path: String) -> String { let ext = (path as NSString).pathExtension.lowercased() diff --git a/ios/EdgeCoreWebView.swift b/ios/EdgeCoreWebView.swift index 77214cd5c..8ee2fbca7 100644 --- a/ios/EdgeCoreWebView.swift +++ b/ios/EdgeCoreWebView.swift @@ -6,6 +6,15 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { var native = EdgeNative() var webView: WKWebView? private var httpServer: BundleHTTPServer? + private let securityToken: String = EdgeCoreWebView.generateSecurityToken() + private var isSecurityVerified: Bool = false + + // Generate a crypto-secure 128-bit hex token + private static func generateSecurityToken() -> String { + var bytes = [UInt8](repeating: 0, count: 16) // 128 bits = 16 bytes + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } // react api-------------------------------------------------------------- @@ -39,8 +48,8 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { override init(frame: CGRect) { super.init(frame: frame) - // Start the HTTP server - let server = BundleHTTPServer(port: 3693) + // Start the HTTP server with security token + let server = BundleHTTPServer(port: 3693, securityToken: securityToken) server.start() self.httpServer = server @@ -103,7 +112,14 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { let name = call[1] as? String, let args = call[2] as? NSArray { + // Handle special messages (id == 0) including security verification if id == 0 { return handleMessage(name, args: args) } + + // Block all other communication until security is verified + guard isSecurityVerified else { + print("⚠️ WARNING: EdgeCoreWebView blocked message '\(name)' - BundleHTTPServer security token not yet verified!") + return + } let promise = PendingCall( resolve: { result in @@ -128,6 +144,32 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { func handleMessage( _ name: String, args: NSArray ) { + // Security token verification - must happen before any other communication + if name == "verifySecurityToken", let token = args[0] as? String { + if token == securityToken { + isSecurityVerified = true + print("EdgeCoreWebView: Security token verified successfully") + } else { + print("EdgeCoreWebView: Security token mismatch! Potential attack detected.") + // Destroy the WebView - don't trust it + webView?.stopLoading() + if let webView = self.webView { + webView.configuration.userContentController + .removeScriptMessageHandler(forName: "edgeCore") + webView.removeFromSuperview() + self.webView = nil + } + onScriptError?(["source": "Security verification failed - possible server hijacking"]) + } + return + } + + // Block all other id==0 messages until security is verified + guard isSecurityVerified else { + print("⚠️ WARNING: EdgeCoreWebView blocked '\(name)' - BundleHTTPServer security token not yet verified!") + return + } + if name == "postMessage", let message = args[0] as? String { onMessage?(["message": message]) return @@ -162,6 +204,9 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler { let baseUrl: String if let src = source, src.contains("localhost:8080") { baseUrl = "http://localhost:8080/" + // Auto-verify in debug mode - security token is only for BundleHTTPServer + isSecurityVerified = true + print("EdgeCoreWebView: Debug mode detected (localhost:8080) - bypassing BundleHTTPServer security token verification") } else { baseUrl = "http://localhost:3693/" } diff --git a/src/io/react-native/react-native-worker.ts b/src/io/react-native/react-native-worker.ts index f565375b1..4ed76253e 100644 --- a/src/io/react-native/react-native-worker.ts +++ b/src/io/react-native/react-native-worker.ts @@ -21,6 +21,29 @@ import { hideProperties } from '../hidden-properties' import { makeNativeBridge } from './native-bridge' import { WorkerApi, YAOB_THROTTLE_MS } from './react-native-types' +// Security token verification - must happen before any other communication. +// The BundleHTTPServer injects this token into index.html. We send it back +// to native to prove that we were served by the legitimate server. +// This prevents attacks where a malicious app runs a server on port 3693. +const securityToken = (window as any).__EDGE_BUNDLE_SECURITY_TOKEN__ +if (typeof securityToken === 'string') { + if ((window as any).edgeCore != null) { + // Android: call with id=0 for special message handling + ;(window as any).edgeCore.call( + 0, + 'verifySecurityToken', + JSON.stringify([securityToken]) + ) + } else if ((window as any).webkit?.messageHandlers?.edgeCore != null) { + // iOS: postMessage with id=0 for special message handling + ;(window as any).webkit.messageHandlers.edgeCore.postMessage([ + 0, + 'verifySecurityToken', + [securityToken] + ]) + } +} + // Tracks the status of different URI endpoints for the CORS bouncer: const endpointCorsState = new Map< string,