mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
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>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// 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()
|
||||
Reference in New Issue
Block a user