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>
Vendored arti.xcframework rebuilt from source (Rust 1.96.0, normalized
archive metadata for reproducible hashes). New ARTI-BINARY-PROVENANCE.md
records toolchain, rebuild steps, and a SHA256 manifest for every file
in the xcframework. A new CI workflow turns that policy into a gate:
PRs must keep the binary matching the manifest, and binary changes must
ship with source/lockfile/build-script evidence.
Also raises TorManager.awaitReady's default timeout from 25s to 75s to
match the bootstrap monitor deadline - a shorter wait reported "not
ready" while Arti was still legitimately bootstrapping, silently
stranding queued relay work.
Privacy policy, Tor integration doc, and privacy assessment updated to
match the current implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arti's dormant/wake FFI functions are no-op stubs that don't actually
implement dormant mode. The app was wasting 2.5-12 seconds trying to
wake from a dormant state that doesn't exist before falling back to
a full restart.
This change:
- Removes wakeFromDormant() call and function
- Simplifies goDormantOnBackground() to just mark state as not ready
- Goes straight to restartArti() on foreground, eliminating the delay
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Replace C Tor (0.4.8.21) with Rust Arti (1.9.0/arti-client 0.38)
- 70% smaller binary: 21MB xcframework vs 67MB (6.9MB vs 14MB per slice)
- Memory-safe Rust implementation with modern async (tokio)
- Same SOCKS5 proxy interface at 127.0.0.1:39050 for drop-in compatibility
- FFI wrapper (arti-bitchat crate) with C-compatible exports
- Swift TorManager maintains identical public API
- Aggressive size optimization: opt-level=z, lto=fat, panic=abort, strip
- Supports iOS device, iOS simulator (Apple Silicon), and macOS
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>