mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user