// // PolicySandboxHost.swift // AT-IRC // // Host half of the embedded fenced policy sandbox (plan 0038 I6). // A dedicated, non-persistent WKWebView runs the page's policy_sandbox.js; // policy programs run in the data: module Worker that script creates, never // in the page. The app's Keychain, defaults, files and cookies are planted // by the probe and must be invisible from the sandbox; any failure keeps the // engine Deny (the Go side fails closed). // // Go calls these methods on Go threads, so the class is nonisolated and // blocks the caller while the WebKit work runs on the main actor. // import AtircGo import Foundation import OSLog import Security import UIKit import WebKit nonisolated final class PolicySandboxHost: NSObject, AtircgoPolicySandboxProtocol, @unchecked Sendable { private static let probeName = "socialweb-fence-probe" private static let probeDomain = "socialweb-fence-probe.invalid" private let engine = SandboxEngine() private let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "policy-sandbox") // MARK: - Go host protocol func run(_ requestJSON: Data?) throws -> Data { guard let requestJSON, let object = try? JSONSerialization.jsonObject(with: requestJSON) as? [String: Any], let timeoutMs = object["timeoutMs"] as? Int else { throw failure("invalid policy request") } let request = String(decoding: requestJSON, as: UTF8.self) log.notice("run bytes=\(requestJSON.count, privacy: .public) timeout=\(timeoutMs, privacy: .public)") let semaphore = DispatchSemaphore(value: 0) var result: Result = .failure(failure("policy sandbox did not start")) Task { @MainActor [engine] in do { result = .success(try await engine.run(request)) log.notice("run answered") } catch { result = .failure(error) log.error("run failed: \(error.localizedDescription, privacy: .public)") } semaphore.signal() } if semaphore.wait(timeout: .now() + Double(timeoutMs) / 1000 + 10) == .timedOut { log.error("run timed out") throw failure("policy sandbox timed out") } return try result.get() } func kill(_ runID: String?) { guard let runID else { return } Task { @MainActor [engine] in await engine.kill(runID) } } func plantProbe(_ marker: String?) throws -> Data { guard let marker, !marker.isEmpty else { throw failure("empty probe marker") } let planted = try plant(marker) log.notice("plantProbe keychain=\(planted.keychain, privacy: .public) file=\(planted.file, privacy: .public) defaults=\(planted.defaults, privacy: .public) cookies=\(planted.cookies, privacy: .public)") let answer: [String: Any] = [ "engine": "WKWebView", "fileURL": planted.fileURL, "planted": [ "keychain": planted.keychain ? "yes" : "no", "file": planted.file ? "yes" : "no", "defaults": planted.defaults ? "yes" : "no", "cookies": planted.cookies ? "yes" : "no", ], ] return try JSONSerialization.data(withJSONObject: answer) } func clearProbe(_ marker: String?) throws { try? FileManager.default.removeItem(at: Self.probeFileURL()) Keychain.delete(Self.probeName) UserDefaults.standard.removeObject(forKey: Self.probeName) for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == Self.probeName { HTTPCookieStorage.shared.deleteCookie(cookie) } } // MARK: - Probe markers private struct Planted { let fileURL: String let keychain: Bool let file: Bool let defaults: Bool let cookies: Bool } private func plant(_ marker: String) throws -> Planted { let file = Self.probeFileURL() try FileManager.default.createDirectory( at: file.deletingLastPathComponent(), withIntermediateDirectories: true ) try "export default \"\(marker)\";".write(to: file, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) Keychain.set(Data(marker.utf8), for: Self.probeName, accessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly) let keychain = Keychain.get(Self.probeName).map { String(decoding: $0, as: UTF8.self) } == marker UserDefaults.standard.set(marker, forKey: Self.probeName) let defaults = UserDefaults.standard.string(forKey: Self.probeName) == marker let cookie = HTTPCookie(properties: [ .domain: Self.probeDomain, .path: "/", .name: Self.probeName, .value: marker, .secure: "TRUE", .expires: Date(timeIntervalSinceNow: 300), ]) if let cookie { HTTPCookieStorage.shared.setCookie(cookie) } let cookies = HTTPCookieStorage.shared.cookies?.contains { $0.name == Self.probeName } ?? false return Planted( fileURL: file.absoluteString, keychain: keychain, file: FileManager.default.fileExists(atPath: file.path), defaults: defaults, cookies: cookies ) } private static func probeFileURL() -> URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("probe", isDirectory: true) .appendingPathComponent(probeName + ".js") } private func failure(_ message: String) -> NSError { NSError(domain: "PolicySandboxHost", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) } } /// Main-actor WebKit engine for the policy sandbox. @MainActor private final class SandboxEngine { private var webView: WKWebView? private var ready = false nonisolated init() {} func run(_ request: String) async throws -> Data { let webView = try await ensureWebView() let value = try await webView.callAsyncJavaScript( "return await window.__sandboxRun(requestJSON);", arguments: ["requestJSON": request], in: nil, contentWorld: .page ) guard let text = value as? String else { throw failure("invalid policy answer") } return Data(text.utf8) } func kill(_ runID: String) async { guard let webView else { return } _ = try? await webView.callAsyncJavaScript( "window.__sandboxKill(runID);", arguments: ["runID": runID], in: nil, contentWorld: .page ) } private func ensureWebView() async throws -> WKWebView { if let webView, ready { return webView } if webView == nil { Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "policy-sandbox").notice("creating webview") let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() configuration.defaultWebpagePreferences.allowsContentJavaScript = true let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 2, height: 2), configuration: configuration) webView.isUserInteractionEnabled = false webView.alpha = 0.01 if let window = Self.keyWindow() { window.addSubview(webView) } self.webView = webView let script = try Self.sandboxScript() let html = """ """ webView.loadHTMLString(html, baseURL: nil) } guard let webView else { throw failure("policy sandbox did not start") } for _ in 0..<100 { if let value = try? await webView.evaluateJavaScript("typeof window.__sandboxRun"), (value as? String) == "function" { ready = true return webView } try? await Task.sleep(for: .milliseconds(100)) } throw failure("policy sandbox frame did not start") } private static func sandboxScript() throws -> String { if let url = Bundle.main.url(forResource: "policy_sandbox", withExtension: "js"), let text = try? String(contentsOf: url, encoding: .utf8) { return text } // Development builds without the resource use the pinned copy beside // the source. let local = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .appendingPathComponent("policy_sandbox.js") return try String(contentsOf: local, encoding: .utf8) } private static func keyWindow() -> UIWindow? { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } .first { $0.isKeyWindow } } private func failure(_ message: String) -> NSError { NSError(domain: "PolicySandboxHost", code: 2, userInfo: [NSLocalizedDescriptionKey: message]) } }