1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
//
// 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])
}
}