mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Fail closed on unverified egress and gate the already-ready connect path
Address two review findings on the Tor egress self-check: 1. Bypass on the already-bootstrapped path: shouldWaitForTorBeforeConnecting only checked torIsReady, so once Tor was up, connectToRelays/connectToRelay (initial connect, reconnect backoff timers, manual retry, subscription- triggered connects) opened relay WebSockets without ever running the egress canary. The gate now also requires a fresh cached egress verification (TorManager.isEgressVerified, backed by a nonisolated TTL snapshot on TorEgressVerifier); any path lacking one queues behind awaitEgressReady(). 2. unreachable canary no longer allows: an unreachable canary is exactly the ambiguous case where we cannot tell whether the platform honored the SOCKS proxy, so it now refuses relay connections (fail-closed) instead of allow-with-warning. A verifiedTor verdict still allows connection opens for its 300s TTL (a cached good verdict covers brief canary blips); after TTL expiry or invalidate() (Tor restart/dormant/shutdown) connections stay closed until a probe succeeds again. Probe cadence stays bounded via the verifier's minRetryInterval, and the relay manager schedules a bounded 30s-cadence gate retry after wait-attempt exhaustion so a transient canary outage recovers automatically (GeoRelayDirectory already retries with its own backoff). Tests: verifier policy (unreachable refuses, recovery via retry, cached- within-TTL allows during blip, TTL expiry + unreachable refuses, snapshot invalidation) and relay-manager gating (ready path awaits egress verification, reconnect requeues behind the gate, exhaustion schedules the bounded retry and recovers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,11 @@ struct NostrRelayManagerDependencies {
|
|||||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||||
var torEnforced: () -> Bool
|
var torEnforced: () -> Bool
|
||||||
var torIsReady: () -> Bool
|
var torIsReady: () -> Bool
|
||||||
|
/// Synchronous cached egress-gate check: `true` only while a positive Tor
|
||||||
|
/// egress verification is within its TTL (or Tor is not enforced). When
|
||||||
|
/// `false`, connections must be queued behind `awaitTorReady`, which runs
|
||||||
|
/// the async egress self-check.
|
||||||
|
var torEgressVerified: () -> Bool
|
||||||
var torIsForeground: () -> Bool
|
var torIsForeground: () -> Bool
|
||||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||||
var makeSession: () -> NostrRelaySessionProtocol
|
var makeSession: () -> NostrRelaySessionProtocol
|
||||||
@@ -83,6 +88,7 @@ private extension NostrRelayManagerDependencies {
|
|||||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||||
torEnforced: { TorManager.shared.torEnforced },
|
torEnforced: { TorManager.shared.torEnforced },
|
||||||
torIsReady: { TorManager.shared.isReady },
|
torIsReady: { TorManager.shared.isReady },
|
||||||
|
torEgressVerified: { TorManager.shared.isEgressVerified },
|
||||||
torIsForeground: { TorManager.shared.isForeground() },
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
awaitTorReady: { completion in
|
awaitTorReady: { completion in
|
||||||
Task.detached {
|
Task.detached {
|
||||||
@@ -628,8 +634,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Every path that opens a relay socket funnels through this check (initial
|
||||||
|
/// connect, reconnect backoff timers, subscription-triggered connects,
|
||||||
|
/// manual retry). It must hold connections back when Tor isn't bootstrapped
|
||||||
|
/// OR when the runtime egress self-check has no fresh positive verdict —
|
||||||
|
/// otherwise the already-bootstrapped path would open sockets without ever
|
||||||
|
/// running the egress canary.
|
||||||
private var shouldWaitForTorBeforeConnecting: Bool {
|
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||||
shouldUseTor && !dependencies.torIsReady()
|
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||||
@@ -677,16 +689,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard ready else {
|
guard ready else {
|
||||||
self.torReadyWaitAttempts += 1
|
self.torReadyWaitAttempts += 1
|
||||||
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||||
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
||||||
self.queueConnectionsUntilTorReady(pending)
|
self.queueConnectionsUntilTorReady(pending)
|
||||||
} else {
|
} else {
|
||||||
// Still fail-closed (no network), but unblock any callers
|
// Still fail-closed (no network), but unblock any callers
|
||||||
// waiting on EOSE so the UI doesn't hang indefinitely.
|
// waiting on EOSE so the UI doesn't hang indefinitely.
|
||||||
// Queued subscriptions/sends are kept and flush if a later
|
// Queued subscriptions/sends are kept; a bounded-cadence
|
||||||
// trigger (e.g. app foreground) brings Tor up.
|
// retry (below) re-enters the gate so a transient failure
|
||||||
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
|
// (Tor stall, canary outage keeping the egress unverified)
|
||||||
|
// recovers automatically, and any later trigger (e.g. app
|
||||||
|
// foreground) also re-enters it.
|
||||||
|
SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session)
|
||||||
self.torReadyWaitAttempts = 0
|
self.torReadyWaitAttempts = 0
|
||||||
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
||||||
|
self.scheduleTorGateRetry(pending)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -696,6 +712,24 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After the Tor-gate wait attempts are exhausted, keep a low-frequency
|
||||||
|
/// retry alive so the gate re-opens without an external trigger once the
|
||||||
|
/// transient failure clears. Bounded cadence: one wait cycle per
|
||||||
|
/// `nostrTorGateRetrySeconds`; the egress verifier additionally throttles
|
||||||
|
/// actual canary probes to one per its `minRetryInterval`.
|
||||||
|
private func scheduleTorGateRetry(_ relayUrls: [String]) {
|
||||||
|
guard !relayUrls.isEmpty else { return }
|
||||||
|
let generation = connectionGeneration
|
||||||
|
dependencies.scheduleAfter(TransportConfig.nostrTorGateRetrySeconds) { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// Void after disconnect/reset: those paths bump the generation.
|
||||||
|
guard generation == self.connectionGeneration else { return }
|
||||||
|
self.queueConnectionsUntilTorReady(relayUrls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
|
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
|
||||||
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
||||||
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ enum TransportConfig {
|
|||||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||||
|
// After Tor-gate wait attempts are exhausted (Tor never ready, or egress
|
||||||
|
// self-check unverified), retry the whole gate at this bounded cadence so
|
||||||
|
// a transient failure (e.g. canary outage) recovers without user action.
|
||||||
|
static let nostrTorGateRetrySeconds: TimeInterval = 30.0
|
||||||
static let nostrPendingSendQueueCap: Int = 200
|
static let nostrPendingSendQueueCap: Int = 200
|
||||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||||
|
|||||||
@@ -83,36 +83,119 @@ struct TorEgressVerifierTests {
|
|||||||
#expect(await v.verify() == true)
|
#expect(await v.verify() == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("unreachable allows (session stays fail-closed) but does not cache a Tor verification")
|
@Test("unreachable refuses: an unverified egress must not proceed (fail-closed)")
|
||||||
func unreachableAllowsButNotCached() async {
|
func unreachableRefuses() async {
|
||||||
let h = Harness()
|
let h = Harness()
|
||||||
h.setResult(.unreachable("down"))
|
h.setResult(.unreachable("down"))
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
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)
|
#expect(await v.verify() == false)
|
||||||
|
#expect(v.hasFreshVerification == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("minRetryInterval throttles re-probing when not verified")
|
@Test("unreachable-then-reachable recovers via retry")
|
||||||
|
func unreachableRecoversWhenCanaryReturns() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.unreachable("down"))
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
||||||
|
|
||||||
|
#expect(await v.verify() == false)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
|
||||||
|
// Canary comes back; the next probe (after the retry throttle window)
|
||||||
|
// verifies and allows again.
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
h.advance(5)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 2)
|
||||||
|
#expect(v.hasFreshVerification == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("cached verifiedTor within TTL allows during a canary blip without re-probing")
|
||||||
|
func cachedVerifiedAllowsDuringCanaryBlip() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
|
||||||
|
// The canary goes down inside the TTL window: the cached positive
|
||||||
|
// verdict is authoritative, no probe runs, traffic stays allowed.
|
||||||
|
h.setResult(.unreachable("down"))
|
||||||
|
h.advance(100)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(h.probeCount == 1)
|
||||||
|
#expect(v.hasFreshVerification == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("TTL expiry + unreachable refuses until a probe succeeds again")
|
||||||
|
func expiredCacheWithUnreachableRefuses() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
|
||||||
|
// Past the TTL the old verdict no longer stands: an unreachable canary
|
||||||
|
// means unverified egress, so connection opens are refused.
|
||||||
|
h.setResult(.unreachable("down"))
|
||||||
|
h.advance(301)
|
||||||
|
#expect(v.hasFreshVerification == false)
|
||||||
|
#expect(await v.verify() == false)
|
||||||
|
#expect(h.probeCount == 2)
|
||||||
|
|
||||||
|
// A subsequent successful probe restores service.
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(v.hasFreshVerification == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("minRetryInterval bounds re-probing while unverified (no canary hammering)")
|
||||||
func throttleReprobe() async {
|
func throttleReprobe() async {
|
||||||
let h = Harness()
|
let h = Harness()
|
||||||
h.setResult(.unreachable("down"))
|
h.setResult(.unreachable("down"))
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
#expect(await v.verify() == false)
|
||||||
#expect(h.probeCount == 1)
|
#expect(h.probeCount == 1)
|
||||||
// Within minRetry window: reuse last decision, no new probe.
|
// Within minRetry window: reuse last (refusing) decision, no new probe.
|
||||||
h.advance(1)
|
h.advance(1)
|
||||||
#expect(await v.verify() == true)
|
#expect(await v.verify() == false)
|
||||||
#expect(h.probeCount == 1)
|
#expect(h.probeCount == 1)
|
||||||
// After the window: re-probe.
|
// After the window: re-probe.
|
||||||
h.advance(5)
|
h.advance(5)
|
||||||
#expect(await v.verify() == true)
|
#expect(await v.verify() == false)
|
||||||
#expect(h.probeCount == 2)
|
#expect(h.probeCount == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("invalidate clears the synchronous cache snapshot")
|
||||||
|
func invalidateClearsSnapshot() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(v.hasFreshVerification == true)
|
||||||
|
await v.invalidate()
|
||||||
|
#expect(v.hasFreshVerification == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("notTor drops any cached verification snapshot")
|
||||||
|
func notTorClearsSnapshot() async {
|
||||||
|
let h = Harness()
|
||||||
|
h.setResult(.verifiedTor)
|
||||||
|
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||||
|
|
||||||
|
#expect(await v.verify() == true)
|
||||||
|
#expect(v.hasFreshVerification == true)
|
||||||
|
|
||||||
|
h.setResult(.notTor)
|
||||||
|
h.advance(301)
|
||||||
|
#expect(await v.verify() == false)
|
||||||
|
#expect(v.hasFreshVerification == false)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("invalidate forces a fresh probe")
|
@Test("invalidate forces a fresh probe")
|
||||||
func invalidateForcesReprobe() async {
|
func invalidateForcesReprobe() async {
|
||||||
let h = Harness()
|
let h = Harness()
|
||||||
|
|||||||
@@ -111,6 +111,116 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async {
|
||||||
|
// Tor is bootstrapped, but the egress self-check has no fresh verdict:
|
||||||
|
// the ready path must still queue behind the async egress gate instead
|
||||||
|
// of opening sockets directly.
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .authorized,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: true,
|
||||||
|
torEgressVerified: false
|
||||||
|
)
|
||||||
|
|
||||||
|
context.manager.connect()
|
||||||
|
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||||
|
|
||||||
|
// Egress verification succeeds (awaitEgressReady returned true, which
|
||||||
|
// implies the verifier now holds a fresh cached verdict).
|
||||||
|
context.torEgressVerified.value = true
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
|
||||||
|
let connectedAfterVerification = await waitUntil {
|
||||||
|
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||||
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connectedAfterVerification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async {
|
||||||
|
// Reconnect backoff timers call connectToRelay directly; when the
|
||||||
|
// cached egress verification has lapsed by then, the reconnect must go
|
||||||
|
// back through the gate rather than opening a socket.
|
||||||
|
let relayURL = "wss://egress-reconnect.example"
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .denied,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: true,
|
||||||
|
torEgressVerified: true
|
||||||
|
)
|
||||||
|
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
let connected = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
||||||
|
|
||||||
|
// The socket drops and the cached verification expires meanwhile.
|
||||||
|
context.torEgressVerified.value = false
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?
|
||||||
|
.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
|
||||||
|
let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
|
||||||
|
XCTAssertTrue(reconnectScheduled)
|
||||||
|
|
||||||
|
context.scheduler.runNext()
|
||||||
|
let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 }
|
||||||
|
XCTAssertTrue(queuedBehindGate)
|
||||||
|
// No new socket until the egress gate passes.
|
||||||
|
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
||||||
|
|
||||||
|
context.torEgressVerified.value = true
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
|
||||||
|
let reconnected = await waitUntil {
|
||||||
|
context.sessionFactory.requestedURLs.count == 2 &&
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(reconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async {
|
||||||
|
// A persistent unverified egress exhausts the wait attempts; a bounded
|
||||||
|
// low-frequency retry must then recover automatically once
|
||||||
|
// verification succeeds (e.g. transient canary outage ends).
|
||||||
|
let relayURL = "wss://egress-gate-retry.example"
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .denied,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: true,
|
||||||
|
torEgressVerified: false
|
||||||
|
)
|
||||||
|
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||||
|
context.torWaiter.resolve(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-closed, with a single bounded-cadence retry scheduled.
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
XCTAssertEqual(context.scheduler.scheduled.count, 1)
|
||||||
|
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrTorGateRetrySeconds)
|
||||||
|
|
||||||
|
// The outage ends before the retry fires.
|
||||||
|
let attemptsBefore = context.torWaiter.awaitCallCount
|
||||||
|
context.scheduler.runNext()
|
||||||
|
let regated = await waitUntil { context.torWaiter.awaitCallCount == attemptsBefore + 1 }
|
||||||
|
XCTAssertTrue(regated)
|
||||||
|
|
||||||
|
context.torEgressVerified.value = true
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
|
||||||
|
let recovered = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(recovered)
|
||||||
|
}
|
||||||
|
|
||||||
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
||||||
let relayURL = "wss://tor-eose-unblock.example"
|
let relayURL = "wss://tor-eose-unblock.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -1449,6 +1559,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
userTorEnabled: Bool = false,
|
userTorEnabled: Bool = false,
|
||||||
torEnforced: Bool = false,
|
torEnforced: Bool = false,
|
||||||
torIsReady: Bool = true,
|
torIsReady: Bool = true,
|
||||||
|
torEgressVerified: Bool = true,
|
||||||
torIsForeground: Bool = true,
|
torIsForeground: Bool = true,
|
||||||
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
||||||
) -> RelayManagerTestContext {
|
) -> RelayManagerTestContext {
|
||||||
@@ -1458,6 +1569,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
let scheduler = MockRelayScheduler()
|
let scheduler = MockRelayScheduler()
|
||||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||||
|
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
|
||||||
let torForeground = MutableBool(value: torIsForeground)
|
let torForeground = MutableBool(value: torIsForeground)
|
||||||
let activationFlag = MutableBool(value: activationAllowed)
|
let activationFlag = MutableBool(value: activationAllowed)
|
||||||
let manager = NostrRelayManager(
|
let manager = NostrRelayManager(
|
||||||
@@ -1470,6 +1582,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||||
torEnforced: { torEnforced },
|
torEnforced: { torEnforced },
|
||||||
torIsReady: { torWaiter.isReady },
|
torIsReady: { torWaiter.isReady },
|
||||||
|
torEgressVerified: { torEgressVerifiedFlag.value },
|
||||||
torIsForeground: { torForeground.value },
|
torIsForeground: { torForeground.value },
|
||||||
awaitTorReady: torWaiter.await(completion:),
|
awaitTorReady: torWaiter.await(completion:),
|
||||||
makeSession: { sessionFactory },
|
makeSession: { sessionFactory },
|
||||||
@@ -1489,6 +1602,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
clock: clock,
|
clock: clock,
|
||||||
activationAllowed: activationFlag,
|
activationAllowed: activationFlag,
|
||||||
torWaiter: torWaiter,
|
torWaiter: torWaiter,
|
||||||
|
torEgressVerified: torEgressVerifiedFlag,
|
||||||
torForeground: torForeground
|
torForeground: torForeground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1543,6 +1657,7 @@ private struct RelayManagerTestContext {
|
|||||||
let clock: MutableClock
|
let clock: MutableClock
|
||||||
let activationAllowed: MutableBool
|
let activationAllowed: MutableBool
|
||||||
let torWaiter: MockTorWaiter
|
let torWaiter: MockTorWaiter
|
||||||
|
let torEgressVerified: MutableBool
|
||||||
let torForeground: MutableBool
|
let torForeground: MutableBool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,34 @@ import Foundation
|
|||||||
/// connections are opened under enforced Tor, it performs a canary request whose
|
/// connections are opened under enforced Tor, it performs a canary request whose
|
||||||
/// response positively reports whether the egress hit the network via Tor.
|
/// response positively reports whether the egress hit the network via Tor.
|
||||||
///
|
///
|
||||||
/// Policy (`verify()` return value):
|
/// Policy (`verify()` return value) — fail-closed on unverified egress:
|
||||||
/// - `.verifiedTor` → allow, and cache the positive result for `ttl`.
|
/// - `.verifiedTor` → allow, and cache the positive result for `ttl`.
|
||||||
/// - `.notTor` → REFUSE. The canary reached the internet but the exit
|
/// - `.notTor` → REFUSE, and drop any cached verification. The canary
|
||||||
/// is NOT a Tor node: a real leak. Never allow relays.
|
/// reached the internet but the exit is NOT a Tor node:
|
||||||
/// - `.unreachable` → allow-with-warning. The canary itself failed (endpoint
|
/// a real leak. Never allow relays.
|
||||||
/// down / circuit not built). This does NOT egress direct:
|
/// - `.unreachable` → REFUSE (egress unverified). The canary itself failed
|
||||||
/// the relay session is independently fail-closed, so the
|
/// (endpoint down / circuit not built), so we cannot tell
|
||||||
/// worst case is that relay connections also fail. We do
|
/// whether the platform honored the SOCKS proxy — the
|
||||||
/// not hard-block here to avoid taking Nostr fully offline
|
/// exact ambiguity this verifier exists to resolve.
|
||||||
/// whenever the single canary host is unavailable.
|
/// Unverified traffic must not proceed on the
|
||||||
|
/// enforced-Tor path.
|
||||||
|
///
|
||||||
|
/// TTL / retry semantics:
|
||||||
|
/// - A `verifiedTor` verdict allows connection *opens* for `ttl` without
|
||||||
|
/// re-probing, so a brief canary blip inside the TTL window does not take
|
||||||
|
/// relays offline (a fresh positive verdict is authoritative for the
|
||||||
|
/// window). Already-open sockets are never torn down by verification —
|
||||||
|
/// they were opened under a verified egress and the proxied session is
|
||||||
|
/// fail-closed by construction.
|
||||||
|
/// - After TTL expiry (or `invalidate()` on Tor restart/dormant/shutdown),
|
||||||
|
/// the next `verify()` re-probes; while the canary stays `.unreachable`,
|
||||||
|
/// new connection opens are refused until a probe succeeds again.
|
||||||
|
/// - Probe cadence is bounded: at most one probe per `minRetryInterval`
|
||||||
|
/// (callers within the window reuse the last decision), and concurrent
|
||||||
|
/// `verify()` calls share one in-flight probe. Recovery from a transient
|
||||||
|
/// canary outage is automatic: callers that keep retrying (relay connect
|
||||||
|
/// gate, GeoRelayDirectory backoff) re-probe and succeed once the canary
|
||||||
|
/// is reachable again.
|
||||||
///
|
///
|
||||||
/// The probe is injectable so the policy/caching logic is unit-tested without a
|
/// The probe is injectable so the policy/caching logic is unit-tested without a
|
||||||
/// live network (see `TorEgressVerifierTests`).
|
/// live network (see `TorEgressVerifierTests`).
|
||||||
@@ -51,6 +69,29 @@ public actor TorEgressVerifier {
|
|||||||
private var lastResult: ProbeResult?
|
private var lastResult: ProbeResult?
|
||||||
private var inFlight: Task<Bool, Never>?
|
private var inFlight: Task<Bool, Never>?
|
||||||
|
|
||||||
|
/// Lock-protected mirror of "verified within TTL" so synchronous gates
|
||||||
|
/// (e.g. `NostrRelayManager`'s connect path) can consult the cache without
|
||||||
|
/// awaiting the actor.
|
||||||
|
private let verifiedSnapshot = VerifiedSnapshot()
|
||||||
|
|
||||||
|
private final class VerifiedSnapshot: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var verifiedUntil: Date?
|
||||||
|
|
||||||
|
func update(_ until: Date?) {
|
||||||
|
lock.lock()
|
||||||
|
verifiedUntil = until
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFresh(at date: Date) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard let verifiedUntil else { return false }
|
||||||
|
return date < verifiedUntil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
ttl: TimeInterval,
|
ttl: TimeInterval,
|
||||||
minRetryInterval: TimeInterval = 5.0,
|
minRetryInterval: TimeInterval = 5.0,
|
||||||
@@ -69,14 +110,23 @@ public actor TorEgressVerifier {
|
|||||||
lastVerifiedAt = nil
|
lastVerifiedAt = nil
|
||||||
lastProbeAt = nil
|
lastProbeAt = nil
|
||||||
lastResult = nil
|
lastResult = nil
|
||||||
|
verifiedSnapshot.update(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The most recent probe outcome, for diagnostics/tests.
|
/// The most recent probe outcome, for diagnostics/tests.
|
||||||
public func lastProbeResult() -> ProbeResult? { lastResult }
|
public func lastProbeResult() -> ProbeResult? { lastResult }
|
||||||
|
|
||||||
/// Returns `true` when it is safe to open relay connections through the
|
/// Synchronous view of the cache: `true` while a `verifiedTor` verdict is
|
||||||
/// proxied session, `false` only when a non-Tor egress was positively
|
/// within its TTL. Callers that get `false` must route through the async
|
||||||
/// detected. See the type doc for the full policy.
|
/// `verify()` gate (which probes) before opening connections.
|
||||||
|
public nonisolated var hasFreshVerification: Bool {
|
||||||
|
verifiedSnapshot.isFresh(at: now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` only when the proxied egress is verified to exit via Tor
|
||||||
|
/// (a fresh probe or a cached `verifiedTor` verdict within TTL). Returns
|
||||||
|
/// `false` when a non-Tor egress was positively detected *or* when the
|
||||||
|
/// egress could not be verified. See the type doc for the full policy.
|
||||||
public func verify() async -> Bool {
|
public func verify() async -> Bool {
|
||||||
if isFreshlyVerified() { return true }
|
if isFreshlyVerified() { return true }
|
||||||
// Throttle re-probes when the last attempt did not verify.
|
// Throttle re-probes when the last attempt did not verify.
|
||||||
@@ -102,8 +152,9 @@ public actor TorEgressVerifier {
|
|||||||
private func decision(for result: ProbeResult) -> Bool {
|
private func decision(for result: ProbeResult) -> Bool {
|
||||||
switch result {
|
switch result {
|
||||||
case .verifiedTor: return true
|
case .verifiedTor: return true
|
||||||
case .unreachable: return true
|
// Fail closed: both a positively detected leak and an unverifiable
|
||||||
case .notTor: return false
|
// egress refuse connections. Only a fresh `verifiedTor` allows.
|
||||||
|
case .unreachable, .notTor: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,21 +165,24 @@ public actor TorEgressVerifier {
|
|||||||
switch result {
|
switch result {
|
||||||
case .verifiedTor:
|
case .verifiedTor:
|
||||||
lastVerifiedAt = now()
|
lastVerifiedAt = now()
|
||||||
|
verifiedSnapshot.update(now().addingTimeInterval(ttl))
|
||||||
return true
|
return true
|
||||||
case .notTor:
|
case .notTor:
|
||||||
lastVerifiedAt = nil
|
lastVerifiedAt = nil
|
||||||
|
verifiedSnapshot.update(nil)
|
||||||
SecureLogger.error(
|
SecureLogger.error(
|
||||||
"🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)",
|
"🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
case .unreachable(let why):
|
case .unreachable(let why):
|
||||||
lastVerifiedAt = nil
|
// Note: a probe only runs when no fresh cached verdict exists, so
|
||||||
|
// there is no still-valid cache to preserve or drop here.
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
"🧅 Tor egress self-check could not complete (\(why)); relay session remains fail-closed via SOCKS proxy",
|
"🧅 Tor egress self-check could not complete (\(why)) — egress UNVERIFIED; refusing relay connections until the canary succeeds (bounded retry)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,11 +133,21 @@ public final class TorManager: ObservableObject {
|
|||||||
return await MainActor.run(body: { self.networkPermitted })
|
return await MainActor.run(body: { self.networkPermitted })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronous, cached view of the egress gate: `true` while a positive
|
||||||
|
/// egress verification is within its TTL (or when Tor is not enforced).
|
||||||
|
/// When this is `false`, callers must route through `awaitEgressReady()`
|
||||||
|
/// (which probes) before opening any connection — never connect directly.
|
||||||
|
public var isEgressVerified: Bool {
|
||||||
|
guard torEnforced else { return true }
|
||||||
|
return egressVerifier.hasFreshVerification
|
||||||
|
}
|
||||||
|
|
||||||
/// Like `awaitReady`, but additionally requires that a canary request
|
/// Like `awaitReady`, but additionally requires that a canary request
|
||||||
/// through the proxied session positively verifies Tor egress. Returns
|
/// through the proxied session positively verifies Tor egress. Returns
|
||||||
/// `false` if Tor never became ready, or if the egress self-check
|
/// `false` if Tor never became ready, or if the egress self-check could
|
||||||
/// positively detected a non-Tor (direct) egress. Callers must fail closed
|
/// not positively verify a Tor exit (non-Tor egress detected, or canary
|
||||||
/// on `false` — never fall back to a direct connection.
|
/// unreachable → unverified). Callers must fail closed on `false` — never
|
||||||
|
/// fall back to a direct connection.
|
||||||
nonisolated
|
nonisolated
|
||||||
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
|
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
|
||||||
let ready = await awaitReady(timeout: timeout)
|
let ready = await awaitReady(timeout: timeout)
|
||||||
|
|||||||
Reference in New Issue
Block a user