A lightweight embedded Tor client for iOS/macOS by Olib AI
Used in StealthOS - The privacy-focused operating environment.
This package provides a native Swift wrapper around the Tor C library, enabling anonymous network access directly within your app without external dependencies.
Because we can. We wanted a clean, slim Tor client that we fully control — from source compilation to the final binary. No bloated wrappers, no outdated dependencies dragged in from abandoned repos, no guessing what's inside the black box. Just Tor compiled from official sources, statically linked, with a modern Swift API on top. Full transparency, full control, minimal footprint.
- Embedded Tor Daemon: Full Tor implementation compiled as a static library - no external processes or dependencies
- Client-Only Mode: Optimized build with relay functionality disabled for smaller footprint
- Auto Port Selection: Automatic SOCKS5 port assignment to avoid conflicts with other Tor instances
- Connection Status Monitoring: Real-time bootstrap progress tracking via stdout parsing
- Swift Concurrency: Modern async/await API with strict Swift 6 concurrency compliance
- Actor Isolation: Thread-safe
TorServiceactor ensures data race prevention - Circuit Management: Extended circuit lifetime settings for stable long-running connections
- New Circuit on Demand: Request new Tor identity (SIGNAL NEWNYM) via in-process control socket
- Real Disconnect / Reconnect:
disconnect()/reconnect()toggleDisableNetworkover the control socket - all circuits, connections and the SOCKS listener close; reconnect re-bootstraps in seconds, any number of times - In-Process Restart:
stop()letstor_run_main()return and clean up;start()can launch it again in the same process (with the patches inScripts/patches/) - Validated Control Protocol: replies are parsed per control-spec (status codes, multi-line, data blocks, size limits), commands are built from validated input only (no CR/LF injection)
- iOS 18.0+
- Swift 6.0+
- Xcode 16+
To build the XCFramework from source, you also need:
- macOS with Apple Silicon (arm64) or Intel Mac
- Xcode Command Line Tools (
xcode-select --install) - Homebrew packages:
brew install autoconf automake libtool pkg-config gnupgThe TorClientC.xcframework is included pre-built for convenience. If you want to build it yourself (to verify, modify, or update versions):
# From the TorClient package directory:
./Scripts/build-xcframework.sh
# Or clean and rebuild:
./Scripts/build-xcframework.sh --clean
# Only download + verify the pinned tarballs:
./Scripts/build-xcframework.sh --sources-onlyThe build script will:
- Download source tarballs from official mirrors (zlib, OpenSSL, libevent, Tor) and verify them before use (cached copies included): the SHA-256 pinned in
Scripts/supply-chain/sources.lock, plus the upstream OpenPGP signature checked against the pinned keyrings inScripts/supply-chain/keys/(Tor's signed.sha256sum, OpenSSL, zlib and libevent detached signatures inScripts/supply-chain/signatures/). Any mismatch aborts the build; nothing is re-downloaded over a bad file - Apply the local Tor patches in
Scripts/patches/(each has a header explaining the bug; they are upstream candidates). A cached Tor build is redone automatically when the patch set changes - Cross-compile each library for iOS device arm64 and arm64e (pointer authentication) and simulator (arm64 + x86_64 stub), with hardening on every C file:
-ftrivial-auto-var-init=zero, typed allocator support (-ftyped-memory-operations) and Apple's security warning set. Warning counts land in.build-tor/logs/warnings-summary.txt - Combine all libraries into a single
libTorClient.aper platform; the device library is one fat arm64 + arm64e archive (xcframework identifierios-arm64_arm64e) - Package everything into
TorClientC.xcframeworkand record every file's SHA-256 inTorClientC.xcframework.sha256
The build is reproducible: two builds from different directories produce
byte-identical xcframeworks (fixed SOURCE_DATE_EPOCH, ZERO_AR_DATE,
-ffile-prefix-map, neutral install prefixes, so no build-machine path ends up in the
binary). To check, build twice and compare the manifests:
TORCLIENT_BUILD_DIR=/tmp/tc-a TORCLIENT_OUTPUT_DIR=/tmp/tc-a ./Scripts/build-xcframework.sh
diff <(grep -v '^#' TorClientC.xcframework.sha256) <(grep -v '^#' /tmp/tc-a/TorClientC.xcframework.sha256)StealthOS's Scripts/verify-release.sh checks the shipped xcframework against
TorClientC.xcframework.sha256 before every release.
./Scripts/test-supply-chain.sh proves the checks fail closed: tampered cached
tarballs, unpinned files, edited pins, corrupted signatures, the wrong key, a forged
Tor checksum file, and an end-to-end build run on a tampered tarball must all abort.
Bumping a version: download the new tarball and its signature, verify the
signature by hand against the fingerprints the project publishes, then update the
line in sources.lock and the file in signatures/ (see the comments in
sources.lock). Never copy a hash from a tarball you have not verified.
| Patch | Fixes |
|---|---|
0001-workqueue-per-pool-worker-accounting.patch |
Function-level statics in worker_thread_main() survive threadpool_free(); the second tor_run_main() in a process then frees its CPU-worker pool while workers still run and aborts with "Error unlocking a mutex" |
0002-tor-api-owning-controller-socket-double-close.patch |
tor_main_configuration_free() closed the owning-controller descriptor that tor_run_main() had already closed, which can close an unrelated descriptor in the embedding app |
0003-dispatch-naming-reset-on-shutdown.patch |
The pubsub name maps (message/subsystem/channel/type name to ID) survive tor_run_main(), but their hashes use a siphash key that every run re-randomizes; after a few in-process restarts one name resolves to two IDs and pubsub_install() aborts |
0004-time-fmt-arithmetic-timegm-on-apple.patch |
tor_timegm() used the system timegm(), a notifyd IPC per call in the iOS simulator; reloading a cached consensus (~8000 calls, before the main loop runs) left the control port silent for 5-10 s after every start, so GETINFO / SIGNAL right after start() timed out |
| Library | Version | Source |
|---|---|---|
| Tor | 0.4.9.15 | https://dist.torproject.org/ |
| OpenSSL | 4.1.0 | https://github.com/openssl/openssl |
| libevent | 2.1.13-stable | https://github.com/libevent/libevent |
| zlib | 1.3.2 | https://zlib.net/ |
Build time: ~10-30 minutes depending on CPU. OpenSSL is the longest step.
Downloaded source tarballs are cached in .build-tor/ and reused on subsequent runs.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/Olib-AI/TorClient.git", from: "1.3.0")
]Then add the dependency to your target:
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "TorClientWrapper", package: "TorClient")
]
)
]If using XcodeGen, add to your project.yml:
packages:
TorClient:
path: LocalPackages/TorClient
targets:
YourApp:
dependencies:
- package: TorClient
product: TorClientWrapperThen regenerate: xcodegen generate
import TorClientWrapper
// Get the shared TorService instance
let torService = TorService.shared
// Configure Tor
let dataDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("tor")
let config = TorConfiguration(
dataDirectory: dataDirectory,
// App-private Unix socket: no other app on the device can reach it (a TCP port on
// 127.0.0.1 is reachable by every app). Must fit sun_path (103 bytes).
socksPort: .unix(path: TorSocksSocketLocation.plan()!),
clientOnly: true, // No relay functionality
avoidDiskWrites: true // Minimize disk I/O
)
do {
// Configure and start Tor
try await torService.configure(config)
try await torService.start()
// Wait for Tor to bootstrap (connect to the network)
try await torService.waitForBootstrap(timeout: 120)
// Get the SOCKS5 proxy URL
if let proxyURL = await torService.socksProxyURL {
print("Tor SOCKS5 proxy: \(proxyURL)")
// Use with URLSession or other networking
}
// The listener Tor actually opened (re-read after every reconnect)
let listener = await torService.actualSocksListener
print("Tor SOCKS listener: \(String(describing: listener))")
} catch {
print("Tor error: \(error)")
}URLSession cannot address a Unix-socket SOCKS proxy; with .unix route URLSession
traffic through an in-app HTTP CONNECT proxy that speaks SOCKS5 to the socket (as
StealthOS's LocalProxyServer does). With .auto / .fixed (TCP):
let port = await torService.actualSocksPort
let sessionConfig = URLSessionConfiguration.ephemeral
sessionConfig.connectionProxyDictionary = [
kCFProxyTypeKey as String: kCFProxyTypeSOCKS,
kCFStreamPropertySOCKSProxyHost as String: "127.0.0.1",
kCFStreamPropertySOCKSProxyPort as String: port
]
let session = URLSession(configuration: sessionConfig)
let (data, response) = try await session.data(from: url)| Property | Type | Default | Description |
|---|---|---|---|
dataDirectory |
URL |
Required | Directory for Tor state files (keys, consensus, etc.) |
socksPortMode |
PortMode |
.auto |
SOCKS5 listener: .auto, .fixed(port) or .unix(path:) (app-private socket, directory created 0700, not group/world writable) |
bridges |
[String] |
[] |
Bridge relay addresses for censored networks |
clientOnly |
Bool |
true |
Disable relay/exit functionality |
avoidDiskWrites |
Bool |
true |
Minimize disk I/O for privacy |
geoIPFile |
URL? |
nil |
Path to GeoIP database file |
geoIP6File |
URL? |
nil |
Path to GeoIPv6 database file |
public enum PortMode: Sendable, Equatable {
case auto // Let Tor select an available port
case fixed(UInt16) // Use a specific port number
}Recommendation: Use .auto to avoid port conflicts with other Tor instances or services.
TorService is the main interface for controlling the Tor daemon. It is implemented as a Swift actor for thread safety.
/// Shared singleton instance
public static let shared: TorService
/// Whether Tor daemon is currently running
public var isRunning: Bool { get async }
/// Whether Tor is ready to accept connections (running and bootstrapped)
public var isReady: Bool { get async }
/// The actual SOCKS port Tor bound to (0 if not yet discovered, or a Unix socket)
public var actualSocksPort: UInt16 { get async }
/// The SOCKS listener Tor opened, TCP or Unix socket (nil while disconnected)
public var actualSocksListener: TorSocksListener? { get async }
/// Current Tor status
public var status: TorStatus { get async }
/// SOCKS5 proxy URL (nil if not running)
public var socksProxyURL: URL? { get async }/// Configure Tor with the specified settings
/// Must be called before start(). Cannot reconfigure while running.
public func configure(_ config: TorConfiguration) async throws
/// Start the Tor daemon
/// If already running, returns immediately without error.
public func start() async throws
/// Stop the Tor daemon: SIGNAL HALT, then wait for tor_run_main() to return
/// (tor_cleanup() runs). start() may launch Tor again afterwards when
/// TorService.supportsInProcessRestart is true. Throws .shutdownFailed if
/// Tor does not exit in time (start() then refuses a second instance).
public func stop() async throws
/// Wait for Tor to fully bootstrap
/// - Parameter timeout: Maximum time to wait (default: 120 seconds)
public func waitForBootstrap(timeout: TimeInterval = 120) async throws
/// Real network disconnect without stopping the daemon: SETCONF DisableNetwork=1,
/// verified with GETCONF/GETINFO. Closes all circuits, connections and the SOCKS
/// listener. Throws TorControlError on any failure - never assumes success.
public func disconnect(timeout: Duration = .seconds(15)) async throws
/// SETCONF DisableNetwork=0, then waits for an established circuit and an open
/// SOCKS listener. The SOCKS port usually changes (SocksPort auto): re-read
/// actualSocksPort. Time suspended in the background does not use up `timeout`.
public func reconnect(timeout: Duration = .seconds(120),
onProgress: (@Sendable (Int) -> Void)? = nil) async throws
/// .stopped / .enabled / .disabled (soft-disconnected)
public var networkState: TorNetworkState { get async }
/// One GETINFO round trip: circuit-established, network-liveness,
/// bootstrap-phase, SOCKS listeners.
public func networkSnapshot(timeout: Duration = .seconds(10)) async throws -> TorNetworkSnapshot
/// Send any validated command (TorControlCommand.getInfo / getConf / setConf / signal).
public func sendControlCommand(_ command: TorControlCommand,
timeout: Duration = .seconds(10)) async throws -> TorControlReply
/// Request a new Tor circuit (new identity / exit node)
/// Sends SIGNAL NEWNYM via the in-process control socket.
/// Throws TorControlError.networkDisabled while disconnected.
public func sendNewnym() async throws
/// Whether the control socket is available for sending commands
public var hasControlSocket: Bool { get }
/// Get current bootstrap progress (0-100)
public func getBootstrapProgress() async -> Int?
/// Stream of status updates
public var statusStream: AsyncStream<TorStatus> { get async }public enum TorStatus: Sendable, Equatable {
case idle // Not started
case starting // Starting up
case connecting // Connecting to network
case bootstrapping(progress: Int) // Bootstrap in progress (0-100%)
case ready // Fully connected and ready
case stopping // Shutting down
case error(TorError) // Error occurred
}public enum TorError: Error, Sendable {
case configurationFailed(String) // Configuration issue
case startFailed(String) // Failed to start daemon
case alreadyRunning // Tor already running (configure error)
case notRunning // Tor not running (operation error)
case bootstrapTimeout // Bootstrap took too long
case connectionFailed // Network connection failed
case shutdownFailed // Failed to shut down cleanly
case controlSocketFailed // Control socket error
}Only one tor_run_main() may run at a time: Tor's globals are per process. The old "assertion in hs_circuitmap_init() on restart" was a second instance started while the first had never returned. stop() now waits for tor_run_main() to return; with Scripts/patches/0001 a following start() works (exercised 10x per process in the StealthOS test suite, including under AddressSanitizer). Without the patch the second shutdown aborts.
Best Practice: for a user-facing "disconnect", prefer disconnect() / reconnect(): no thread restart, cached consensus, reconnects in seconds.
This implementation uses tor_main_configuration_setup_control_socket() to obtain a pre-authenticated in-process control socket instead of a TCP control port. Status monitoring is done by parsing Tor's stdout. This approach:
- Avoids TCP port conflicts
- Reduces attack surface: an anonymous
AF_UNIXsocketpair has no filesystem path and no TCP port, so no other app or process can connect to it; it needs no cookie or password because only this process holds the descriptor - Works reliably on iOS where socket permissions may be restricted
- Enables
SIGNAL NEWNYM,DisableNetworkandGETINFOat runtime - Is the owning controller connection: closing it makes Tor exit
- Is protected against
SIGPIPE(SO_NOSIGPIPE) and every wait on it is bounded by a timeout
TorService is an actor. All property access and method calls must use await from non-isolated contexts:
// Correct
let port = await TorService.shared.actualSocksPort
// Incorrect - will not compile
let port = TorService.shared.actualSocksPortTorClient/
├── Package.swift # Swift Package manifest
├── Scripts/
│ ├── build-xcframework.sh # Build Tor + deps from source (hardened, reproducible)
│ ├── test-supply-chain.sh # Proves tarball verification fails closed
│ ├── openssl/ios64e.conf # OpenSSL target for the arm64e slice
│ ├── supply-chain/ # sources.lock (pinned SHA-256), keys/, signatures/, verify.sh
│ └── patches/ # Local Tor patches (upstream candidates)
├── Sources/
│ └── TorClientWrapper/
│ ├── TorService.swift # Swift actor wrapping Tor C API
│ ├── TorControlProtocol.swift # Pure reply parser, command builder, value helpers
│ └── TorControlChannel.swift # Bounded, serialized control-socket I/O
├── TorClientC.xcframework.sha256 # SHA-256 of every xcframework file (reproducible build)
└── TorClientC.xcframework/ # Pre-built static libraries
├── ios-arm64_arm64e/ # iOS Device: one fat archive, arm64 + arm64e
│ ├── Headers/
│ │ ├── tor_api.h # Tor C API
│ │ ├── module.modulemap
│ │ └── openssl/ # OpenSSL headers
│ └── libTorClient.a # Combined static library (~13MB per architecture)
└── ios-arm64_x86_64-simulator/
└── ... # Simulator libraries (arm64 real + x86_64 stub)
An app that links TorClient for a device builds both architectures when it enables
pointer authentication (ENABLE_POINTER_AUTHENTICATION); the linker takes the
matching half of the fat archive. Apps that build arm64 only are unaffected.
libTorClient.a is a combined static library containing:
- Tor 0.4.9.15: The Onion Router (client-only build)
- OpenSSL 4.1.0: Cryptographic library
- libevent 2.1.13: Event notification library
- zlib 1.3.2: Compression library
All dependencies are statically linked into libTorClient.a, which is linked into
exactly one image: the TorClientWrapper product is a dynamic library, so the app
embeds TorClientWrapper.framework once and every framework, extension or test bundle
that uses Tor binds to that single copy. Tor and OpenSSL keep process-wide globals;
they must not be copied into several images. For the same reason the package declares
no linker flags (they would propagate to every client) and the module map carries no
link directive (it would ask every importing module's linker for the archive).
- Initialization:
TorService.configure()creates the data directory and stores configuration - Startup:
TorService.start()launchestor_run_main()in a background thread - Port Discovery: stdout is parsed to find the auto-assigned SOCKS5 port
- Bootstrap Monitoring: stdout is parsed for bootstrap progress (0-100%)
- Control Socket:
tor_main_configuration_setup_control_socket()provides a pre-authenticated control connection for runtime commands - SOCKS5 Proxy: Applications route traffic through
127.0.0.1:<port> - New Circuit:
sendNewnym()writesSIGNAL NEWNYMto the control socket, forcing Tor to build new circuits with a different exit node - Disconnect / Reconnect:
disconnect()/reconnect()toggleDisableNetwork; the result is verified viaGETCONF/GETINFO(note:network-livenessonly flips to "down" after circuit timeouts, sostatus/circuit-establishedandnet/listeners/socksare what prove a disconnect) - Stop / Restart:
stop()sendsSIGNAL HALTand waits fortor_run_main()to return;start()can launch a new instance afterwards
The Swift wrapper sets these Tor options automatically:
--DataDirectory /path/to/tor # State storage
--SocksPort unix:/path | auto # Unix socket (StealthOS) or auto port selection
--ControlPort 0 # Disable TCP control port (uses in-process socket instead)
--ClientOnly 1 # No relay functionality
--DirCache 0 # Disable directory cache
--HiddenServiceStatistics 0 # Disable HS stats
--AvoidDiskWrites 1 # Minimize disk I/O
--Log notice stdout # Log to stdout for parsing
--MaxCircuitDirtiness 1800 # 30 min circuit lifetime
--NewCircuitPeriod 60 # Less aggressive circuit building
--CircuitBuildTimeout 90 # Patient circuit building
--SocksTimeout 120 # Generous SOCKS timeout
The wrapper logs through unified logging (subsystem ai.olib.stealthos.torclient, categories TorService and TorThread); reply text and anything that may identify the user is .private, and Tor's command line is never logged because it can carry upstream-proxy credentials. Stream it with log stream --level debug --predicate 'subsystem == "ai.olib.stealthos.torclient"'. Common causes:
- Invalid data directory permissions
- Missing GeoIP files (optional, but Tor may warn)
- Network connectivity issues
Tor may take 1-2 minutes to bootstrap on slow networks. Consider:
- Increasing the timeout:
try await tor.waitForBootstrap(timeout: 180) - Using bridges if Tor is blocked:
TorConfiguration(bridges: [...])
Use .auto port mode (default) to let Tor find an available port.
The XCFramework was built without Scripts/patches/0001-workqueue-per-pool-worker-accounting.patch. Rebuild with ./Scripts/build-xcframework.sh (it applies the patches), or use disconnect() / reconnect() instead of stop() / start().
stop() timed out (it threw .shutdownFailed). A second tor_run_main() must not run alongside the first, so start() refuses until the old one has returned.
MIT License
Copyright (c) 2025 Olib AI
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- Olib AI - Package maintainer and StealthOS developer
- The Tor Project - Tor software and anonymity network
- OpenSSL Project - Cryptographic library
- libevent - Event notification library
- zlib - Compression library
Contributions are welcome! Please ensure:
- Code compiles under Swift 6 strict concurrency
- All public APIs are documented
- Actor isolation is maintained for thread safety
- No use of
@preconcurrencyescape hatches
If you discover a security vulnerability, please report it privately to security@olib.ai rather than opening a public issue.