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