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:
jack
2026-07-01 23:34:10 +02:00
co-authored by Claude Fable 5
parent f688e529f6
commit c7ee2a4cb4
9 changed files with 675 additions and 2 deletions
@@ -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)
}
}
}
}