mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:45:21 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6346870250 | ||
|
|
74f2cd98ab | ||
|
|
ad5fb1ddc6 | ||
|
|
c7ee2a4cb4 |
@@ -6,6 +6,7 @@
|
||||
plans/
|
||||
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# SwiftLint configuration for BitChat.
|
||||
#
|
||||
# Intentionally pragmatic: rules that would flood the existing codebase are
|
||||
# disabled so the lint signal stays actionable; re-enable them individually as
|
||||
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
|
||||
# from the repo root.
|
||||
|
||||
included:
|
||||
- bitchat
|
||||
- bitchatTests
|
||||
- localPackages/BitFoundation/Sources
|
||||
- localPackages/BitFoundation/Tests
|
||||
- localPackages/BitLogger/Sources
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- localPackages/Arti
|
||||
- localPackages/BitFoundation/.build
|
||||
- localPackages/BitLogger/.build
|
||||
|
||||
disabled_rules:
|
||||
# Style/volume rules that currently produce noise across the codebase.
|
||||
- line_length
|
||||
- identifier_name
|
||||
- type_name
|
||||
- type_body_length
|
||||
- cyclomatic_complexity
|
||||
- function_parameter_count
|
||||
- large_tuple
|
||||
- nesting
|
||||
- todo
|
||||
- trailing_comma
|
||||
- opening_brace
|
||||
- statement_position
|
||||
- for_where
|
||||
- redundant_string_enum_value
|
||||
- non_optional_string_data_conversion
|
||||
# Common in tests (force casts/tries on fixtures).
|
||||
- force_cast
|
||||
- force_try
|
||||
|
||||
file_length:
|
||||
warning: 600
|
||||
# BLEService.swift is currently ~3,500 lines; the error threshold sits above
|
||||
# today's maximum so the codebase passes as-is and only future growth of the
|
||||
# largest files trips it.
|
||||
error: 4000
|
||||
|
||||
function_body_length:
|
||||
warning: 100
|
||||
error: 200
|
||||
@@ -1,106 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
# Build macOS (no signing)
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
# Build iOS for simulator
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||
|
||||
# Run all tests (iOS simulator)
|
||||
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
|
||||
|
||||
# Run tests via Swift Package Manager (used in CI)
|
||||
swift build && swift test --parallel
|
||||
|
||||
# Clean build
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
|
||||
|
||||
# Quick dev build and run (macOS only, requires `just`)
|
||||
just run
|
||||
```
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run a single test class
|
||||
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
|
||||
|
||||
# Run a single test method
|
||||
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
|
||||
|
||||
### Local Packages (`localPackages/`)
|
||||
|
||||
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
|
||||
- **BitLogger**: `SecureLogger` logging.
|
||||
- **Arti**: Tor integration (exposes the `Tor` product).
|
||||
|
||||
### Transport Layer
|
||||
|
||||
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
|
||||
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
|
||||
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
|
||||
|
||||
### Encryption Layer
|
||||
|
||||
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
|
||||
- `NoiseEncryptionService`: Main encryption/decryption API
|
||||
- `NoiseSessionManager`: Thread-safe per-peer session management
|
||||
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
|
||||
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
|
||||
|
||||
### Protocol Layer
|
||||
|
||||
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
|
||||
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
|
||||
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
|
||||
|
||||
### Application Layer
|
||||
|
||||
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
|
||||
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
|
||||
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
|
||||
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
|
||||
- `ChatNostrCoordinator` → `GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
|
||||
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
|
||||
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
|
||||
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
|
||||
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
|
||||
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
|
||||
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
Tests use an **in-memory networking harness** for deterministic, race-free testing:
|
||||
|
||||
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
|
||||
- `MockBLEService.resetTestBus()` - Clear state in setUp()
|
||||
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
|
||||
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
|
||||
|
||||
- **Test categories**:
|
||||
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
|
||||
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
|
||||
- Unit tests: Individual component tests
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
|
||||
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
|
||||
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
|
||||
|
||||
## Device Setup
|
||||
|
||||
To run on physical devices:
|
||||
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
|
||||
2. Add your Developer Team ID to `Local.xcconfig`
|
||||
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
|
||||
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
|
||||
@@ -328,85 +328,52 @@ struct NostrProtocol {
|
||||
return try NostrEvent(from: rumorDict)
|
||||
}
|
||||
|
||||
// MARK: - Encryption (NIP-44 v2/v3)
|
||||
|
||||
/// Whether outgoing DMs use the padded "v3:" envelope.
|
||||
///
|
||||
/// TWO-PHASE ROLLOUT — DO NOT FLIP YET. Deployed clients hard-reject any
|
||||
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
|
||||
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
|
||||
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
|
||||
/// smuggled inside "v2:" without breaking old receivers either. Enabling
|
||||
/// this today would strand every client in the field.
|
||||
///
|
||||
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
|
||||
/// Phase 2 (future release, once phase-1 clients are widely deployed):
|
||||
/// set this to true so outgoing DMs stop leaking plaintext length to
|
||||
/// relays.
|
||||
static let sendPaddedEnvelope = false
|
||||
|
||||
/// Internal (rather than private) so tests can exercise both envelope
|
||||
/// versions directly.
|
||||
static func encrypt(
|
||||
// MARK: - Encryption (NIP-44 v2)
|
||||
|
||||
private static func encrypt(
|
||||
plaintext: String,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey,
|
||||
padded: Bool = NostrProtocol.sendPaddedEnvelope
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
|
||||
|
||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
|
||||
|
||||
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
|
||||
// The v3 envelope deliberately reuses the same key derivation; it only
|
||||
// changes the payload framing (length prefix + padding).
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
|
||||
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
|
||||
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
|
||||
|
||||
let pt = Data(plaintext.utf8)
|
||||
let payload = padded ? try NIP44Padding.pad(pt) : pt
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
|
||||
|
||||
// version prefix + base64url(nonce24 || ciphertext || tag)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
|
||||
// v2: base64url(nonce24 || ciphertext || tag)
|
||||
var combined = Data()
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
|
||||
return "v2:" + Base64URLCoding.encode(combined)
|
||||
}
|
||||
|
||||
/// Internal (rather than private) so tests can exercise both envelope
|
||||
/// versions directly.
|
||||
static func decrypt(
|
||||
|
||||
private static func decrypt(
|
||||
ciphertext: String,
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
|
||||
// envelope (see `sendPaddedEnvelope` for the rollout plan).
|
||||
let isPadded: Bool
|
||||
if ciphertext.hasPrefix("v2:") {
|
||||
isPadded = false
|
||||
} else if ciphertext.hasPrefix("v3:") {
|
||||
isPadded = true
|
||||
} else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
@@ -432,23 +399,18 @@ struct NostrProtocol {
|
||||
}
|
||||
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
let payload: Data
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
payload = pt
|
||||
} else {
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
payload = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
payload = try attemptDecrypt(using: senderPubkeyData)
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
// The AEAD tag has already authenticated the payload; unpadding
|
||||
// failures here mean a malformed sender, not a wrong key.
|
||||
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
|
||||
return String(data: plaintextData, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
@@ -679,60 +641,6 @@ enum NostrError: Error {
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 style padding (v3 envelope payload framing)
|
||||
|
||||
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
|
||||
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
|
||||
/// total is padded to `paddedLength(for:)` — power-of-two-derived buckets with
|
||||
/// a 32-byte minimum — so ciphertext length no longer reveals exact plaintext
|
||||
/// length to relays.
|
||||
enum NIP44Padding {
|
||||
static let minPaddedLength = 32
|
||||
static let maxPlaintextLength = 65535
|
||||
|
||||
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
|
||||
/// granularity of max(32, nextPowerOfTwo/8).
|
||||
static func paddedLength(for unpaddedLength: Int) -> Int {
|
||||
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
|
||||
// Smallest power of two strictly greater than (unpaddedLength - 1).
|
||||
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
|
||||
let chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||
return chunk * ((unpaddedLength - 1) / chunk + 1)
|
||||
}
|
||||
|
||||
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
|
||||
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
|
||||
/// fit the 16-bit length prefix.
|
||||
static func pad(_ plaintext: Data) throws -> Data {
|
||||
let length = plaintext.count
|
||||
guard length >= 1, length <= maxPlaintextLength else {
|
||||
throw NostrError.encryptionFailed
|
||||
}
|
||||
let padded = paddedLength(for: length)
|
||||
var result = Data(capacity: 2 + padded)
|
||||
result.append(UInt8(length >> 8))
|
||||
result.append(UInt8(length & 0xFF))
|
||||
result.append(plaintext)
|
||||
result.append(Data(count: padded - length))
|
||||
return result
|
||||
}
|
||||
|
||||
/// Read the 2-byte length prefix, validate the total padded size matches
|
||||
/// it exactly, and return the plaintext. Throws on any inconsistency so a
|
||||
/// malformed (already-authenticated) payload can never over- or
|
||||
/// under-read.
|
||||
static func unpad(_ padded: Data) throws -> Data {
|
||||
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
|
||||
let start = padded.startIndex
|
||||
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
|
||||
guard length >= 1,
|
||||
padded.count == 2 + paddedLength(for: length) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
return padded.subdata(in: (start + 2)..<(start + 2 + length))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
|
||||
private extension NostrProtocol {
|
||||
|
||||
@@ -61,6 +61,11 @@ struct NostrRelayManagerDependencies {
|
||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
var torEnforced: () -> 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 awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||
var makeSession: () -> NostrRelaySessionProtocol
|
||||
@@ -83,10 +88,14 @@ private extension NostrRelayManagerDependencies {
|
||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||
torEnforced: { TorManager.shared.torEnforced },
|
||||
torIsReady: { TorManager.shared.isReady },
|
||||
torEgressVerified: { TorManager.shared.isEgressVerified },
|
||||
torIsForeground: { TorManager.shared.isForeground() },
|
||||
awaitTorReady: { completion in
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
// Require both Tor bootstrap AND a positive egress self-check
|
||||
// so relay sockets never open unless traffic is proven to
|
||||
// route through Tor (fail-closed).
|
||||
let ready = await TorManager.shared.awaitEgressReady()
|
||||
await MainActor.run {
|
||||
completion(ready)
|
||||
}
|
||||
@@ -625,8 +634,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// 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 {
|
||||
shouldUseTor && !dependencies.torIsReady()
|
||||
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
|
||||
}
|
||||
|
||||
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||
@@ -674,16 +689,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard ready else {
|
||||
self.torReadyWaitAttempts += 1
|
||||
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)
|
||||
} else {
|
||||
// Still fail-closed (no network), but unblock any callers
|
||||
// waiting on EOSE so the UI doesn't hang indefinitely.
|
||||
// Queued subscriptions/sends are kept and flush if a later
|
||||
// trigger (e.g. app foreground) brings Tor up.
|
||||
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
|
||||
// Queued subscriptions/sends are kept; a bounded-cadence
|
||||
// retry (below) re-enters the gate so a transient failure
|
||||
// (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.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
||||
self.scheduleTorGateRetry(pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -693,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
|
||||
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
||||
/// 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
|
||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||
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
|
||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//
|
||||
// TorEgressVerifierTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Unit tests for the runtime Tor-egress self-check policy/caching. The network
|
||||
// probe is injected, so these tests are deterministic and offline.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
import Tor
|
||||
|
||||
@Suite(.serialized)
|
||||
struct TorEgressVerifierTests {
|
||||
|
||||
/// Deterministic, controllable clock + probe.
|
||||
private final class Harness: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _now = Date(timeIntervalSince1970: 1_000_000)
|
||||
private var _result: TorEgressVerifier.ProbeResult = .verifiedTor
|
||||
private var _hanging = false
|
||||
private var _gated = false
|
||||
private var _released = false
|
||||
private var _probeCount = 0
|
||||
private var _cancelledCount = 0
|
||||
|
||||
var now: Date {
|
||||
lock.lock(); defer { lock.unlock() }; return _now
|
||||
}
|
||||
var probeCount: Int {
|
||||
lock.lock(); defer { lock.unlock() }; return _probeCount
|
||||
}
|
||||
/// Number of hung probes that observed cooperative cancellation.
|
||||
var cancelledCount: Int {
|
||||
lock.lock(); defer { lock.unlock() }; return _cancelledCount
|
||||
}
|
||||
func advance(_ seconds: TimeInterval) {
|
||||
lock.lock(); _now = _now.addingTimeInterval(seconds); lock.unlock()
|
||||
}
|
||||
func setResult(_ r: TorEgressVerifier.ProbeResult) {
|
||||
lock.lock(); _result = r; lock.unlock()
|
||||
}
|
||||
/// When `true`, probes park forever and only exit via cooperative
|
||||
/// cancellation — models a canary request wedged by
|
||||
/// `waitsForConnectivity` deferring the request timer.
|
||||
func setHanging(_ hanging: Bool) {
|
||||
lock.lock(); _hanging = hanging; lock.unlock()
|
||||
}
|
||||
/// When `true`, probes wait for `release()` before returning — models
|
||||
/// a slow-but-completing canary for join-semantics tests.
|
||||
func setGated(_ gated: Bool) {
|
||||
lock.lock(); _gated = gated; lock.unlock()
|
||||
}
|
||||
func release() {
|
||||
lock.lock(); _released = true; lock.unlock()
|
||||
}
|
||||
/// Suspends until at least `n` probes have started.
|
||||
func waitUntilProbeCount(atLeast n: Int) async {
|
||||
while probeCount < n {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
}
|
||||
func makeProbe() -> @Sendable () async -> TorEgressVerifier.ProbeResult {
|
||||
return { [self] in
|
||||
lock.lock()
|
||||
_probeCount += 1
|
||||
let r = _result
|
||||
let hang = _hanging
|
||||
let gated = _gated
|
||||
lock.unlock()
|
||||
if hang {
|
||||
// Park until cancelled (verifier watchdog or invalidate());
|
||||
// cancellation-responsive so no task outlives the test.
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000)
|
||||
}
|
||||
lock.lock(); _cancelledCount += 1; lock.unlock()
|
||||
return .unreachable("hung probe cancelled")
|
||||
}
|
||||
if gated {
|
||||
while !Task.isCancelled {
|
||||
lock.lock(); let released = _released; lock.unlock()
|
||||
if released { break }
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
func nowProvider() -> @Sendable () -> Date {
|
||||
return { [self] in self.now }
|
||||
}
|
||||
}
|
||||
|
||||
private func makeVerifier(
|
||||
_ h: Harness,
|
||||
ttl: TimeInterval = 300,
|
||||
minRetry: TimeInterval = 5,
|
||||
probeTimeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout
|
||||
) -> TorEgressVerifier {
|
||||
TorEgressVerifier(
|
||||
ttl: ttl,
|
||||
minRetryInterval: minRetry,
|
||||
probeTimeout: probeTimeout,
|
||||
now: h.nowProvider(),
|
||||
probe: h.makeProbe()
|
||||
)
|
||||
}
|
||||
|
||||
@Test("verifiedTor allows and is cached within TTL (single probe)")
|
||||
func verifiedIsCached() async {
|
||||
let h = Harness()
|
||||
h.setResult(.verifiedTor)
|
||||
let v = makeVerifier(h, ttl: 300)
|
||||
|
||||
#expect(await v.verify() == true)
|
||||
// Second call within TTL must not re-probe.
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 1)
|
||||
}
|
||||
|
||||
@Test("cache expires after TTL and re-probes")
|
||||
func cacheExpires() async {
|
||||
let h = Harness()
|
||||
h.setResult(.verifiedTor)
|
||||
let v = makeVerifier(h, ttl: 300)
|
||||
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 1)
|
||||
|
||||
h.advance(301)
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 2)
|
||||
}
|
||||
|
||||
@Test("notTor refuses (leak detected) and is never cached as allowed")
|
||||
func notTorRefuses() async {
|
||||
let h = Harness()
|
||||
h.setResult(.notTor)
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||
|
||||
#expect(await v.verify() == false)
|
||||
// A subsequent success recovers.
|
||||
h.setResult(.verifiedTor)
|
||||
#expect(await v.verify() == true)
|
||||
}
|
||||
|
||||
@Test("unreachable refuses: an unverified egress must not proceed (fail-closed)")
|
||||
func unreachableRefuses() async {
|
||||
let h = Harness()
|
||||
h.setResult(.unreachable("down"))
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||
|
||||
#expect(await v.verify() == false)
|
||||
#expect(v.hasFreshVerification == false)
|
||||
}
|
||||
|
||||
@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 {
|
||||
let h = Harness()
|
||||
h.setResult(.unreachable("down"))
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
||||
|
||||
#expect(await v.verify() == false)
|
||||
#expect(h.probeCount == 1)
|
||||
// Within minRetry window: reuse last (refusing) decision, no new probe.
|
||||
h.advance(1)
|
||||
#expect(await v.verify() == false)
|
||||
#expect(h.probeCount == 1)
|
||||
// After the window: re-probe.
|
||||
h.advance(5)
|
||||
#expect(await v.verify() == false)
|
||||
#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")
|
||||
func invalidateForcesReprobe() async {
|
||||
let h = Harness()
|
||||
h.setResult(.verifiedTor)
|
||||
let v = makeVerifier(h, ttl: 300)
|
||||
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 1)
|
||||
await v.invalidate()
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 2)
|
||||
}
|
||||
|
||||
@Test("lastProbeResult reflects the most recent outcome")
|
||||
func lastResultTracked() async {
|
||||
let h = Harness()
|
||||
h.setResult(.notTor)
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||
_ = await v.verify()
|
||||
#expect(await v.lastProbeResult() == .notTor)
|
||||
}
|
||||
|
||||
// MARK: - Liveness (probe timeout watchdog + invalidate cancellation)
|
||||
|
||||
@Test("a probe that never completes is bounded by probeTimeout and fails closed")
|
||||
func hungProbeIsBoundedByTimeout() async {
|
||||
let h = Harness()
|
||||
h.setHanging(true)
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 0, probeTimeout: 0.05)
|
||||
|
||||
// Without the watchdog this would wedge: waitsForConnectivity can
|
||||
// defer the request timer, leaving the canary bounded only by the
|
||||
// 7-day resource timeout.
|
||||
#expect(await v.verify() == false)
|
||||
let last = await v.lastProbeResult()
|
||||
switch last {
|
||||
case .unreachable:
|
||||
break // fail-closed timeout verdict recorded
|
||||
default:
|
||||
Issue.record("expected .unreachable after probe timeout, got \(String(describing: last))")
|
||||
}
|
||||
#expect(v.hasFreshVerification == false)
|
||||
|
||||
// The hung probe task itself was cancelled (URLSession task would be
|
||||
// torn down), not abandoned.
|
||||
while h.cancelledCount < 1 {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
#expect(h.cancelledCount == 1)
|
||||
|
||||
// The in-flight slot was cleared: the next verify() starts a fresh
|
||||
// probe (does not join the hung one) and recovers.
|
||||
h.setHanging(false)
|
||||
h.setResult(.verifiedTor)
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 2)
|
||||
#expect(v.hasFreshVerification == true)
|
||||
}
|
||||
|
||||
@Test("invalidate() cancels the in-flight probe and the awaiting caller fails closed")
|
||||
func invalidateCancelsInFlightProbe() async {
|
||||
let h = Harness()
|
||||
h.setHanging(true)
|
||||
// Long (real-time) timeout and throttle: only invalidate() can
|
||||
// unblock the caller, and only invalidate() clearing the throttle
|
||||
// lets the follow-up probe run without advancing the clock.
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 600, probeTimeout: 600)
|
||||
|
||||
let first = Task { await v.verify() }
|
||||
await h.waitUntilProbeCount(atLeast: 1)
|
||||
await v.invalidate()
|
||||
|
||||
// The awaiting caller resolves promptly (no 600s wait) and refuses.
|
||||
#expect(await first.value == false)
|
||||
#expect(v.hasFreshVerification == false)
|
||||
|
||||
// The hung probe observed cancellation (a Tor restart genuinely
|
||||
// shakes the wedged canary request).
|
||||
while h.cancelledCount < 1 {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
|
||||
// Recovery: a fresh verify() runs a NEW probe — it neither joins the
|
||||
// cancelled one nor inherits its throttle/last-result state.
|
||||
h.setHanging(false)
|
||||
h.setResult(.verifiedTor)
|
||||
#expect(await v.verify() == true)
|
||||
#expect(h.probeCount == 2)
|
||||
#expect(v.hasFreshVerification == true)
|
||||
}
|
||||
|
||||
@Test("recovery after a hung probe survives invalidate + Tor restart cycle")
|
||||
func hungThenInvalidatedThenRecovers() async {
|
||||
let h = Harness()
|
||||
h.setHanging(true)
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 5, probeTimeout: 600)
|
||||
|
||||
// Wedge one probe, then simulate a Tor restart mid-flight.
|
||||
let wedged = Task { await v.verify() }
|
||||
await h.waitUntilProbeCount(atLeast: 1)
|
||||
await v.invalidate()
|
||||
#expect(await wedged.value == false)
|
||||
|
||||
// Canary still down right after restart: fresh probe, fail closed —
|
||||
// and the throttle applies to the FRESH result (bounded retry intact).
|
||||
h.setHanging(false)
|
||||
h.setResult(.unreachable("circuit not built"))
|
||||
#expect(await v.verify() == false)
|
||||
#expect(h.probeCount == 2)
|
||||
h.advance(1)
|
||||
#expect(await v.verify() == false)
|
||||
#expect(h.probeCount == 2) // throttled, no hammering
|
||||
|
||||
// Canary returns after the retry window: verification recovers.
|
||||
h.setResult(.verifiedTor)
|
||||
h.advance(5)
|
||||
#expect(await v.verify() == true)
|
||||
#expect(v.hasFreshVerification == true)
|
||||
}
|
||||
|
||||
@Test("concurrent verify() callers still share a single in-flight probe")
|
||||
func concurrentCallersShareOneProbe() async {
|
||||
let h = Harness()
|
||||
h.setResult(.verifiedTor)
|
||||
h.setGated(true)
|
||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
||||
|
||||
let t1 = Task { await v.verify() }
|
||||
await h.waitUntilProbeCount(atLeast: 1)
|
||||
let t2 = Task { await v.verify() }
|
||||
// Give t2 a chance to join the in-flight probe before releasing it.
|
||||
// Either way the invariant holds: t2 joins the shared probe, or (if
|
||||
// scheduled after completion) hits the fresh TTL cache — exactly one
|
||||
// probe runs.
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
h.release()
|
||||
|
||||
#expect(await t1.value == true)
|
||||
#expect(await t2.value == true)
|
||||
#expect(h.probeCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -289,159 +289,6 @@ struct NostrProtocolTests {
|
||||
#expect(object["limit"] as? Int == 42)
|
||||
}
|
||||
|
||||
// MARK: - Padding (v3 envelope)
|
||||
|
||||
@Test func paddedLengthMatchesNIP44Buckets() {
|
||||
// Vectors from the NIP-44 reference test suite (calc_padded_len).
|
||||
let vectors: [(Int, Int)] = [
|
||||
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
|
||||
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
|
||||
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
|
||||
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
|
||||
(1020, 1024), (65535, 65536)
|
||||
]
|
||||
for (unpadded, expected) in vectors {
|
||||
#expect(
|
||||
NIP44Padding.paddedLength(for: unpadded) == expected,
|
||||
"paddedLength(for: \(unpadded)) should be \(expected)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func padUnpadRoundTrip() throws {
|
||||
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
|
||||
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
|
||||
let padded = try NIP44Padding.pad(plaintext)
|
||||
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
|
||||
let unpadded = try NIP44Padding.unpad(padded)
|
||||
#expect(unpadded == plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func padHidesExactLengthWithinBucket() throws {
|
||||
// Two plaintexts of different length in the same bucket must produce
|
||||
// identically sized padded payloads (and thus ciphertexts).
|
||||
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
|
||||
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
|
||||
#expect(short.count == long.count)
|
||||
}
|
||||
|
||||
@Test func padRejectsOutOfRangePlaintexts() {
|
||||
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
|
||||
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
|
||||
}
|
||||
|
||||
@Test func unpadRejectsTamperedLengthPrefix() throws {
|
||||
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
|
||||
|
||||
// Claimed length larger than the actual payload
|
||||
var tooLong = padded
|
||||
tooLong[tooLong.startIndex] = 0xFF
|
||||
tooLong[tooLong.startIndex + 1] = 0xFF
|
||||
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
|
||||
|
||||
// Claimed length of zero
|
||||
var zero = padded
|
||||
zero[zero.startIndex] = 0x00
|
||||
zero[zero.startIndex + 1] = 0x00
|
||||
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
|
||||
|
||||
// Claimed length whose bucket does not match the payload size
|
||||
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
|
||||
var wrongBucket = padded
|
||||
wrongBucket[wrongBucket.startIndex] = 0x00
|
||||
wrongBucket[wrongBucket.startIndex + 1] = 0x14
|
||||
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
|
||||
|
||||
// Truncated payloads
|
||||
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
|
||||
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
|
||||
padded.removeLast()
|
||||
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
|
||||
|
||||
// Works on Data slices with non-zero startIndex
|
||||
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
|
||||
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
|
||||
}
|
||||
|
||||
@Test func paddedEnvelopeRoundTrip_v3() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let plaintext = "padded envelope test"
|
||||
|
||||
let ciphertext = try NostrProtocol.encrypt(
|
||||
plaintext: plaintext,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderKey: sender.schnorrSigningKey(),
|
||||
padded: true
|
||||
)
|
||||
#expect(ciphertext.hasPrefix("v3:"))
|
||||
|
||||
let decrypted = try NostrProtocol.decrypt(
|
||||
ciphertext: ciphertext,
|
||||
senderPubkey: sender.publicKeyHex,
|
||||
recipientKey: recipient.schnorrSigningKey()
|
||||
)
|
||||
#expect(decrypted == plaintext)
|
||||
}
|
||||
|
||||
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let plaintext = "legacy v2 envelope"
|
||||
|
||||
// What deployed clients send today.
|
||||
let ciphertext = try NostrProtocol.encrypt(
|
||||
plaintext: plaintext,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderKey: sender.schnorrSigningKey(),
|
||||
padded: false
|
||||
)
|
||||
#expect(ciphertext.hasPrefix("v2:"))
|
||||
|
||||
let decrypted = try NostrProtocol.decrypt(
|
||||
ciphertext: ciphertext,
|
||||
senderPubkey: sender.publicKeyHex,
|
||||
recipientKey: recipient.schnorrSigningKey()
|
||||
)
|
||||
#expect(decrypted == plaintext)
|
||||
}
|
||||
|
||||
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
|
||||
// Deployed clients reject anything that is not "v2:", so the padded
|
||||
// envelope must stay off by default until decrypt-side support is
|
||||
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
|
||||
#expect(NostrProtocol.sendPaddedEnvelope == false)
|
||||
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: "default envelope",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
}
|
||||
|
||||
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let ciphertext = try NostrProtocol.encrypt(
|
||||
plaintext: "test",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderKey: sender.schnorrSigningKey(),
|
||||
padded: false
|
||||
)
|
||||
let mutated = "v9:" + ciphertext.dropFirst(3)
|
||||
#expect(throws: NostrError.invalidCiphertext) {
|
||||
_ = try NostrProtocol.decrypt(
|
||||
ciphertext: mutated,
|
||||
senderPubkey: sender.publicKeyHex,
|
||||
recipientKey: recipient.schnorrSigningKey()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
@@ -111,6 +111,116 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
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 {
|
||||
let relayURL = "wss://tor-eose-unblock.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -1449,6 +1559,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
userTorEnabled: Bool = false,
|
||||
torEnforced: Bool = false,
|
||||
torIsReady: Bool = true,
|
||||
torEgressVerified: Bool = true,
|
||||
torIsForeground: Bool = true,
|
||||
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
||||
) -> RelayManagerTestContext {
|
||||
@@ -1458,6 +1569,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
let scheduler = MockRelayScheduler()
|
||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
|
||||
let torForeground = MutableBool(value: torIsForeground)
|
||||
let activationFlag = MutableBool(value: activationAllowed)
|
||||
let manager = NostrRelayManager(
|
||||
@@ -1470,6 +1582,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
torEnforced: { torEnforced },
|
||||
torIsReady: { torWaiter.isReady },
|
||||
torEgressVerified: { torEgressVerifiedFlag.value },
|
||||
torIsForeground: { torForeground.value },
|
||||
awaitTorReady: torWaiter.await(completion:),
|
||||
makeSession: { sessionFactory },
|
||||
@@ -1489,6 +1602,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
clock: clock,
|
||||
activationAllowed: activationFlag,
|
||||
torWaiter: torWaiter,
|
||||
torEgressVerified: torEgressVerifiedFlag,
|
||||
torForeground: torForeground
|
||||
)
|
||||
}
|
||||
@@ -1543,6 +1657,7 @@ private struct RelayManagerTestContext {
|
||||
let clock: MutableClock
|
||||
let activationAllowed: MutableBool
|
||||
let torWaiter: MockTorWaiter
|
||||
let torEgressVerified: MutableBool
|
||||
let torForeground: MutableBool
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ let package = Package(
|
||||
"TorManager.swift",
|
||||
"TorURLSession.swift",
|
||||
"TorNotifications.swift",
|
||||
"TorEgressVerifier.swift",
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("resolv"),
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Runtime self-check that the proxied `URLSession` egress is *actually* routed
|
||||
/// through Tor — defense-in-depth for the case where a platform silently ignores
|
||||
/// `URLSessionConfiguration.connectionProxyDictionary` SOCKS settings and lets
|
||||
/// traffic egress directly (leaking the real IP while Tor appears enabled).
|
||||
///
|
||||
/// Runtime verification (see `scripts/tor-egress-verification/`) showed that
|
||||
/// macOS and the iOS simulator DO honor the SOCKS proxy for both plain HTTPS and
|
||||
/// `URLSessionWebSocketTask`, and that the proxied session is fail-closed (every
|
||||
/// request errors when the SOCKS proxy is down). Apple does not officially
|
||||
/// support SOCKS for URLSession on iOS, so on a physical device the behavior is
|
||||
/// not contractually guaranteed. This verifier closes that gap: before relay
|
||||
/// connections are opened under enforced Tor, it performs a canary request whose
|
||||
/// response positively reports whether the egress hit the network via Tor.
|
||||
///
|
||||
/// Policy (`verify()` return value) — fail-closed on unverified egress:
|
||||
/// - `.verifiedTor` → allow, and cache the positive result for `ttl`.
|
||||
/// - `.notTor` → REFUSE, and drop any cached verification. The canary
|
||||
/// reached the internet but the exit is NOT a Tor node:
|
||||
/// a real leak. Never allow relays.
|
||||
/// - `.unreachable` → REFUSE (egress unverified). The canary itself failed
|
||||
/// (endpoint down / circuit not built), so we cannot tell
|
||||
/// whether the platform honored the SOCKS proxy — the
|
||||
/// exact ambiguity this verifier exists to resolve.
|
||||
/// 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.
|
||||
///
|
||||
/// Liveness:
|
||||
/// - Every probe is hard-bounded by `probeTimeout` via an independent async
|
||||
/// watchdog. The proxied session sets `waitsForConnectivity = true`, which
|
||||
/// can defer the per-request timer indefinitely, leaving the request
|
||||
/// bounded only by the default 7-day resource timeout — without the
|
||||
/// watchdog a hung canary would wedge every subsequent `verify()` caller.
|
||||
/// 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()` (after the retry throttle) starts a fresh probe.
|
||||
/// - `invalidate()` cancels any in-flight probe and clears all cached state
|
||||
/// (including the retry throttle), so a Tor restart/dormant/shutdown
|
||||
/// genuinely resets the verifier: a hung probe cannot survive it.
|
||||
///
|
||||
/// The probe is injectable so the policy/caching logic is unit-tested without a
|
||||
/// live network (see `TorEgressVerifierTests`).
|
||||
public actor TorEgressVerifier {
|
||||
public enum ProbeResult: Equatable, Sendable {
|
||||
/// Canary succeeded and the exit is a Tor node.
|
||||
case verifiedTor
|
||||
/// Canary succeeded but the exit is NOT Tor — a direct-egress leak.
|
||||
case notTor
|
||||
/// Canary could not complete (endpoint down, no circuit, parse error).
|
||||
case unreachable(String)
|
||||
}
|
||||
|
||||
/// Hard upper bound for a single canary probe, enforced independently of
|
||||
/// URLSession timers (see the "Liveness" section of the type doc). Matches
|
||||
/// the live probe's per-request timeout.
|
||||
public static let defaultProbeTimeout: TimeInterval = 20
|
||||
|
||||
private let probe: @Sendable () async -> ProbeResult
|
||||
private let now: @Sendable () -> Date
|
||||
private let ttl: TimeInterval
|
||||
/// Minimum spacing between probes when not currently verified, so a
|
||||
/// persistent `.unreachable` cannot hammer the canary endpoint on every
|
||||
/// reconnect burst.
|
||||
private let minRetryInterval: TimeInterval
|
||||
/// Outer wall-clock bound on a single probe (watchdog; fail-closed).
|
||||
private let probeTimeout: TimeInterval
|
||||
|
||||
private var lastVerifiedAt: Date?
|
||||
private var lastProbeAt: Date?
|
||||
private var lastResult: ProbeResult?
|
||||
private var inFlight: Task<Bool, Never>?
|
||||
/// Bumped whenever a new probe starts or `invalidate()` runs. A completing
|
||||
/// probe only records its outcome (cache/throttle) and clears `inFlight`
|
||||
/// if its generation is still current, so a cancelled/superseded probe
|
||||
/// cannot clobber state owned by a fresh one (actor-reentrancy safety).
|
||||
private var probeGeneration = 0
|
||||
|
||||
/// 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(
|
||||
ttl: TimeInterval,
|
||||
minRetryInterval: TimeInterval = 5.0,
|
||||
probeTimeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout,
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
probe: @escaping @Sendable () async -> ProbeResult
|
||||
) {
|
||||
self.ttl = ttl
|
||||
self.minRetryInterval = minRetryInterval
|
||||
self.probeTimeout = probeTimeout
|
||||
self.now = now
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
/// Drop any cached verification (e.g. after a Tor restart or when the
|
||||
/// network path changes) AND cancel any in-flight probe. The next
|
||||
/// `verify()` starts a fresh probe — it neither joins the cancelled one
|
||||
/// nor is throttled by its outcome, so a probe hung from before a Tor
|
||||
/// restart cannot wedge callers after it.
|
||||
public func invalidate() {
|
||||
probeGeneration += 1
|
||||
inFlight?.cancel()
|
||||
inFlight = nil
|
||||
lastVerifiedAt = nil
|
||||
lastProbeAt = nil
|
||||
lastResult = nil
|
||||
verifiedSnapshot.update(nil)
|
||||
}
|
||||
|
||||
/// The most recent probe outcome, for diagnostics/tests.
|
||||
public func lastProbeResult() -> ProbeResult? { lastResult }
|
||||
|
||||
/// Synchronous view of the cache: `true` while a `verifiedTor` verdict is
|
||||
/// within its TTL. Callers that get `false` must route through the async
|
||||
/// `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 {
|
||||
if isFreshlyVerified() { return true }
|
||||
// Throttle re-probes when the last attempt did not verify.
|
||||
if let last = lastProbeAt,
|
||||
let result = lastResult,
|
||||
now().timeIntervalSince(last) < minRetryInterval {
|
||||
return decision(for: result)
|
||||
}
|
||||
if let inFlight { return await inFlight.value }
|
||||
|
||||
probeGeneration += 1
|
||||
let generation = probeGeneration
|
||||
let task = Task<Bool, Never> { await self.runProbe(generation: generation) }
|
||||
inFlight = task
|
||||
let allowed = await task.value
|
||||
// Only clear the slot if this probe is still the current one: an
|
||||
// `invalidate()` while we were suspended has already cleared it and a
|
||||
// newer probe may occupy it (do not clobber the fresh task).
|
||||
if probeGeneration == generation { inFlight = nil }
|
||||
return allowed
|
||||
}
|
||||
|
||||
private func isFreshlyVerified() -> Bool {
|
||||
guard let last = lastVerifiedAt else { return false }
|
||||
return now().timeIntervalSince(last) < ttl
|
||||
}
|
||||
|
||||
private func decision(for result: ProbeResult) -> Bool {
|
||||
switch result {
|
||||
case .verifiedTor: return true
|
||||
// Fail closed: both a positively detected leak and an unverifiable
|
||||
// egress refuse connections. Only a fresh `verifiedTor` allows.
|
||||
case .unreachable, .notTor: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func runProbe(generation: Int) async -> Bool {
|
||||
let result = await boundedProbe()
|
||||
// Superseded by `invalidate()` (Tor restart/dormant/shutdown) while the
|
||||
// probe ran: its verdict predates the reset, so discard it — recording
|
||||
// it would re-seed the throttle/cache that invalidate() just cleared.
|
||||
// Fail closed for the callers that were awaiting this probe.
|
||||
guard generation == probeGeneration else { return false }
|
||||
lastProbeAt = now()
|
||||
lastResult = result
|
||||
switch result {
|
||||
case .verifiedTor:
|
||||
lastVerifiedAt = now()
|
||||
verifiedSnapshot.update(now().addingTimeInterval(ttl))
|
||||
return true
|
||||
case .notTor:
|
||||
lastVerifiedAt = nil
|
||||
verifiedSnapshot.update(nil)
|
||||
SecureLogger.error(
|
||||
"🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
case .unreachable(let why):
|
||||
// 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(
|
||||
"🧅 Tor egress self-check could not complete (\(why)) — egress UNVERIFIED; refusing relay connections until the canary succeeds (bounded retry)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the injected probe raced against `probeTimeout`, guaranteeing a
|
||||
/// result in bounded time regardless of URLSession timer behavior (the
|
||||
/// proxied session's `waitsForConnectivity` can defer the per-request
|
||||
/// timeout indefinitely). Whichever side loses the race is cancelled:
|
||||
/// - on timeout, the probe task is cancelled (URLSession's async APIs
|
||||
/// cancel the underlying `URLSessionTask` cooperatively) and the result
|
||||
/// is the fail-closed `.unreachable`;
|
||||
/// - on completion, the watchdog's sleep is cancelled so no timer lingers.
|
||||
/// Cancelling the enclosing task (`invalidate()`) resolves immediately as
|
||||
/// `.unreachable` and cancels both sides.
|
||||
///
|
||||
/// `nonisolated` so the race body never re-enters the actor; it touches
|
||||
/// only immutable `Sendable` state.
|
||||
nonisolated private func boundedProbe() async -> ProbeResult {
|
||||
let probe = self.probe
|
||||
let timeout = self.probeTimeout
|
||||
let race = ProbeRace()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<ProbeResult, Never>) in
|
||||
race.install(continuation)
|
||||
let probeTask = Task { race.finish(await probe()) }
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
race.finish(.unreachable("probe timed out after \(Int(timeout))s"))
|
||||
}
|
||||
race.register(probeTask: probeTask, watchdog: watchdog)
|
||||
}
|
||||
} onCancel: {
|
||||
race.finish(.unreachable("probe cancelled"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve-once rendezvous for the probe/watchdog race. Lock-protected
|
||||
/// (never held across an await); the first `finish()` wins, resumes the
|
||||
/// continuation exactly once, and cancels both tasks.
|
||||
private final class ProbeRace: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<ProbeResult, Never>?
|
||||
private var pendingResult: ProbeResult?
|
||||
private var resolved = false
|
||||
private var probeTask: Task<Void, Never>?
|
||||
private var watchdog: Task<Void, Never>?
|
||||
|
||||
func install(_ continuation: CheckedContinuation<ProbeResult, Never>) {
|
||||
lock.lock()
|
||||
if let result = pendingResult {
|
||||
// finish() ran before the continuation existed (e.g. the
|
||||
// enclosing task was already cancelled): resolve immediately.
|
||||
pendingResult = nil
|
||||
lock.unlock()
|
||||
continuation.resume(returning: result)
|
||||
return
|
||||
}
|
||||
self.continuation = continuation
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func register(probeTask: Task<Void, Never>, watchdog: Task<Void, Never>) {
|
||||
lock.lock()
|
||||
if resolved {
|
||||
lock.unlock()
|
||||
probeTask.cancel()
|
||||
watchdog.cancel()
|
||||
return
|
||||
}
|
||||
self.probeTask = probeTask
|
||||
self.watchdog = watchdog
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func finish(_ result: ProbeResult) {
|
||||
lock.lock()
|
||||
guard !resolved else { lock.unlock(); return }
|
||||
resolved = true
|
||||
let continuation = self.continuation
|
||||
self.continuation = nil
|
||||
if continuation == nil { pendingResult = result }
|
||||
let probeTask = self.probeTask
|
||||
let watchdog = self.watchdog
|
||||
self.probeTask = nil
|
||||
self.watchdog = nil
|
||||
lock.unlock()
|
||||
probeTask?.cancel()
|
||||
watchdog?.cancel()
|
||||
continuation?.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live probe
|
||||
|
||||
public extension TorEgressVerifier {
|
||||
/// Default canary: fetch Tor Project's connectivity check API through the
|
||||
/// shared proxied session and assert `IsTor == true`. Because the response
|
||||
/// is served from the *exit's* vantage point, a silent direct egress is
|
||||
/// caught here as `.notTor`. `check.torproject.org` is clearnet, so this
|
||||
/// works without onion-service support.
|
||||
///
|
||||
/// Follow-up (see PR): make the canary endpoint configurable and add an
|
||||
/// onion-service canary so verification does not depend on a single host.
|
||||
/// Note: the per-request `timeoutInterval` below is best-effort only — the
|
||||
/// proxied session's `waitsForConnectivity` can defer it. The authoritative
|
||||
/// bound is the verifier's `probeTimeout` watchdog, whose cancellation
|
||||
/// propagates into `session.data(for:)` and cancels the URLSessionTask.
|
||||
static func liveProbe(
|
||||
endpoint: URL = URL(string: "https://check.torproject.org/api/ip")!,
|
||||
timeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout
|
||||
) -> @Sendable () async -> ProbeResult {
|
||||
return {
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.timeoutInterval = timeout
|
||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
let session = TorURLSession.shared.session
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse,
|
||||
(200..<300).contains(http.statusCode) else {
|
||||
return .unreachable("http status \((response as? HTTPURLResponse)?.statusCode ?? -1)")
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return .unreachable("unparseable canary response")
|
||||
}
|
||||
if let isTor = json["IsTor"] as? Bool {
|
||||
return isTor ? .verifiedTor : .notTor
|
||||
}
|
||||
return .unreachable("canary response missing IsTor")
|
||||
} catch {
|
||||
return .unreachable(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,14 @@ public final class TorManager: ObservableObject {
|
||||
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
|
||||
@@ -125,6 +133,32 @@ public final class TorManager: ObservableObject {
|
||||
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
|
||||
/// through the proxied session positively verifies Tor egress. Returns
|
||||
/// `false` if Tor never became ready, or if the egress self-check could
|
||||
/// not positively verify a Tor exit (non-Tor egress detected, or canary
|
||||
/// unreachable → unverified). 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? {
|
||||
@@ -325,6 +359,8 @@ public final class TorManager: ObservableObject {
|
||||
self.socksReady = false
|
||||
self.isStarting = false
|
||||
}
|
||||
// Force a fresh egress self-check once Tor comes back.
|
||||
Task { await egressVerifier.invalidate() }
|
||||
}
|
||||
|
||||
public func shutdownCompletely() {
|
||||
@@ -353,6 +389,7 @@ public final class TorManager: ObservableObject {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +405,8 @@ public final class TorManager: ObservableObject {
|
||||
self.isDormant = false
|
||||
self.lastRestartAt = Date()
|
||||
}
|
||||
// New Arti instance means new circuits; re-verify egress after restart.
|
||||
await egressVerifier.invalidate()
|
||||
|
||||
_ = arti_stop()
|
||||
|
||||
|
||||
@@ -8,44 +8,12 @@
|
||||
|
||||
import struct Foundation.Data
|
||||
|
||||
/// Lowercase hex digits used by `hexEncodedString()`.
|
||||
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
|
||||
|
||||
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
|
||||
/// Accepts both lowercase and uppercase hex digits.
|
||||
@inline(__always)
|
||||
private func hexNibble(_ ascii: UInt8) -> UInt8? {
|
||||
switch ascii {
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"):
|
||||
return ascii - UInt8(ascii: "0")
|
||||
case UInt8(ascii: "a")...UInt8(ascii: "f"):
|
||||
return ascii - UInt8(ascii: "a") + 10
|
||||
case UInt8(ascii: "A")...UInt8(ascii: "F"):
|
||||
return ascii - UInt8(ascii: "A") + 10
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public extension Data {
|
||||
/// Lowercase hex representation of the bytes.
|
||||
///
|
||||
/// Lookup-table based: this sits on the hot BLE receive path (it is called
|
||||
/// several times per received packet via `PeerID(hexData:)`), where the
|
||||
/// previous per-byte `String(format: "%02x", _)` implementation spent most
|
||||
/// of its time re-parsing the format string through Foundation.
|
||||
func hexEncodedString() -> String {
|
||||
if isEmpty {
|
||||
if self.isEmpty {
|
||||
return ""
|
||||
}
|
||||
var output = [UInt8](repeating: 0, count: count * 2)
|
||||
var i = 0
|
||||
for byte in self {
|
||||
output[i] = hexDigits[Int(byte >> 4)]
|
||||
output[i + 1] = hexDigits[Int(byte & 0x0F)]
|
||||
i += 2
|
||||
}
|
||||
return String(decoding: output, as: UTF8.self)
|
||||
return self.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Initialize Data from a hex string.
|
||||
@@ -60,28 +28,28 @@ public extension Data {
|
||||
hex = String(hex.dropFirst(2))
|
||||
}
|
||||
|
||||
let ascii = Array(hex.utf8)
|
||||
|
||||
// Reject odd-length strings
|
||||
guard ascii.count % 2 == 0 else {
|
||||
guard hex.count % 2 == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept empty strings
|
||||
guard !ascii.isEmpty else {
|
||||
// Reject empty strings
|
||||
guard !hex.isEmpty else {
|
||||
self = Data()
|
||||
return
|
||||
}
|
||||
|
||||
var data = Data(capacity: ascii.count / 2)
|
||||
var index = 0
|
||||
while index < ascii.count {
|
||||
guard let high = hexNibble(ascii[index]),
|
||||
let low = hexNibble(ascii[index + 1]) else {
|
||||
let len = hex.count / 2
|
||||
var data = Data(capacity: len)
|
||||
var index = hex.startIndex
|
||||
|
||||
for _ in 0..<len {
|
||||
let nextIndex = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
data.append((high << 4) | low)
|
||||
index += 2
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
self = data
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
//
|
||||
// DataHexTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct DataHexTests {
|
||||
|
||||
// MARK: - Encoding
|
||||
|
||||
@Test func encode_knownVectors() {
|
||||
#expect(Data().hexEncodedString() == "")
|
||||
#expect(Data([0x00]).hexEncodedString() == "00")
|
||||
#expect(Data([0x0f]).hexEncodedString() == "0f")
|
||||
#expect(Data([0xf0]).hexEncodedString() == "f0")
|
||||
#expect(Data([0xff]).hexEncodedString() == "ff")
|
||||
#expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef")
|
||||
#expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef")
|
||||
}
|
||||
|
||||
@Test func encode_allByteValues_matchesFormatReference() {
|
||||
let all = Data((0...255).map { UInt8($0) })
|
||||
let reference = (0...255).map { String(format: "%02x", $0) }.joined()
|
||||
#expect(all.hexEncodedString() == reference)
|
||||
}
|
||||
|
||||
@Test func encode_worksOnDataSlices() {
|
||||
let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb])
|
||||
let slice = data.dropFirst().dropLast()
|
||||
#expect(slice.hexEncodedString() == "deadbeef")
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
@Test func decode_knownVectors() {
|
||||
#expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||
#expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||
#expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||
#expect(Data(hexString: "00") == Data([0x00]))
|
||||
#expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))
|
||||
}
|
||||
|
||||
@Test func decode_handlesPrefixAndWhitespace() {
|
||||
#expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||
#expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||
#expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||
#expect(Data(hexString: "") == Data())
|
||||
#expect(Data(hexString: "0x") == Data())
|
||||
}
|
||||
|
||||
@Test func decode_rejectsInvalidInput() {
|
||||
#expect(Data(hexString: "abc") == nil) // odd length
|
||||
#expect(Data(hexString: "zz") == nil) // non-hex characters
|
||||
#expect(Data(hexString: "0xg1") == nil) // non-hex after prefix
|
||||
#expect(Data(hexString: "+f") == nil) // sign characters are not hex
|
||||
#expect(Data(hexString: "-0") == nil)
|
||||
#expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII
|
||||
#expect(Data(hexString: "de ad") == nil) // interior whitespace
|
||||
}
|
||||
|
||||
// MARK: - Round trip
|
||||
|
||||
@Test func roundTrip_randomLengths() {
|
||||
for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] {
|
||||
let data = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
|
||||
let hex = data.hexEncodedString()
|
||||
#expect(hex.count == length * 2)
|
||||
#expect(Data(hexString: hex) == data)
|
||||
#expect(Data(hexString: hex.uppercased()) == data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Standalone harness: does Apple's URLSession honor connectionProxyDictionary
|
||||
// SOCKS settings for a plain HTTPS GET and for URLSessionWebSocketTask?
|
||||
//
|
||||
// Build: swiftc -O proxy_probe.swift -o proxy_probe
|
||||
// Usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]
|
||||
//
|
||||
// Prints a single RESULT line: RESULT <mode> <keyStyle> <outcome> <detail>
|
||||
// The caller correlates this with the SOCKS proxy's connection log to decide
|
||||
// whether the request was proxied.
|
||||
#if canImport(CFNetwork)
|
||||
import CFNetwork
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count >= 4 else {
|
||||
FileHandle.standardError.write(Data("usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]\n".utf8))
|
||||
exit(2)
|
||||
}
|
||||
let mode = args[1]
|
||||
let keyStyle = args[2]
|
||||
let proxyPort = Int(args[3]) ?? 19999
|
||||
let host = "127.0.0.1"
|
||||
|
||||
func makeProxyDict() -> [AnyHashable: Any] {
|
||||
switch keyStyle {
|
||||
#if os(macOS)
|
||||
case "cf":
|
||||
// The exact constants the app uses on macOS.
|
||||
return [
|
||||
kCFNetworkProxiesSOCKSEnable as String: 1,
|
||||
kCFNetworkProxiesSOCKSProxy as String: host,
|
||||
kCFNetworkProxiesSOCKSPort as String: proxyPort
|
||||
]
|
||||
#endif
|
||||
default:
|
||||
// The exact raw string keys the app uses on iOS.
|
||||
return [
|
||||
"SOCKSEnable": 1,
|
||||
"SOCKSProxy": host,
|
||||
"SOCKSPort": proxyPort
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = false
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
cfg.connectionProxyDictionary = makeProxyDict()
|
||||
let session = URLSession(configuration: cfg)
|
||||
|
||||
func emit(_ outcome: String, _ detail: String) {
|
||||
print("RESULT \(mode) \(keyStyle) \(outcome) \(detail)")
|
||||
exit(outcome == "ERROR" ? 1 : 0)
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
|
||||
if mode == "http" {
|
||||
let target = URL(string: args.count >= 5 ? args[4] : "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
let task = session.dataTask(with: target) { data, resp, err in
|
||||
if let err = err {
|
||||
emit("ERROR", "\(err.localizedDescription)")
|
||||
} else if let http = resp as? HTTPURLResponse {
|
||||
emit("OK", "status=\(http.statusCode) bytes=\(data?.count ?? 0)")
|
||||
} else {
|
||||
emit("OK", "bytes=\(data?.count ?? 0)")
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
} else {
|
||||
// WebSocket
|
||||
let target = URL(string: args.count >= 5 ? args[4] : "wss://relay.damus.io")!
|
||||
let ws = session.webSocketTask(with: target)
|
||||
ws.resume()
|
||||
// A successful ping proves the TLS+WS handshake completed end-to-end.
|
||||
ws.sendPing { err in
|
||||
if let err = err {
|
||||
emit("ERROR", "\(err.localizedDescription)")
|
||||
} else {
|
||||
emit("OK", "ws-ping-ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global watchdog so we never hang.
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 25) {
|
||||
emit("ERROR", "timeout")
|
||||
}
|
||||
sem.wait()
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Tor-egress proxy-honoring verification on macOS.
|
||||
#
|
||||
# For each (request-type x key-style) it runs two experiments:
|
||||
# A) proxy UP — did a connection arrive at the SOCKS proxy? (log grows)
|
||||
# B) proxy DOWN — pointed at a dead port; does the request still SUCCEED?
|
||||
# If it succeeds with no proxy, egress went DIRECT (proxy ignored).
|
||||
# If it fails, the proxy setting is being enforced (fail-closed).
|
||||
#
|
||||
# Discriminator: PROXIED = connection observed at proxy AND fails when proxy down
|
||||
# DIRECT = no connection at proxy OR succeeds when proxy down
|
||||
set -u
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PORT=19999
|
||||
DEADPORT=19998 # nothing listens here
|
||||
LOG="$(mktemp -t sockslog)"
|
||||
BIN="$(mktemp -t proxyprobe)"
|
||||
|
||||
echo "== building swift probe =="
|
||||
swiftc -O "$DIR/proxy_probe.swift" -o "$BIN" || { echo "swiftc failed"; exit 1; }
|
||||
|
||||
echo "== starting SOCKS proxy on $PORT =="
|
||||
: > "$LOG"
|
||||
python3 "$DIR/socks5_probe_proxy.py" "$PORT" "$LOG" >/tmp/socksproxy.out 2>&1 &
|
||||
PROXY_PID=$!
|
||||
trap 'kill $PROXY_PID 2>/dev/null' EXIT
|
||||
# wait for READY
|
||||
for _ in $(seq 1 50); do
|
||||
grep -q READY /tmp/socksproxy.out 2>/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
run_case() {
|
||||
local mode="$1" key="$2"
|
||||
# Experiment A: proxy up, watch log
|
||||
local before after target
|
||||
before=$(wc -l < "$LOG" | tr -d ' ')
|
||||
local outA
|
||||
outA=$("$BIN" "$mode" "$key" "$PORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
||||
sleep 0.3
|
||||
after=$(wc -l < "$LOG" | tr -d ' ')
|
||||
local proxied="NO"
|
||||
if [ "$after" -gt "$before" ]; then proxied="YES"; fi
|
||||
local newlines
|
||||
newlines=$(tail -n +"$((before+1))" "$LOG" | tr '\t' ' ' | tr '\n' '|')
|
||||
|
||||
# Experiment B: proxy down (dead port), same request
|
||||
local outB
|
||||
outB=$("$BIN" "$mode" "$key" "$DEADPORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo "CASE mode=$mode key=$key"
|
||||
echo " A(proxy up): $outA | connection_at_proxy=$proxied [$newlines]"
|
||||
echo " B(proxy down): $outB"
|
||||
# verdict
|
||||
local a_ok b_ok
|
||||
a_ok=$(echo "$outA" | awk '{print $4}')
|
||||
b_ok=$(echo "$outB" | awk '{print $4}')
|
||||
local verdict="UNKNOWN"
|
||||
if [ "$proxied" = "YES" ] && [ "$b_ok" = "ERROR" ]; then verdict="PROXIED (enforced)"; fi
|
||||
if [ "$proxied" = "NO" ] && [ "$b_ok" = "OK" ]; then verdict="DIRECT (proxy ignored)"; fi
|
||||
if [ "$proxied" = "YES" ] && [ "$b_ok" = "OK" ]; then verdict="AMBIGUOUS (uses proxy if up, but egresses direct if down)"; fi
|
||||
if [ "$proxied" = "NO" ] && [ "$b_ok" = "ERROR" ]; then verdict="BLOCKED both (network/target issue?)"; fi
|
||||
echo " VERDICT: $verdict"
|
||||
}
|
||||
|
||||
for mode in http ws; do
|
||||
for key in cf raw; do
|
||||
run_case "$mode" "$key"
|
||||
done
|
||||
done
|
||||
echo "========================================"
|
||||
echo "raw proxy log:"; cat "$LOG" | tr '\t' ' '
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal threaded SOCKS5 CONNECT proxy used to verify whether Apple's
|
||||
URLSession actually honors `connectionProxyDictionary` SOCKS settings for
|
||||
different request types (plain HTTPS vs URLSessionWebSocketTask).
|
||||
|
||||
Behavior:
|
||||
- Speaks enough SOCKS5 (no-auth) to complete a CONNECT and then relays
|
||||
bytes bidirectionally to the real destination.
|
||||
- Every accepted CONNECT is appended to a log file as one line:
|
||||
<iso8601>\tCONNECT\t<host>:<port>
|
||||
- Any raw connection that is NOT valid SOCKS5 is logged as:
|
||||
<iso8601>\tNON_SOCKS\t<first-bytes-hex>
|
||||
(this catches the feared case where URLSession sends a raw TLS/HTTP
|
||||
ClientHello straight at the proxy port instead of a SOCKS greeting).
|
||||
|
||||
If a request egresses DIRECTLY (proxy ignored), nothing is logged at all.
|
||||
|
||||
Usage: socks5_probe_proxy.py <listen_port> <log_file>
|
||||
"""
|
||||
import selectors
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
LOG_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def log(logfile, kind, detail):
|
||||
line = f"{datetime.now(timezone.utc).isoformat()}\t{kind}\t{detail}\n"
|
||||
with LOG_LOCK:
|
||||
with open(logfile, "a") as f:
|
||||
f.write(line)
|
||||
sys.stderr.write("[proxy] " + line)
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def recv_exact(sock, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def handle(client, logfile):
|
||||
client.settimeout(15)
|
||||
try:
|
||||
# SOCKS5 greeting: VER=0x05, NMETHODS, METHODS...
|
||||
head = recv_exact(client, 2)
|
||||
if not head:
|
||||
return
|
||||
if head[0] != 0x05:
|
||||
# Not SOCKS5 at all — this is the smoking gun for a direct egress
|
||||
# that mistakenly hit the proxy port. Log the first bytes.
|
||||
rest = b""
|
||||
try:
|
||||
client.setblocking(False)
|
||||
rest = client.recv(64)
|
||||
except Exception:
|
||||
pass
|
||||
log(logfile, "NON_SOCKS", (head + rest).hex())
|
||||
return
|
||||
nmethods = head[1]
|
||||
if nmethods:
|
||||
recv_exact(client, nmethods)
|
||||
# Reply: no authentication required
|
||||
client.sendall(b"\x05\x00")
|
||||
|
||||
# Request: VER, CMD, RSV, ATYP, ADDR, PORT
|
||||
req = recv_exact(client, 4)
|
||||
if not req or req[1] != 0x01: # only CONNECT
|
||||
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
return
|
||||
atyp = req[3]
|
||||
if atyp == 0x01: # IPv4
|
||||
addr = socket.inet_ntoa(recv_exact(client, 4))
|
||||
elif atyp == 0x03: # domain
|
||||
ln = recv_exact(client, 1)[0]
|
||||
addr = recv_exact(client, ln).decode("ascii", errors="replace")
|
||||
elif atyp == 0x04: # IPv6
|
||||
addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16))
|
||||
else:
|
||||
client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
return
|
||||
port = int.from_bytes(recv_exact(client, 2), "big")
|
||||
|
||||
log(logfile, "CONNECT", f"{addr}:{port}")
|
||||
|
||||
# Connect to the real destination and reply success.
|
||||
try:
|
||||
remote = socket.create_connection((addr, port), timeout=15)
|
||||
except Exception as e:
|
||||
log(logfile, "CONNECT_FAIL", f"{addr}:{port} {e}")
|
||||
client.sendall(b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
return
|
||||
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
|
||||
relay(client, remote)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def relay(a, b):
|
||||
a.setblocking(False)
|
||||
b.setblocking(False)
|
||||
sel = selectors.DefaultSelector()
|
||||
sel.register(a, selectors.EVENT_READ, b)
|
||||
sel.register(b, selectors.EVENT_READ, a)
|
||||
try:
|
||||
while True:
|
||||
events = sel.select(timeout=30)
|
||||
if not events:
|
||||
break
|
||||
for key, _ in events:
|
||||
src = key.fileobj
|
||||
dst = key.data
|
||||
try:
|
||||
data = src.recv(65536)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
continue
|
||||
except Exception:
|
||||
return
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
dst.sendall(data)
|
||||
except Exception:
|
||||
return
|
||||
finally:
|
||||
sel.close()
|
||||
for s in (a, b):
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: socks5_probe_proxy.py <port> <logfile>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
port = int(sys.argv[1])
|
||||
logfile = sys.argv[2]
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", port))
|
||||
srv.listen(64)
|
||||
sys.stderr.write(f"[proxy] listening on 127.0.0.1:{port}, log={logfile}\n")
|
||||
sys.stderr.flush()
|
||||
print("READY", flush=True)
|
||||
while True:
|
||||
client, _ = srv.accept()
|
||||
threading.Thread(target=handle, args=(client, logfile), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user