Files
bitchat/localPackages/Arti/Sources/TorManager.swift
T
jackandClaude Fable 5 c7ee2a4cb4 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>
2026-07-01 23:34:10 +02:00

480 lines
17 KiB
Swift

import BitLogger
import Foundation
#if canImport(Network)
import Network
#endif
#if !canImport(Network)
private final class NWPathMonitor {
var pathUpdateHandler: ((Any) -> Void)?
func start(queue: DispatchQueue) {
// Path monitoring is unavailable on this platform; nothing to do.
}
}
#endif
// FFI declarations for Arti (Rust)
@_silgen_name("arti_start")
private func arti_start(_ dataDir: UnsafePointer<CChar>, _ socksPort: UInt16) -> Int32
@_silgen_name("arti_stop")
private func arti_stop() -> Int32
@_silgen_name("arti_is_running")
private func arti_is_running() -> Int32
@_silgen_name("arti_bootstrap_progress")
private func arti_bootstrap_progress() -> Int32
@_silgen_name("arti_bootstrap_summary")
private func arti_bootstrap_summary(_ buf: UnsafeMutablePointer<CChar>, _ len: Int32) -> Int32
@_silgen_name("arti_go_dormant")
private func arti_go_dormant() -> Int32
@_silgen_name("arti_wake")
private func arti_wake() -> Int32
/// Arti-based Tor integration for BitChat.
/// - Boots a local Arti client and exposes a SOCKS5 proxy
/// on 127.0.0.1:socksPort. All app networking should await readiness and
/// route via this proxy. Fails closed by default when Tor is unavailable.
@MainActor
public final class TorManager: ObservableObject {
public static let shared = TorManager()
// SOCKS endpoint where Arti listens
let socksHost: String = "127.0.0.1"
let socksPort: Int = 39050
// State
@Published private(set) public var isReady: Bool = false
@Published private(set) var isStarting: Bool = false
@Published private(set) var lastError: Error?
@Published private(set) var bootstrapProgress: Int = 0
@Published private(set) var bootstrapSummary: String = ""
// Internal readiness trackers
private var socksReady: Bool = false { didSet { recomputeReady() } }
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).
public var torEnforced: Bool {
#if BITCHAT_DEV_ALLOW_CLEARNET
return false
#else
return true
#endif
}
// Returns true only when Tor is actually up (or dev fallback is compiled).
var networkPermitted: Bool {
if torEnforced { return isReady }
return true
}
private var didStart = false
private var bootstrapMonitorStarted = false
private var pathMonitor: NWPathMonitor?
private var isAppForeground: Bool = true
private var isDormant: Bool = false
private var lastRestartAt: Date? = nil
private var startedAt: Date? = nil // Tracks initial startup time for grace period
private(set) var allowAutoStart: Bool = false
private init() {}
// MARK: - Public API
public func startIfNeeded() {
guard allowAutoStart else { return }
guard isAppForeground else { return }
guard !didStart else { return }
didStart = true
isDormant = false
isStarting = true
startedAt = Date() // Track startup time for grace period
SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session)
lastError = nil
NotificationCenter.default.post(name: .TorWillStart, object: nil)
ensureFilesystemLayout()
startArti()
startPathMonitorIfNeeded()
}
public func setAppForeground(_ foreground: Bool) {
isAppForeground = foreground
}
public func isForeground() -> Bool { isAppForeground }
nonisolated
// Default matches the bootstrap monitor deadline (75s); a shorter wait here
// reports "not ready" while Arti is still legitimately bootstrapping.
public func awaitReady(timeout: TimeInterval = 75.0) async -> Bool {
await MainActor.run {
if self.isAppForeground { self.startIfNeeded() }
}
let deadline = Date().addingTimeInterval(timeout)
if await MainActor.run(body: { self.networkPermitted }) { return true }
while Date() < deadline {
try? await Task.sleep(nanoseconds: 200_000_000)
if await MainActor.run(body: { self.networkPermitted }) { return true }
}
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
func dataDirectoryURL() -> URL? {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat/arti", isDirectory: true)
return dir
} catch {
return nil
}
}
private func ensureFilesystemLayout() {
guard let dir = dataDirectoryURL() else { return }
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
} catch {
// Non-fatal; Arti will surface errors during start if paths are missing
}
}
// MARK: - Arti Integration
private func startArti() {
guard let dir = dataDirectoryURL()?.path else {
isStarting = false
lastError = NSError(domain: "TorManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data directory"])
return
}
// Check if already running
if arti_is_running() != 0 {
SecureLogger.info("TorManager: Arti already running", category: .session)
startBootstrapMonitor()
return
}
let result = dir.withCString { dptr in
arti_start(dptr, UInt16(socksPort))
}
if result != 0 {
SecureLogger.error("TorManager: arti_start failed rc=\(result)", category: .session)
isStarting = false
lastError = NSError(domain: "TorManager", code: Int(result), userInfo: [NSLocalizedDescriptionKey: "Arti start failed"])
return
}
SecureLogger.info("TorManager: arti_start OK (SOCKS \(socksHost):\(socksPort))", category: .session)
startBootstrapMonitor()
// Start SOCKS readiness probe
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if ready {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} else {
self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "SOCKS not reachable"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
}
}
}
}
private func waitForSocksReady(timeout: TimeInterval) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await probeSocksOnce() { return true }
try? await Task.sleep(nanoseconds: 250_000_000)
}
return false
}
private func probeSocksOnce() async -> Bool {
#if canImport(Network)
await withCheckedContinuation { cont in
let params = NWParameters.tcp
let host = NWEndpoint.Host.ipv4(.loopback)
guard let port = NWEndpoint.Port(rawValue: UInt16(socksPort)) else {
cont.resume(returning: false)
return
}
let endpoint = NWEndpoint.hostPort(host: host, port: port)
let conn = NWConnection(to: endpoint, using: params)
var resumed = false
let resumeOnce: (Bool) -> Void = { value in
if !resumed {
resumed = true
cont.resume(returning: value)
}
}
conn.stateUpdateHandler = { state in
switch state {
case .ready:
resumeOnce(true)
conn.cancel()
case .failed, .cancelled:
resumeOnce(false)
conn.cancel()
default:
break
}
}
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) {
resumeOnce(false)
conn.cancel()
}
conn.start(queue: DispatchQueue.global(qos: .utility))
}
#else
return false
#endif
}
// MARK: - Bootstrap Monitoring
private func startBootstrapMonitor() {
guard !bootstrapMonitorStarted else { return }
bootstrapMonitorStarted = true
Task.detached(priority: .utility) { [weak self] in
await self?.bootstrapPollLoop()
}
}
private func bootstrapPollLoop() async {
let deadline = Date().addingTimeInterval(75)
while Date() < deadline {
let progress = Int(arti_bootstrap_progress())
let summary = getBootstrapSummary()
await MainActor.run {
self.bootstrapProgress = progress
self.bootstrapSummary = summary
if progress >= 100 { self.isStarting = false }
self.recomputeReady()
}
if progress >= 100 { break }
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
private func getBootstrapSummary() -> String {
var buf = [CChar](repeating: 0, count: 256)
let len = arti_bootstrap_summary(&buf, Int32(buf.count))
if len > 0 {
return String(cString: buf)
}
return ""
}
// MARK: - Foreground/Background
public func ensureRunningOnForeground() {
if !allowAutoStart { return }
SecureLogger.debug("TorManager: ensureRunningOnForeground() started", category: .session)
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
let claimed: Bool = await MainActor.run {
if self.isStarting || self.restarting { return false }
self.restarting = true
return true
}
if !claimed { return }
// Check if already ready
let alreadyReady = await MainActor.run { self.isReady }
if alreadyReady {
await MainActor.run { self.restarting = false }
return
}
// Arti doesn't support dormant/wake (it's a no-op stub), so always do full restart
await self.restartArti()
await MainActor.run { self.restarting = false }
}
}
public func goDormantOnBackground() {
// Arti doesn't support real dormant mode, so just mark as not ready.
// iOS will suspend the runtime anyway. On foreground we do a full restart.
// Clear isStarting so foreground recovery can proceed if bootstrap was interrupted.
SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session)
Task { @MainActor in
self.isReady = false
self.socksReady = false
self.isStarting = false
}
// Force a fresh egress self-check once Tor comes back.
Task { await egressVerifier.invalidate() }
}
public func shutdownCompletely() {
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
Task.detached { [weak self] in
guard let self = self else { return }
_ = arti_stop()
// Wait for shutdown
var waited = 0
while arti_is_running() != 0 && waited < 50 {
try? await Task.sleep(nanoseconds: 100_000_000)
waited += 1
}
await MainActor.run {
self.isDormant = false
self.isReady = false
self.socksReady = false
self.bootstrapProgress = 0
self.bootstrapSummary = ""
self.isStarting = false
self.didStart = false
self.restarting = false
self.bootstrapMonitorStarted = false
// 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
}
await self.egressVerifier.invalidate()
}
}
private func restartArti() async {
SecureLogger.debug("TorManager: restartArti() starting", category: .session)
await MainActor.run {
NotificationCenter.default.post(name: .TorWillRestart, object: nil)
self.isReady = false
self.socksReady = false
self.bootstrapProgress = 0
self.bootstrapSummary = ""
self.isStarting = true
self.isDormant = false
self.lastRestartAt = Date()
}
// New Arti instance means new circuits; re-verify egress after restart.
await egressVerifier.invalidate()
_ = arti_stop()
// Wait for stop
var waited = 0
while arti_is_running() != 0 && waited < 40 {
try? await Task.sleep(nanoseconds: 100_000_000)
waited += 1
}
await MainActor.run {
self.bootstrapMonitorStarted = false
self.didStart = false
}
await MainActor.run { self.startIfNeeded() }
}
private func recomputeReady() {
let ready = socksReady && bootstrapProgress >= 100
if ready != isReady {
if !ready {
SecureLogger.debug("TorManager: isReady -> false (socksReady=\(socksReady), bootstrap=\(bootstrapProgress))", category: .session)
}
isReady = ready
if ready {
NotificationCenter.default.post(name: .TorDidBecomeReady, object: nil)
}
}
}
private func startPathMonitorIfNeeded() {
#if canImport(Network)
guard pathMonitor == nil else { return }
let monitor = NWPathMonitor()
pathMonitor = monitor
let queue = DispatchQueue(label: "TorPathMonitor")
monitor.pathUpdateHandler = { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if self.isAppForeground {
self.pokeTorOnPathChange()
}
}
}
monitor.start(queue: queue)
#endif
}
private func pokeTorOnPathChange() {
// Skip if we recently restarted
if let last = lastRestartAt, Date().timeIntervalSince(last) < 3.0 {
SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - recent restart", category: .session)
return
}
// Skip during initial startup grace period (15s) to avoid race conditions
if let started = startedAt, Date().timeIntervalSince(started) < 15.0 {
SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - startup grace period (\(Int(Date().timeIntervalSince(started)))s)", category: .session)
return
}
if isStarting || restarting {
SecureLogger.debug("TorManager: pokeTorOnPathChange() skipped - isStarting=\(isStarting) restarting=\(restarting)", category: .session)
return
}
if isReady { return }
SecureLogger.debug("TorManager: pokeTorOnPathChange() - Arti not ready, initiating recovery", category: .session)
ensureRunningOnForeground()
}
}
// MARK: - Start policy configuration
extension TorManager {
@MainActor
public func setAutoStartAllowed(_ allow: Bool) {
allowAutoStart = allow
}
@MainActor
public func isAutoStartAllowed() -> Bool { allowAutoStart }
}