mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Runtime-verified whether Apple's URLSession honors connectionProxyDictionary SOCKS settings for Nostr relay traffic (plain HTTPS + URLSessionWebSocketTask), since the app routes all Nostr traffic through Arti's local SOCKS5 proxy solely via those keys and Apple does not officially support SOCKS for URLSession on iOS. Verification harness (scripts/tor-egress-verification/): a minimal SOCKS5 CONNECT proxy that logs arriving connections, plus a Swift probe that runs an HTTPS GET and a WebSocket ping through a URLSession configured with the proxy dict. Result: on macOS (kCFNetworkProxiesSOCKS* constants) and the iOS simulator (raw "SOCKSProxy"/"SOCKSPort" string keys, the exact app path) BOTH HTTP and WebSocket are proxied, do remote DNS through the proxy, and the proxied session is fail-closed (every request errors when the SOCKS proxy is down). The feared direct-egress leak did not reproduce on the tested platforms. Defense-in-depth for the platform Apple does not guarantee (physical iOS): add TorEgressVerifier, which performs a canary request through the proxied session and asserts the exit is a Tor node (check.torproject.org IsTor==true), positively detecting a direct egress even if the OS silently ignored the proxy. Policy: verifiedTor allows (cached for a TTL); notTor refuses (leak detected, never open relays); unreachable allows-with-warning (session stays fail-closed by construction). Wired into TorManager.awaitEgressReady() and required by both NostrRelayManager and GeoRelayDirectory before opening connections. Cache is invalidated on Tor restart/dormant/shutdown. Never falls back to a direct connection. Probe is injected so the policy/caching is unit-tested offline; the live-network harness lives under scripts/ and is not run by CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 lines
2.8 KiB
Swift
91 lines
2.8 KiB
Swift
// Standalone harness: does Apple's URLSession honor connectionProxyDictionary
|
|
// SOCKS settings for a plain HTTPS GET and for URLSessionWebSocketTask?
|
|
//
|
|
// Build: swiftc -O proxy_probe.swift -o proxy_probe
|
|
// Usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]
|
|
//
|
|
// Prints a single RESULT line: RESULT <mode> <keyStyle> <outcome> <detail>
|
|
// The caller correlates this with the SOCKS proxy's connection log to decide
|
|
// whether the request was proxied.
|
|
#if canImport(CFNetwork)
|
|
import CFNetwork
|
|
#endif
|
|
import Foundation
|
|
|
|
let args = CommandLine.arguments
|
|
guard args.count >= 4 else {
|
|
FileHandle.standardError.write(Data("usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]\n".utf8))
|
|
exit(2)
|
|
}
|
|
let mode = args[1]
|
|
let keyStyle = args[2]
|
|
let proxyPort = Int(args[3]) ?? 19999
|
|
let host = "127.0.0.1"
|
|
|
|
func makeProxyDict() -> [AnyHashable: Any] {
|
|
switch keyStyle {
|
|
#if os(macOS)
|
|
case "cf":
|
|
// The exact constants the app uses on macOS.
|
|
return [
|
|
kCFNetworkProxiesSOCKSEnable as String: 1,
|
|
kCFNetworkProxiesSOCKSProxy as String: host,
|
|
kCFNetworkProxiesSOCKSPort as String: proxyPort
|
|
]
|
|
#endif
|
|
default:
|
|
// The exact raw string keys the app uses on iOS.
|
|
return [
|
|
"SOCKSEnable": 1,
|
|
"SOCKSProxy": host,
|
|
"SOCKSPort": proxyPort
|
|
]
|
|
}
|
|
}
|
|
|
|
let cfg = URLSessionConfiguration.ephemeral
|
|
cfg.waitsForConnectivity = false
|
|
cfg.timeoutIntervalForRequest = 20
|
|
cfg.connectionProxyDictionary = makeProxyDict()
|
|
let session = URLSession(configuration: cfg)
|
|
|
|
func emit(_ outcome: String, _ detail: String) {
|
|
print("RESULT \(mode) \(keyStyle) \(outcome) \(detail)")
|
|
exit(outcome == "ERROR" ? 1 : 0)
|
|
}
|
|
|
|
let sem = DispatchSemaphore(value: 0)
|
|
|
|
if mode == "http" {
|
|
let target = URL(string: args.count >= 5 ? args[4] : "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
|
let task = session.dataTask(with: target) { data, resp, err in
|
|
if let err = err {
|
|
emit("ERROR", "\(err.localizedDescription)")
|
|
} else if let http = resp as? HTTPURLResponse {
|
|
emit("OK", "status=\(http.statusCode) bytes=\(data?.count ?? 0)")
|
|
} else {
|
|
emit("OK", "bytes=\(data?.count ?? 0)")
|
|
}
|
|
}
|
|
task.resume()
|
|
} else {
|
|
// WebSocket
|
|
let target = URL(string: args.count >= 5 ? args[4] : "wss://relay.damus.io")!
|
|
let ws = session.webSocketTask(with: target)
|
|
ws.resume()
|
|
// A successful ping proves the TLS+WS handshake completed end-to-end.
|
|
ws.sendPing { err in
|
|
if let err = err {
|
|
emit("ERROR", "\(err.localizedDescription)")
|
|
} else {
|
|
emit("OK", "ws-ping-ok")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Global watchdog so we never hang.
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 25) {
|
|
emit("ERROR", "timeout")
|
|
}
|
|
sem.wait()
|