mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25: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:
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
|
||||||
makeFetchData: {
|
makeFetchData: {
|
||||||
let session = TorURLSession.shared.session
|
let session = TorURLSession.shared.session
|
||||||
return { request in
|
return { request in
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ private extension NostrRelayManagerDependencies {
|
|||||||
torIsForeground: { TorManager.shared.isForeground() },
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
awaitTorReady: { completion in
|
awaitTorReady: { completion in
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let ready = await TorManager.shared.awaitReady()
|
// Require both Tor bootstrap AND a positive egress self-check
|
||||||
|
// so relay sockets never open unless traffic is proven to
|
||||||
|
// route through Tor (fail-closed).
|
||||||
|
let ready = await TorManager.shared.awaitEgressReady()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
completion(ready)
|
completion(ready)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//
|
||||||
|
// TorEgressVerifierTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Unit tests for the runtime Tor-egress self-check policy/caching. The network
|
||||||
|
// probe is injected, so these tests are deterministic and offline.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import bitchat
|
||||||
|
import Tor
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct TorEgressVerifierTests {
|
||||||
|
|
||||||
|
/// Deterministic, controllable clock + probe.
|
||||||
|
private final class Harness: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _now = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
private var _result: TorEgressVerifier.ProbeResult = .verifiedTor
|
||||||
|
private(set) var probeCount = 0
|
||||||
|
|
||||||
|
var now: Date {
|
||||||
|
lock.lock(); defer { lock.unlock() }; return _now
|
||||||
|
}
|
||||||
|
func advance(_ seconds: TimeInterval) {
|
||||||
|
lock.lock(); _now = _now.addingTimeInterval(seconds); lock.unlock()
|
||||||
|
}
|
||||||
|
func setResult(_ r: TorEgressVerifier.ProbeResult) {
|
||||||
|
lock.lock(); _result = r; lock.unlock()
|
||||||
|
}
|
||||||
|
func makeProbe() -> @Sendable () async -> TorEgressVerifier.ProbeResult {
|
||||||
|
return { [self] in
|
||||||
|
lock.lock(); probeCount += 1; let r = _result; lock.unlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func nowProvider() -> @Sendable () -> Date {
|
||||||
|
return { [self] in self.now }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeVerifier(_ h: Harness, ttl: TimeInterval = 300, minRetry: TimeInterval = 5) -> TorEgressVerifier {
|
||||||
|
TorEgressVerifier(ttl: ttl, minRetryInterval: minRetry, now: h.nowProvider(), probe: h.makeProbe())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("verifiedTor allows and is cached within TTL (single probe)")
|
||||||
|
func verifiedIsCached() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
// Second call within TTL must not re-probe.
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("cache expires after TTL and re-probes")
|
||||||
|
func cacheExpires() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
|
||||||
|
h.advance(301)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("notTor refuses (leak detected) and is never cached as allowed")
|
||||||
|
func notTorRefuses() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.notTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||||
|
|
||||||
|
#expect(await v.verify() == false)
|
||||||
|
// A subsequent success recovers.
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("unreachable allows (session stays fail-closed) but does not cache a Tor verification")
|
||||||
|
func unreachableAllowsButNotCached() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.unreachable("down"))
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
// Not a verified state: once it turns into a real leak, we must refuse.
|
||||||
|
h.setResult(.notTor)
|
||||||
|
#expect(await v.verify() == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("minRetryInterval throttles re-probing when not verified")
|
||||||
|
func throttleReprobe() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.unreachable("down"))
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
// Within minRetry window: reuse last decision, no new probe.
|
||||||
|
h.advance(1)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
// After the window: re-probe.
|
||||||
|
h.advance(5)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("invalidate forces a fresh probe")
|
||||||
|
func invalidateForcesReprobe() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
await v.invalidate()
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lastProbeResult reflects the most recent outcome")
|
||||||
|
func lastResultTracked() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.notTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||||
|
_ = await v.verify()
|
||||||
|
#expect(await v.lastProbeResult() == .notTor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ let package = Package(
|
|||||||
"TorManager.swift",
|
"TorManager.swift",
|
||||||
"TorURLSession.swift",
|
"TorURLSession.swift",
|
||||||
"TorNotifications.swift",
|
"TorNotifications.swift",
|
||||||
|
"TorEgressVerifier.swift",
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
.linkedLibrary("resolv"),
|
.linkedLibrary("resolv"),
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Runtime self-check that the proxied `URLSession` egress is *actually* routed
|
||||||
|
/// through Tor — defense-in-depth for the case where a platform silently ignores
|
||||||
|
/// `URLSessionConfiguration.connectionProxyDictionary` SOCKS settings and lets
|
||||||
|
/// traffic egress directly (leaking the real IP while Tor appears enabled).
|
||||||
|
///
|
||||||
|
/// Runtime verification (see `scripts/tor-egress-verification/`) showed that
|
||||||
|
/// macOS and the iOS simulator DO honor the SOCKS proxy for both plain HTTPS and
|
||||||
|
/// `URLSessionWebSocketTask`, and that the proxied session is fail-closed (every
|
||||||
|
/// request errors when the SOCKS proxy is down). Apple does not officially
|
||||||
|
/// support SOCKS for URLSession on iOS, so on a physical device the behavior is
|
||||||
|
/// not contractually guaranteed. This verifier closes that gap: before relay
|
||||||
|
/// connections are opened under enforced Tor, it performs a canary request whose
|
||||||
|
/// response positively reports whether the egress hit the network via Tor.
|
||||||
|
///
|
||||||
|
/// Policy (`verify()` return value):
|
||||||
|
/// - `.verifiedTor` → allow, and cache the positive result for `ttl`.
|
||||||
|
/// - `.notTor` → REFUSE. The canary reached the internet but the exit
|
||||||
|
/// is NOT a Tor node: a real leak. Never allow relays.
|
||||||
|
/// - `.unreachable` → allow-with-warning. The canary itself failed (endpoint
|
||||||
|
/// down / circuit not built). This does NOT egress direct:
|
||||||
|
/// the relay session is independently fail-closed, so the
|
||||||
|
/// worst case is that relay connections also fail. We do
|
||||||
|
/// not hard-block here to avoid taking Nostr fully offline
|
||||||
|
/// whenever the single canary host is unavailable.
|
||||||
|
///
|
||||||
|
/// The probe is injectable so the policy/caching logic is unit-tested without a
|
||||||
|
/// live network (see `TorEgressVerifierTests`).
|
||||||
|
public actor TorEgressVerifier {
|
||||||
|
public enum ProbeResult: Equatable, Sendable {
|
||||||
|
/// Canary succeeded and the exit is a Tor node.
|
||||||
|
case verifiedTor
|
||||||
|
/// Canary succeeded but the exit is NOT Tor — a direct-egress leak.
|
||||||
|
case notTor
|
||||||
|
/// Canary could not complete (endpoint down, no circuit, parse error).
|
||||||
|
case unreachable(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let probe: @Sendable () async -> ProbeResult
|
||||||
|
private let now: @Sendable () -> Date
|
||||||
|
private let ttl: TimeInterval
|
||||||
|
/// Minimum spacing between probes when not currently verified, so a
|
||||||
|
/// persistent `.unreachable` cannot hammer the canary endpoint on every
|
||||||
|
/// reconnect burst.
|
||||||
|
private let minRetryInterval: TimeInterval
|
||||||
|
|
||||||
|
private var lastVerifiedAt: Date?
|
||||||
|
private var lastProbeAt: Date?
|
||||||
|
private var lastResult: ProbeResult?
|
||||||
|
private var inFlight: Task<Bool, Never>?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
ttl: TimeInterval,
|
||||||
|
minRetryInterval: TimeInterval = 5.0,
|
||||||
|
now: @escaping @Sendable () -> Date = Date.init,
|
||||||
|
probe: @escaping @Sendable () async -> ProbeResult
|
||||||
|
) {
|
||||||
|
self.ttl = ttl
|
||||||
|
self.minRetryInterval = minRetryInterval
|
||||||
|
self.now = now
|
||||||
|
self.probe = probe
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop any cached verification (e.g. after a Tor restart or when the
|
||||||
|
/// network path changes). The next `verify()` re-probes.
|
||||||
|
public func invalidate() {
|
||||||
|
lastVerifiedAt = nil
|
||||||
|
lastProbeAt = nil
|
||||||
|
lastResult = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent probe outcome, for diagnostics/tests.
|
||||||
|
public func lastProbeResult() -> ProbeResult? { lastResult }
|
||||||
|
|
||||||
|
/// Returns `true` when it is safe to open relay connections through the
|
||||||
|
/// proxied session, `false` only when a non-Tor egress was positively
|
||||||
|
/// detected. See the type doc for the full policy.
|
||||||
|
public func verify() async -> Bool {
|
||||||
|
if isFreshlyVerified() { return true }
|
||||||
|
// Throttle re-probes when the last attempt did not verify.
|
||||||
|
if let last = lastProbeAt,
|
||||||
|
let result = lastResult,
|
||||||
|
now().timeIntervalSince(last) < minRetryInterval {
|
||||||
|
return decision(for: result)
|
||||||
|
}
|
||||||
|
if let inFlight { return await inFlight.value }
|
||||||
|
|
||||||
|
let task = Task<Bool, Never> { await self.runProbe() }
|
||||||
|
inFlight = task
|
||||||
|
let allowed = await task.value
|
||||||
|
inFlight = nil
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFreshlyVerified() -> Bool {
|
||||||
|
guard let last = lastVerifiedAt else { return false }
|
||||||
|
return now().timeIntervalSince(last) < ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decision(for result: ProbeResult) -> Bool {
|
||||||
|
switch result {
|
||||||
|
case .verifiedTor: return true
|
||||||
|
case .unreachable: return true
|
||||||
|
case .notTor: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runProbe() async -> Bool {
|
||||||
|
let result = await probe()
|
||||||
|
lastProbeAt = now()
|
||||||
|
lastResult = result
|
||||||
|
switch result {
|
||||||
|
case .verifiedTor:
|
||||||
|
lastVerifiedAt = now()
|
||||||
|
return true
|
||||||
|
case .notTor:
|
||||||
|
lastVerifiedAt = nil
|
||||||
|
SecureLogger.error(
|
||||||
|
"🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
case .unreachable(let why):
|
||||||
|
lastVerifiedAt = nil
|
||||||
|
SecureLogger.warning(
|
||||||
|
"🧅 Tor egress self-check could not complete (\(why)); relay session remains fail-closed via SOCKS proxy",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live probe
|
||||||
|
|
||||||
|
public extension TorEgressVerifier {
|
||||||
|
/// Default canary: fetch Tor Project's connectivity check API through the
|
||||||
|
/// shared proxied session and assert `IsTor == true`. Because the response
|
||||||
|
/// is served from the *exit's* vantage point, a silent direct egress is
|
||||||
|
/// caught here as `.notTor`. `check.torproject.org` is clearnet, so this
|
||||||
|
/// works without onion-service support.
|
||||||
|
///
|
||||||
|
/// Follow-up (see PR): make the canary endpoint configurable and add an
|
||||||
|
/// onion-service canary so verification does not depend on a single host.
|
||||||
|
static func liveProbe(
|
||||||
|
endpoint: URL = URL(string: "https://check.torproject.org/api/ip")!,
|
||||||
|
timeout: TimeInterval = 20
|
||||||
|
) -> @Sendable () async -> ProbeResult {
|
||||||
|
return {
|
||||||
|
var request = URLRequest(url: endpoint)
|
||||||
|
request.timeoutInterval = timeout
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||||
|
let session = TorURLSession.shared.session
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse,
|
||||||
|
(200..<300).contains(http.statusCode) else {
|
||||||
|
return .unreachable("http status \((response as? HTTPURLResponse)?.statusCode ?? -1)")
|
||||||
|
}
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return .unreachable("unparseable canary response")
|
||||||
|
}
|
||||||
|
if let isTor = json["IsTor"] as? Bool {
|
||||||
|
return isTor ? .verifiedTor : .notTor
|
||||||
|
}
|
||||||
|
return .unreachable("canary response missing IsTor")
|
||||||
|
} catch {
|
||||||
|
return .unreachable(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,14 @@ public final class TorManager: ObservableObject {
|
|||||||
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
||||||
private var restarting: Bool = false
|
private var restarting: Bool = false
|
||||||
|
|
||||||
|
/// Runtime egress self-check: proves the proxied session actually exits via
|
||||||
|
/// Tor before relay connections are opened (defense-in-depth against a
|
||||||
|
/// platform silently ignoring the SOCKS proxy). Cached for a few minutes.
|
||||||
|
public let egressVerifier = TorEgressVerifier(
|
||||||
|
ttl: 300,
|
||||||
|
probe: TorEgressVerifier.liveProbe()
|
||||||
|
)
|
||||||
|
|
||||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||||
public var torEnforced: Bool {
|
public var torEnforced: Bool {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
@@ -125,6 +133,22 @@ public final class TorManager: ObservableObject {
|
|||||||
return await MainActor.run(body: { self.networkPermitted })
|
return await MainActor.run(body: { self.networkPermitted })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `awaitReady`, but additionally requires that a canary request
|
||||||
|
/// through the proxied session positively verifies Tor egress. Returns
|
||||||
|
/// `false` if Tor never became ready, or if the egress self-check
|
||||||
|
/// positively detected a non-Tor (direct) egress. Callers must fail closed
|
||||||
|
/// on `false` — never fall back to a direct connection.
|
||||||
|
nonisolated
|
||||||
|
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
|
||||||
|
let ready = await awaitReady(timeout: timeout)
|
||||||
|
guard ready else { return false }
|
||||||
|
// Clearnet dev builds don't route through Tor, so the canary would
|
||||||
|
// (correctly) report non-Tor; skip it there.
|
||||||
|
let enforced = await MainActor.run { self.torEnforced }
|
||||||
|
guard enforced else { return true }
|
||||||
|
return await egressVerifier.verify()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Filesystem
|
// MARK: - Filesystem
|
||||||
|
|
||||||
func dataDirectoryURL() -> URL? {
|
func dataDirectoryURL() -> URL? {
|
||||||
@@ -325,6 +349,8 @@ public final class TorManager: ObservableObject {
|
|||||||
self.socksReady = false
|
self.socksReady = false
|
||||||
self.isStarting = false
|
self.isStarting = false
|
||||||
}
|
}
|
||||||
|
// Force a fresh egress self-check once Tor comes back.
|
||||||
|
Task { await egressVerifier.invalidate() }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func shutdownCompletely() {
|
public func shutdownCompletely() {
|
||||||
@@ -353,6 +379,7 @@ public final class TorManager: ObservableObject {
|
|||||||
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
||||||
// Clearing it here races with startup and defeats the grace period
|
// Clearing it here races with startup and defeats the grace period
|
||||||
}
|
}
|
||||||
|
await self.egressVerifier.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +395,8 @@ public final class TorManager: ObservableObject {
|
|||||||
self.isDormant = false
|
self.isDormant = false
|
||||||
self.lastRestartAt = Date()
|
self.lastRestartAt = Date()
|
||||||
}
|
}
|
||||||
|
// New Arti instance means new circuits; re-verify egress after restart.
|
||||||
|
await egressVerifier.invalidate()
|
||||||
|
|
||||||
_ = arti_stop()
|
_ = arti_stop()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Orchestrates the Tor-egress proxy-honoring verification on macOS.
|
||||||
|
#
|
||||||
|
# For each (request-type x key-style) it runs two experiments:
|
||||||
|
# A) proxy UP — did a connection arrive at the SOCKS proxy? (log grows)
|
||||||
|
# B) proxy DOWN — pointed at a dead port; does the request still SUCCEED?
|
||||||
|
# If it succeeds with no proxy, egress went DIRECT (proxy ignored).
|
||||||
|
# If it fails, the proxy setting is being enforced (fail-closed).
|
||||||
|
#
|
||||||
|
# Discriminator: PROXIED = connection observed at proxy AND fails when proxy down
|
||||||
|
# DIRECT = no connection at proxy OR succeeds when proxy down
|
||||||
|
set -u
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PORT=19999
|
||||||
|
DEADPORT=19998 # nothing listens here
|
||||||
|
LOG="$(mktemp -t sockslog)"
|
||||||
|
BIN="$(mktemp -t proxyprobe)"
|
||||||
|
|
||||||
|
echo "== building swift probe =="
|
||||||
|
swiftc -O "$DIR/proxy_probe.swift" -o "$BIN" || { echo "swiftc failed"; exit 1; }
|
||||||
|
|
||||||
|
echo "== starting SOCKS proxy on $PORT =="
|
||||||
|
: > "$LOG"
|
||||||
|
python3 "$DIR/socks5_probe_proxy.py" "$PORT" "$LOG" >/tmp/socksproxy.out 2>&1 &
|
||||||
|
PROXY_PID=$!
|
||||||
|
trap 'kill $PROXY_PID 2>/dev/null' EXIT
|
||||||
|
# wait for READY
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
grep -q READY /tmp/socksproxy.out 2>/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local mode="$1" key="$2"
|
||||||
|
# Experiment A: proxy up, watch log
|
||||||
|
local before after target
|
||||||
|
before=$(wc -l < "$LOG" | tr -d ' ')
|
||||||
|
local outA
|
||||||
|
outA=$("$BIN" "$mode" "$key" "$PORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
||||||
|
sleep 0.3
|
||||||
|
after=$(wc -l < "$LOG" | tr -d ' ')
|
||||||
|
local proxied="NO"
|
||||||
|
if [ "$after" -gt "$before" ]; then proxied="YES"; fi
|
||||||
|
local newlines
|
||||||
|
newlines=$(tail -n +"$((before+1))" "$LOG" | tr '\t' ' ' | tr '\n' '|')
|
||||||
|
|
||||||
|
# Experiment B: proxy down (dead port), same request
|
||||||
|
local outB
|
||||||
|
outB=$("$BIN" "$mode" "$key" "$DEADPORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
||||||
|
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "CASE mode=$mode key=$key"
|
||||||
|
echo " A(proxy up): $outA | connection_at_proxy=$proxied [$newlines]"
|
||||||
|
echo " B(proxy down): $outB"
|
||||||
|
# verdict
|
||||||
|
local a_ok b_ok
|
||||||
|
a_ok=$(echo "$outA" | awk '{print $4}')
|
||||||
|
b_ok=$(echo "$outB" | awk '{print $4}')
|
||||||
|
local verdict="UNKNOWN"
|
||||||
|
if [ "$proxied" = "YES" ] && [ "$b_ok" = "ERROR" ]; then verdict="PROXIED (enforced)"; fi
|
||||||
|
if [ "$proxied" = "NO" ] && [ "$b_ok" = "OK" ]; then verdict="DIRECT (proxy ignored)"; fi
|
||||||
|
if [ "$proxied" = "YES" ] && [ "$b_ok" = "OK" ]; then verdict="AMBIGUOUS (uses proxy if up, but egresses direct if down)"; fi
|
||||||
|
if [ "$proxied" = "NO" ] && [ "$b_ok" = "ERROR" ]; then verdict="BLOCKED both (network/target issue?)"; fi
|
||||||
|
echo " VERDICT: $verdict"
|
||||||
|
}
|
||||||
|
|
||||||
|
for mode in http ws; do
|
||||||
|
for key in cf raw; do
|
||||||
|
run_case "$mode" "$key"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo "========================================"
|
||||||
|
echo "raw proxy log:"; cat "$LOG" | tr '\t' ' '
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Minimal threaded SOCKS5 CONNECT proxy used to verify whether Apple's
|
||||||
|
URLSession actually honors `connectionProxyDictionary` SOCKS settings for
|
||||||
|
different request types (plain HTTPS vs URLSessionWebSocketTask).
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Speaks enough SOCKS5 (no-auth) to complete a CONNECT and then relays
|
||||||
|
bytes bidirectionally to the real destination.
|
||||||
|
- Every accepted CONNECT is appended to a log file as one line:
|
||||||
|
<iso8601>\tCONNECT\t<host>:<port>
|
||||||
|
- Any raw connection that is NOT valid SOCKS5 is logged as:
|
||||||
|
<iso8601>\tNON_SOCKS\t<first-bytes-hex>
|
||||||
|
(this catches the feared case where URLSession sends a raw TLS/HTTP
|
||||||
|
ClientHello straight at the proxy port instead of a SOCKS greeting).
|
||||||
|
|
||||||
|
If a request egresses DIRECTLY (proxy ignored), nothing is logged at all.
|
||||||
|
|
||||||
|
Usage: socks5_probe_proxy.py <listen_port> <log_file>
|
||||||
|
"""
|
||||||
|
import selectors
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
LOG_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def log(logfile, kind, detail):
|
||||||
|
line = f"{datetime.now(timezone.utc).isoformat()}\t{kind}\t{detail}\n"
|
||||||
|
with LOG_LOCK:
|
||||||
|
with open(logfile, "a") as f:
|
||||||
|
f.write(line)
|
||||||
|
sys.stderr.write("[proxy] " + line)
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def recv_exact(sock, n):
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = sock.recv(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def handle(client, logfile):
|
||||||
|
client.settimeout(15)
|
||||||
|
try:
|
||||||
|
# SOCKS5 greeting: VER=0x05, NMETHODS, METHODS...
|
||||||
|
head = recv_exact(client, 2)
|
||||||
|
if not head:
|
||||||
|
return
|
||||||
|
if head[0] != 0x05:
|
||||||
|
# Not SOCKS5 at all — this is the smoking gun for a direct egress
|
||||||
|
# that mistakenly hit the proxy port. Log the first bytes.
|
||||||
|
rest = b""
|
||||||
|
try:
|
||||||
|
client.setblocking(False)
|
||||||
|
rest = client.recv(64)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log(logfile, "NON_SOCKS", (head + rest).hex())
|
||||||
|
return
|
||||||
|
nmethods = head[1]
|
||||||
|
if nmethods:
|
||||||
|
recv_exact(client, nmethods)
|
||||||
|
# Reply: no authentication required
|
||||||
|
client.sendall(b"\x05\x00")
|
||||||
|
|
||||||
|
# Request: VER, CMD, RSV, ATYP, ADDR, PORT
|
||||||
|
req = recv_exact(client, 4)
|
||||||
|
if not req or req[1] != 0x01: # only CONNECT
|
||||||
|
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
return
|
||||||
|
atyp = req[3]
|
||||||
|
if atyp == 0x01: # IPv4
|
||||||
|
addr = socket.inet_ntoa(recv_exact(client, 4))
|
||||||
|
elif atyp == 0x03: # domain
|
||||||
|
ln = recv_exact(client, 1)[0]
|
||||||
|
addr = recv_exact(client, ln).decode("ascii", errors="replace")
|
||||||
|
elif atyp == 0x04: # IPv6
|
||||||
|
addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16))
|
||||||
|
else:
|
||||||
|
client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
return
|
||||||
|
port = int.from_bytes(recv_exact(client, 2), "big")
|
||||||
|
|
||||||
|
log(logfile, "CONNECT", f"{addr}:{port}")
|
||||||
|
|
||||||
|
# Connect to the real destination and reply success.
|
||||||
|
try:
|
||||||
|
remote = socket.create_connection((addr, port), timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
log(logfile, "CONNECT_FAIL", f"{addr}:{port} {e}")
|
||||||
|
client.sendall(b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
return
|
||||||
|
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
|
||||||
|
relay(client, remote)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def relay(a, b):
|
||||||
|
a.setblocking(False)
|
||||||
|
b.setblocking(False)
|
||||||
|
sel = selectors.DefaultSelector()
|
||||||
|
sel.register(a, selectors.EVENT_READ, b)
|
||||||
|
sel.register(b, selectors.EVENT_READ, a)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
events = sel.select(timeout=30)
|
||||||
|
if not events:
|
||||||
|
break
|
||||||
|
for key, _ in events:
|
||||||
|
src = key.fileobj
|
||||||
|
dst = key.data
|
||||||
|
try:
|
||||||
|
data = src.recv(65536)
|
||||||
|
except (BlockingIOError, InterruptedError):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dst.sendall(data)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
sel.close()
|
||||||
|
for s in (a, b):
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: socks5_probe_proxy.py <port> <logfile>", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
port = int(sys.argv[1])
|
||||||
|
logfile = sys.argv[2]
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.bind(("127.0.0.1", port))
|
||||||
|
srv.listen(64)
|
||||||
|
sys.stderr.write(f"[proxy] listening on 127.0.0.1:{port}, log={logfile}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
print("READY", flush=True)
|
||||||
|
while True:
|
||||||
|
client, _ = srv.accept()
|
||||||
|
threading.Thread(target=handle, args=(client, logfile), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user