Files
bitchat/scripts/tor-egress-verification/proxy_probe.swift
T
jackandClaude Fable 5 c7ee2a4cb4 Add runtime Tor-egress self-check (fail-closed) for Nostr relays
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>
2026-07-01 23:34:10 +02:00

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()