The Tor egress self-check probe had no outer bound: the proxied session
sets waitsForConnectivity = true, which can defer the per-request timer
indefinitely, leaving a canary request bounded only by URLSession's
default 7-day resource timeout. Because verify() joins concurrent
callers on the in-flight task and invalidate() neither cancelled nor
cleared it, one hung probe wedged every subsequent verify() caller --
even across a Tor restart -- parking awaitingTorForConnections in
NostrRelayManager until process restart.
Fixes (liveness only; the fail-closed policy is unchanged):
- Race every probe against an independent async watchdog
(probeTimeout, default 20s = the live probe's request timeout, via
TorEgressVerifier.defaultProbeTimeout). On timeout the probe task is
cancelled (cooperatively cancelling the underlying URLSessionTask),
the verdict is the fail-closed .unreachable, and the in-flight slot
is cleared so the next verify() starts fresh.
- invalidate() now cancels the in-flight probe and clears it (plus the
throttle timestamp it already cleared), so Tor restart/dormant/
shutdown genuinely resets the verifier.
- A probe generation counter keeps actor reentrancy safe: a cancelled/
superseded probe cannot re-seed the throttle/cache that invalidate()
just cleared or clobber a fresh probe's in-flight slot; its awaiting
callers resolve promptly as false.
Concurrent-caller join semantics are preserved (one shared probe), and
the race resolves exactly once behind an NSLock never held across an
await. Tests cover the hung-probe timeout bound, invalidate-cancels-
in-flight, post-restart recovery, and the shared-probe invariant, all
with the injected probe/clock harness (no real network).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>