~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
252 lines · 9.9 KB Raw History
//
//  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<Data, Error> = .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 = """
            <!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob: data:; worker-src data:; connect-src https:">
            <script type="module">
            \(script)
            window.__sandboxRuns = new Map();
            window.__sandboxRun = (json) => {
              const request = JSON.parse(json);
              const run = runInWorker(request);
              window.__sandboxRuns.set(request.id, run);
              return run.then((answer) => {
                window.__sandboxRuns.delete(request.id);
                return JSON.stringify(answer);
              });
            };
            window.__sandboxKill = (id) => {
              const run = window.__sandboxRuns.get(id);
              if (run && run.kill) run.kill();
            };
            </script>
            """
            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])
    }
}