Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 6346870250 Bound egress canary probes with a watchdog and cancel them on invalidate
The Tor egress self-check probe had no outer bound: the proxied session
sets waitsForConnectivity = true, which can defer the per-request timer
indefinitely, leaving a canary request bounded only by URLSession's
default 7-day resource timeout. Because verify() joins concurrent
callers on the in-flight task and invalidate() neither cancelled nor
cleared it, one hung probe wedged every subsequent verify() caller --
even across a Tor restart -- parking awaitingTorForConnections in
NostrRelayManager until process restart.

Fixes (liveness only; the fail-closed policy is unchanged):
- Race every probe against an independent async watchdog
  (probeTimeout, default 20s = the live probe's request timeout, via
  TorEgressVerifier.defaultProbeTimeout). On timeout the probe task is
  cancelled (cooperatively cancelling the underlying URLSessionTask),
  the verdict is the fail-closed .unreachable, and the in-flight slot
  is cleared so the next verify() starts fresh.
- invalidate() now cancels the in-flight probe and clears it (plus the
  throttle timestamp it already cleared), so Tor restart/dormant/
  shutdown genuinely resets the verifier.
- A probe generation counter keeps actor reentrancy safe: a cancelled/
  superseded probe cannot re-seed the throttle/cache that invalidate()
  just cleared or clobber a fresh probe's in-flight slot; its awaiting
  callers resolve promptly as false.

Concurrent-caller join semantics are preserved (one shared probe), and
the race resolves exactly once behind an NSLock never held across an
await. Tests cover the hung-probe timeout bound, invalidate-cancels-
in-flight, post-restart recovery, and the shared-probe invariant, all
with the injected probe/clock harness (no real network).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:01:15 +02:00
jackandGitHub 74f2cd98ab Merge branch 'main' into fix/tor-egress-verification 2026-07-01 23:58:16 +02:00
jackandClaude Fable 5 ad5fb1ddc6 Fail closed on unverified egress and gate the already-ready connect path
Address two review findings on the Tor egress self-check:

1. Bypass on the already-bootstrapped path: shouldWaitForTorBeforeConnecting
   only checked torIsReady, so once Tor was up, connectToRelays/connectToRelay
   (initial connect, reconnect backoff timers, manual retry, subscription-
   triggered connects) opened relay WebSockets without ever running the egress
   canary. The gate now also requires a fresh cached egress verification
   (TorManager.isEgressVerified, backed by a nonisolated TTL snapshot on
   TorEgressVerifier); any path lacking one queues behind awaitEgressReady().

2. unreachable canary no longer allows: an unreachable canary is exactly the
   ambiguous case where we cannot tell whether the platform honored the SOCKS
   proxy, so it now refuses relay connections (fail-closed) instead of
   allow-with-warning. A verifiedTor verdict still allows connection opens for
   its 300s TTL (a cached good verdict covers brief canary blips); after TTL
   expiry or invalidate() (Tor restart/dormant/shutdown) connections stay
   closed until a probe succeeds again. Probe cadence stays bounded via the
   verifier's minRetryInterval, and the relay manager schedules a bounded
   30s-cadence gate retry after wait-attempt exhaustion so a transient canary
   outage recovers automatically (GeoRelayDirectory already retries with its
   own backoff).

Tests: verifier policy (unreachable refuses, recovery via retry, cached-
within-TTL allows during blip, TTL expiry + unreachable refuses, snapshot
invalidation) and relay-manager gating (ready path awaits egress verification,
reconnect requeues behind the gate, exhaustion schedules the bounded retry and
recovers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:57:29 +02:00
jackandClaude Fable 5 c7ee2a4cb4 Add runtime Tor-egress self-check (fail-closed) for Nostr relays
Runtime-verified whether Apple's URLSession honors connectionProxyDictionary
SOCKS settings for Nostr relay traffic (plain HTTPS + URLSessionWebSocketTask),
since the app routes all Nostr traffic through Arti's local SOCKS5 proxy solely
via those keys and Apple does not officially support SOCKS for URLSession on iOS.

Verification harness (scripts/tor-egress-verification/): a minimal SOCKS5 CONNECT
proxy that logs arriving connections, plus a Swift probe that runs an HTTPS GET
and a WebSocket ping through a URLSession configured with the proxy dict. Result:
on macOS (kCFNetworkProxiesSOCKS* constants) and the iOS simulator (raw
"SOCKSProxy"/"SOCKSPort" string keys, the exact app path) BOTH HTTP and WebSocket
are proxied, do remote DNS through the proxy, and the proxied session is
fail-closed (every request errors when the SOCKS proxy is down). The feared
direct-egress leak did not reproduce on the tested platforms.

Defense-in-depth for the platform Apple does not guarantee (physical iOS):
add TorEgressVerifier, which performs a canary request through the proxied
session and asserts the exit is a Tor node (check.torproject.org IsTor==true),
positively detecting a direct egress even if the OS silently ignored the proxy.
Policy: verifiedTor allows (cached for a TTL); notTor refuses (leak detected,
never open relays); unreachable allows-with-warning (session stays fail-closed
by construction). Wired into TorManager.awaitEgressReady() and required by both
NostrRelayManager and GeoRelayDirectory before opening connections. Cache is
invalidated on Tor restart/dormant/shutdown. Never falls back to a direct
connection.

Probe is injected so the policy/caching is unit-tested offline; the live-network
harness lives under scripts/ and is not run by CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:34:10 +02:00
22 changed files with 1374 additions and 875 deletions
-1
View File
@@ -80,4 +80,3 @@ build.log
# Local configs # Local configs
Local.xcconfig Local.xcconfig
*.profraw
+6 -28
View File
@@ -141,44 +141,22 @@ enum TrustLevel: String, Codable {
struct IdentityCache: Codable { struct IdentityCache: Codable {
// Fingerprint -> Social mapping // Fingerprint -> Social mapping
var socialIdentities: [String: SocialIdentity] = [:] var socialIdentities: [String: SocialIdentity] = [:]
// Nickname -> [Fingerprints] reverse index // Nickname -> [Fingerprints] reverse index
// Multiple fingerprints can claim same nickname // Multiple fingerprints can claim same nickname
var nicknameIndex: [String: Set<String>] = [:] var nicknameIndex: [String: Set<String>] = [:]
// Verified fingerprints (cryptographic proof) // Verified fingerprints (cryptographic proof)
var verifiedFingerprints: Set<String> = [] var verifiedFingerprints: Set<String> = []
// Last interaction timestamps (privacy: optional) // Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:] var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats // Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = [] var blockedNostrPubkeys: Set<String> = []
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
// Persisting the signing-key pin is security-critical: it must survive
// app restarts so an attacker cannot replay a known peer's
// noiseKey/peerID with their own signing key and be treated as first
// contact (TOFU downgrade).
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Schema version for future migrations // Schema version for future migrations
var version: Int = 1 var version: Int = 1
init() {}
// Custom decoding so caches written by older builds (without
// `cryptographicIdentities`) still load instead of being discarded.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
}
} }
// //
+43 -104
View File
@@ -145,29 +145,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// In-memory state // In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
// Cryptographic identities (including pinned signing keys) live inside private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// `cache` so they persist across app restarts; see IdentityCache.
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Pending-save coalescing flag. Reads/writes are serialized on `queue`. // Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt // than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call // keeps the dispatch machinery alive and prevents the unit-test process from
// returns the write is already complete and NOTHING is left scheduled on // exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// the queue. This is deliberate a retained DispatchSourceTimer (the // no run loop, so saves never actually fired.)
// original design) kept the dispatch machinery alive and prevented the
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
// (a later design) left a backlog of instrumented barrier saves still
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
// `.profraw`, deadlocking the process at teardown on the constrained CI
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
// neither failure mode is possible. `pendingSave` is now effectively always
// false after any mutation (saveIdentityCache persists inline and clears
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
// and `deinit`.
private var pendingSave = false private var pendingSave = false
// Encryption key // Encryption key
@@ -227,22 +216,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
deinit { deinit {
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread forceSave()
// (including one draining `queue`), and the object is being
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
// (deadlock) and a `queue.async` schedules work that resurrects `self`
// and may not drain before process exit.
//
// A flush here is redundant anyway: every mutating API already
// persists inline within its own barrier, so the keychain is already
// up to date. As a queue-free best-effort belt-and-suspenders, only
// flush if something is still pending. This is a direct read of
// in-hand state safe because a deallocating object has no other
// live references, so nothing can be mutating `cache` concurrently.
if pendingSave {
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
@@ -267,27 +241,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
/// Persists the cache. Always invoked on `queue` under a barrier (its /// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read /// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// while serialized. The encode + keychain write are done here (already on /// and persists it on the same serialized context no timer, nothing left
/// the exclusive barrier context), synchronously, so no separate hop is /// scheduled to keep the process alive.
/// scheduled and nothing is left to keep the process alive.
private func saveIdentityCache() { private func saveIdentityCache() {
pendingSave = true pendingSave = true
// On the barrier context already: snapshot is trivially consistent. performSave()
persist(snapshot: cache)
pendingSave = false
} }
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain. /// Writes the cache to the keychain. Must run on `queue` with exclusive
/// /// (barrier) access.
/// Takes the cache by value so callers can capture a consistent snapshot private func performSave() {
/// under `queue` and then encode without holding it. Reading `cache` guard pendingSave else { return }
/// concurrently with a barrier writer would be a data race on the pendingSave = false
/// dictionary storage, which because `JSONEncoder` walks that storage
/// can spin forever (observed as a CI test-suite hang), so the snapshot
/// must be taken on `queue`, never off it.
private func persist(snapshot: IdentityCache) {
// Never persist under an ephemeral key it would overwrite the real // Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt. // cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else { guard !encryptionKeyIsEphemeral else {
@@ -296,7 +264,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
do { do {
let data = try JSONEncoder().encode(snapshot) let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
@@ -307,26 +275,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
// Force a flush (for app-termination / lifecycle events NOT from // Force immediate save (for app termination / lifecycle events). Mutations
// `deinit`, which persists inline; see the deinit note). Every mutating // already persist synchronously via saveIdentityCache, so this is normally a
// API already persists inline inside its own barrier via // no-op (performSave early-returns when nothing is pending). Runs directly on
// `saveIdentityCache`, so by the time this is called the keychain is // the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// already up to date and this is normally a no-op; it exists as a // reachable from `deinit` and from async tests on the swift-concurrency
// belt-and-suspenders flush of any `pendingSave` left set. // cooperative pool where a blocking barrier-sync can starve/deadlock it.
//
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
// makes the `cache` read race-free (a plain off-queue read races in-flight
// barrier writers JSONEncoder walking a concurrently-mutated dictionary
// can spin forever, which surfaced as a CI hang), and being synchronous it
// leaves nothing scheduled to keep the process alive at teardown. Safe
// against re-entrant deadlock because this is never invoked from `deinit`
// (the only path that can run *on* `queue`).
func forceSave() { func forceSave() {
queue.sync(flags: .barrier) { performSave()
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -340,33 +296,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Cryptographic Identities // MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
///
/// TOFU signing-key pinning: once a signing key has been persisted for a
/// fingerprint, an update carrying a *different* signing key is refused in
/// full (including the claimed-nickname update) and security-logged. This
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` without it, an
/// attacker replaying a victim's noiseKey/peerID with their own signing
/// key could overwrite the victim's persisted identity while the victim is
/// offline or after an app restart. The refusal is permanent: there is
/// currently no targeted in-app way to reset the pin (`setVerified` does
/// not touch it). Recovering from a legitimate signing re-key requires the
/// peer to establish a new noise identity (new peerID) or the local user
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
/// - Parameters: /// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key /// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data /// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity /// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
let now = Date() let now = Date()
if var existing = self.cache.cryptographicIdentities[fingerprint] { if var existing = self.cryptographicIdentities[fingerprint] {
if let pinnedSigningKey = existing.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
return
}
// Update keys if changed // Update keys if changed
if existing.publicKey != noisePublicKey { if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity( existing = CryptographicIdentity(
@@ -376,7 +314,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen, firstSeen: existing.firstSeen,
lastHandshake: now lastHandshake: now
) )
self.cache.cryptographicIdentities[fingerprint] = existing self.cryptographicIdentities[fingerprint] = existing
} else { } else {
// Update signing key and lastHandshake // Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
@@ -387,7 +325,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen, firstSeen: existing.firstSeen,
lastHandshake: now lastHandshake: now
) )
self.cache.cryptographicIdentities[fingerprint] = updated self.cryptographicIdentities[fingerprint] = updated
} }
// Persist updated state (already assigned in branches above) // Persist updated state (already assigned in branches above)
} else { } else {
@@ -399,7 +337,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: now, firstSeen: now,
lastHandshake: now lastHandshake: now
) )
self.cache.cryptographicIdentities[fingerprint] = entry self.cryptographicIdentities[fingerprint] = entry
} }
// Optionally persist claimed nickname into social identity // Optionally persist claimed nickname into social identity
@@ -431,12 +369,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peerID.isShort else { return [] } guard peerID.isShort else { return [] }
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
} }
} }
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
@@ -472,7 +410,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func setFavorite(_ fingerprint: String, isFavorite: Bool) { func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity self.cache.socialIdentities[fingerprint] = identity
@@ -510,7 +448,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked identity.isBlocked = isBlocked
if isBlocked { if isBlocked {
@@ -544,7 +482,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased() let key = pubkeyHexLowercased.lowercased()
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if isBlocked { if isBlocked {
self.cache.blockedNostrPubkeys.insert(key) self.cache.blockedNostrPubkeys.insert(key)
} else { } else {
@@ -561,7 +499,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management // MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity( self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID, peerID: peerID,
sessionStart: Date(), sessionStart: Date(),
@@ -571,7 +509,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func updateHandshakeState(peerID: PeerID, state: HandshakeState) { func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction // If handshake completed, update last interaction
@@ -587,10 +525,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security) SecureLogger.warning("Clearing all identity data", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
@@ -598,7 +537,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func removeEphemeralSession(peerID: PeerID) { func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
} }
} }
@@ -608,7 +547,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
self.cache.verifiedFingerprints.insert(fingerprint) self.cache.verifiedFingerprints.insert(fingerprint)
} else { } else {
+1 -1
View File
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() }, awaitTorReady: { await TorManager.shared.awaitEgressReady() },
makeFetchData: { makeFetchData: {
let session = TorURLSession.shared.session let session = TorURLSession.shared.session
return { request in return { request in
+43 -6
View File
@@ -61,6 +61,11 @@ struct NostrRelayManagerDependencies {
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never> var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool var torEnforced: () -> Bool
var torIsReady: () -> Bool var torIsReady: () -> Bool
/// Synchronous cached egress-gate check: `true` only while a positive Tor
/// egress verification is within its TTL (or Tor is not enforced). When
/// `false`, connections must be queued behind `awaitTorReady`, which runs
/// the async egress self-check.
var torEgressVerified: () -> Bool
var torIsForeground: () -> Bool var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol var makeSession: () -> NostrRelaySessionProtocol
@@ -83,10 +88,14 @@ private extension NostrRelayManagerDependencies {
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(), locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced }, torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady }, torIsReady: { TorManager.shared.isReady },
torEgressVerified: { TorManager.shared.isEgressVerified },
torIsForeground: { TorManager.shared.isForeground() }, torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in awaitTorReady: { completion in
Task.detached { Task.detached {
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 { await MainActor.run {
completion(ready) completion(ready)
} }
@@ -625,8 +634,14 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
/// Every path that opens a relay socket funnels through this check (initial
/// connect, reconnect backoff timers, subscription-triggered connects,
/// manual retry). It must hold connections back when Tor isn't bootstrapped
/// OR when the runtime egress self-check has no fresh positive verdict
/// otherwise the already-bootstrapped path would open sockets without ever
/// running the egress canary.
private var shouldWaitForTorBeforeConnecting: Bool { private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && !dependencies.torIsReady() shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
} }
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) { private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
@@ -674,16 +689,20 @@ final class NostrRelayManager: ObservableObject {
guard ready else { guard ready else {
self.torReadyWaitAttempts += 1 self.torReadyWaitAttempts += 1
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts { if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session) SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
self.queueConnectionsUntilTorReady(pending) self.queueConnectionsUntilTorReady(pending)
} else { } else {
// Still fail-closed (no network), but unblock any callers // Still fail-closed (no network), but unblock any callers
// waiting on EOSE so the UI doesn't hang indefinitely. // waiting on EOSE so the UI doesn't hang indefinitely.
// Queued subscriptions/sends are kept and flush if a later // Queued subscriptions/sends are kept; a bounded-cadence
// trigger (e.g. app foreground) brings Tor up. // retry (below) re-enters the gate so a transient failure
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session) // (Tor stall, canary outage keeping the egress unverified)
// recovers automatically, and any later trigger (e.g. app
// foreground) also re-enters it.
SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session)
self.torReadyWaitAttempts = 0 self.torReadyWaitAttempts = 0
self.unblockPendingEOSECallbacks(reason: "tor-unavailable") self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
self.scheduleTorGateRetry(pending)
} }
return return
} }
@@ -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 /// Park an EOSE callback while Tor is not yet ready, and schedule the same
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback /// fallback timeout `startEOSETracking` uses. Without it, a parked callback
/// would only be unblocked by Tor-readiness retry exhaustion (several /// would only be unblocked by Tor-readiness retry exhaustion (several
+9 -41
View File
@@ -14,14 +14,8 @@ struct BLEAnnounceHandlerEnvironment {
let messageTTL: UInt8 let messageTTL: UInt8
/// Current time source. /// Current time source.
let now: () -> Date let now: () -> Date
/// Noise and signing public keys already recorded for the peer, if any /// Noise public key already recorded for the peer, if any (registry read).
/// (single registry read so both come from one consistent snapshot). let existingNoisePublicKey: (PeerID) -> Data?
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
/// Signing key from the persisted cryptographic identity for the peer, if
/// any. Registry pins do not survive app restarts or offline-peer
/// eviction; this fallback keeps the TOFU signing-key pin effective for
/// returning peers.
let persistedSigningPublicKey: (PeerID) -> Data?
/// Verifies the packet signature against the announced signing key. /// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read). /// Direct link state for the peer (BLE-queue read).
@@ -29,15 +23,13 @@ struct BLEAnnounceHandlerEnvironment {
/// Runs the registry mutation phase under the collections barrier. /// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry. /// Upserts the verified announce into the peer registry.
/// Returns `nil` when the registry refuses the announce because it carries
/// a signing key different from the one already pinned for this peer.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: ( let upsertVerifiedAnnounce: (
_ peerID: PeerID, _ peerID: PeerID,
_ announcement: AnnouncementPacket, _ announcement: AnnouncementPacket,
_ isConnected: Bool, _ isConnected: Bool,
_ now: Date _ now: Date
) -> BLEPeerAnnounceUpdate? ) -> BLEPeerAnnounceUpdate
/// Debounced reconnect-log decision. /// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
@@ -107,16 +99,7 @@ final class BLEAnnounceHandler {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention // Precompute signature verification outside barrier to reduce contention
var existingPeerKeys = env.existingPeerKeys(peerID) let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
if existingPeerKeys.signingPublicKey == nil {
// The registry entry (and its signing-key pin) is dropped on app
// restart and offline-peer eviction, but the persisted
// cryptographic identity survives both. Fall back to it so a
// returning peer is not treated as first contact otherwise an
// attacker could replay the peer's noiseKey/peerID with their own
// signing key and re-pin the identity (TOFU downgrade).
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
}
let hasSignature = packet.signature != nil let hasSignature = packet.signature != nil
let signatureValid: Bool let signatureValid: Bool
if hasSignature { if hasSignature {
@@ -130,18 +113,13 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate( let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature, hasSignature: hasSignature,
signatureValid: signatureValid, signatureValid: signatureValid,
existingNoisePublicKey: existingPeerKeys.noisePublicKey, existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey, announcedNoisePublicKey: announcement.noisePublicKey
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
announcedSigningPublicKey: announcement.signingPublicKey
) )
if case .reject(.keyMismatch) = trustDecision { if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
} }
if case .reject(.signingKeyMismatch) = trustDecision { let verifiedAnnounce = trustDecision.isVerified
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
}
var verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
@@ -161,22 +139,12 @@ final class BLEAnnounceHandler {
return return
} }
// The registry re-checks the signing-key pin inside the barrier. let update = env.upsertVerifiedAnnounce(
// The pre-barrier trust check reads the registry outside the
// barrier, so this closes the race where two announces for the
// same peer are evaluated concurrently.
guard let update = env.upsertVerifiedAnnounce(
peerID, peerID,
announcement, announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now now
) else { )
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
isNewPeer = update.isNewPeer isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected isReconnectedPeer = update.wasDisconnected
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature case missingSignature
case invalidSignature case invalidSignature
case keyMismatch case keyMismatch
case signingKeyMismatch
} }
enum BLEAnnounceTrustDecision: Equatable { enum BLEAnnounceTrustDecision: Equatable {
@@ -73,25 +72,12 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool, hasSignature: Bool,
signatureValid: Bool, signatureValid: Bool,
existingNoisePublicKey: Data?, existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data, announcedNoisePublicKey: Data
existingSigningPublicKey: Data?,
announcedSigningPublicKey: Data
) -> BLEAnnounceTrustDecision { ) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch) return .reject(.keyMismatch)
} }
// TOFU signing-key pinning. The packet signature only proves the
// announce is self-consistent it is verified against the Ed25519 key
// carried *inside the same announce*. Since peerIDs derive from the
// broadcast (public) noise key, an attacker can replay a victim's
// peerID+noiseKey with their own signing key and a valid
// self-signature. Once we have bound a signing key to this peer,
// refuse to silently replace it.
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
return .reject(.signingKeyMismatch)
}
guard hasSignature else { guard hasSignature else {
return .reject(.missingSignature) return .reject(.missingSignature)
} }
+2 -18
View File
@@ -150,14 +150,6 @@ struct BLEPeerRegistry {
peers[peerID] = peer peers[peerID] = peer
} }
/// Applies a verified announce to the registry.
///
/// TOFU signing-key pinning: once a signing key has been bound to this
/// peer entry, an announce carrying a *different* signing key is refused
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
/// derived from the (public) noise key, so without pinning an attacker
/// could replay a victim's noiseKey/peerID with their own signing key and
/// silently take over the victim's mesh identity and nickname.
mutating func upsertVerifiedAnnounce( mutating func upsertVerifiedAnnounce(
peerID: PeerID, peerID: PeerID,
nickname: String, nickname: String,
@@ -165,15 +157,8 @@ struct BLEPeerRegistry {
signingPublicKey: Data?, signingPublicKey: Data?,
isConnected: Bool, isConnected: Bool,
now: Date now: Date
) -> BLEPeerAnnounceUpdate? { ) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID] let existing = peers[peerID]
if let pinnedSigningKey = existing?.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
return nil
}
let update = BLEPeerAnnounceUpdate( let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil, isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false, wasDisconnected: existing?.isConnected == false,
@@ -185,8 +170,7 @@ struct BLEPeerRegistry {
nickname: nickname, nickname: nickname,
isConnected: isConnected, isConnected: isConnected,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
// Never drop an already-pinned signing key. signingPublicKey: signingPublicKey,
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: now lastSeen: now
) )
+4 -19
View File
@@ -3028,21 +3028,9 @@ extension BLEService {
}, },
messageTTL: messageTTL, messageTTL: messageTTL,
now: { Date() }, now: { Date() },
existingPeerKeys: { [weak self] peerID in existingNoisePublicKey: { [weak self] peerID in
guard let self = self else { return (nil, nil) }
return self.collectionsQueue.sync {
let info = self.peerRegistry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
}
},
persistedSigningPublicKey: { [weak self] peerID in
// Same synchronous identity-manager read pattern as
// signedSenderDisplayName(for:from:); the manager serializes
// access on its own internal queue.
guard let self = self else { return nil } guard let self = self else { return nil }
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
.compactMap { $0.signingPublicKey }
.first
}, },
verifySignature: { [weak self] packet, signingPublicKey in verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
@@ -3055,17 +3043,14 @@ extension BLEService {
}, },
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly. // Called from inside withRegistryBarrier; access registry directly.
guard let self = self else { self?.peerRegistry.upsertVerifiedAnnounce(
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}
return self.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID, peerID: peerID,
nickname: announcement.nickname, nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected, isConnected: isConnected,
now: now now: now
) ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}, },
shouldEmitReconnectLog: { [weak self] peerID, now in shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly. // Called from inside withRegistryBarrier; access debouncer directly.
+4
View File
@@ -171,6 +171,10 @@ enum TransportConfig {
// How many consecutive Tor-readiness waits (each bounded by TorManager's // How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers. // bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3 static let nostrTorReadyMaxWaitAttempts: Int = 3
// After Tor-gate wait attempts are exhausted (Tor never ready, or egress
// self-check unverified), retry the whole gate at this bounded cadence so
// a transient failure (e.g. canary outage) recovers without user action.
static let nostrTorGateRetrySeconds: TimeInterval = 30.0
static let nostrPendingSendQueueCap: Int = 200 static let nostrPendingSendQueueCap: Int = 200
// Sample interval for the send-queue overflow warning (first + every Nth // Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only. // dropped event). Drops are ephemeral presence/geo traffic log-only.
@@ -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)
}
}
@@ -6,12 +6,9 @@ import Testing
struct BLEAnnounceHandlerTests { struct BLEAnnounceHandlerTests {
private final class Recorder { private final class Recorder {
var existingNoisePublicKey: Data? var existingNoisePublicKey: Data?
var existingSigningPublicKey: Data?
var persistedSigningPublicKey: Data?
var persistedSigningKeyQueries: [PeerID] = []
var signatureValid = true var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var dedupSeenIDs: Set<String> = [] var dedupSeenIDs: Set<String> = []
var shouldEmitReconnectLogResult = true var shouldEmitReconnectLogResult = true
@@ -38,11 +35,7 @@ struct BLEAnnounceHandlerTests {
localPeerID: { localPeerID }, localPeerID: { localPeerID },
messageTTL: TransportConfig.messageTTLDefault, messageTTL: TransportConfig.messageTTLDefault,
now: { now }, now: { now },
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) }, existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
persistedSigningPublicKey: { peerID in
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
verifySignature: { packet, signingPublicKey in verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey)) recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid return recorder.signatureValid
@@ -375,168 +368,6 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.topologyUpdates.first?.neighbors == neighbors) #expect(recorder.topologyUpdates.first?.neighbors == neighbors)
} }
@Test
func matchingPinnedSigningKeyIsAccepted() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9A, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
// Matches the signing key encoded by makeAnnouncePacket.
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9B, count: 32)
let peerID = PeerID(publicKey: noiseKey)
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
recorder.signatureValid = true
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
// Registry has no entry (app restart or offline-peer eviction), but
// the persisted cryptographic identity still pins the victim's
// signing key. An attacker replaying the victim's noiseKey/peerID
// with their own signing key must not be treated as first contact.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9D, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = nil
recorder.existingSigningPublicKey = nil
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries == [peerID])
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
// Legitimate returning peer: registry entry evicted, persisted pin
// matches the announced signing key accepted like a normal announce.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9E, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
// Matches the signing key encoded by makeAnnouncePacket.
recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func registryPinnedSigningKeySkipsPersistedLookup() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9F, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries.isEmpty)
#expect(recorder.upsertCalls.count == 1)
}
@Test
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9C, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64),
directNeighbors: [Data(repeating: 0xAB, count: 8)]
)
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
// but the registry itself refuses to replace its pinned signing key.
let recorder = Recorder()
recorder.upsertResult = nil
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.afterglowDelays.isEmpty)
}
@Test @Test
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws { func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
@@ -560,251 +391,6 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
} }
@Test
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
// Real crypto: the attacker crafts a fully self-consistent announce
// (victim's noiseKey/peerID, attacker's signing key and nickname,
// valid packet signature made with the attacker's key). Without
// signing-key pinning this used to overwrite the victim's registry
// entry and persisted identity.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
final class RegistryBox {
var registry = BLEPeerRegistry()
var persistedIdentities: [AnnouncementPacket] = []
}
let box = RegistryBox()
let environment = BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
persistedSigningPublicKey: { _ in nil },
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
box.persistedIdentities.append(announcement)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
let handler = BLEAnnounceHandler(environment: environment)
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
// Legitimate announce from the victim is accepted and pinned.
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
handler.handle(victimAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// Attacker announce with a valid self-signature must be rejected.
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
handler.handle(attackerAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// The victim's subsequent announces (same pinned key) still work.
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
handler.handle(victimRename, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
#expect(box.persistedIdentities.count == 2)
}
@Test
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
// Real crypto + real persistence: the victim announces and gets
// pinned, then the registry entry disappears (offline-peer eviction
// via reconcileConnectivity, or app restart which starts with an
// empty registry). The attacker replays the victim's
// noiseKey/peerID with their own signing key and a valid
// self-signature the persisted identity must still block the
// takeover, and must not be overwritten. The victim (same signing
// key) must be re-accepted.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
let identityKeychain = MockKeychain()
let identityManager = SecureIdentityStateManager(identityKeychain)
final class RegistryBox {
var registry = BLEPeerRegistry()
}
let box = RegistryBox()
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
// Mirrors the BLEService wiring: fall back to the persisted
// cryptographic identity.
persistedSigningPublicKey: { peerID in
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
},
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
}
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
func persistedIdentity() -> CryptographicIdentity? {
// queue.sync read; fences the manager's pending barrier writes.
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
}
// 1. Victim announces: pinned in the registry and persisted.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
// 2. Registry entry disappears (eviction / restart).
_ = box.registry.remove(peerID)
#expect(box.registry.info(for: peerID) == nil)
// 3. Attacker replay with own signing key: rejected via the persisted
// pin, and neither the registry nor the persisted identity change.
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
// 4. Victim re-announces with the same signing key: accepted again.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
// 5. Simulated app restart: a fresh identity manager reloads the pin
// from the (mock) keychain, and a fresh registry starts empty. The
// attacker replay is still rejected.
identityManager.forceSave()
let reloadedManager = SecureIdentityStateManager(identityKeychain)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
box.registry = BLEPeerRegistry()
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
// ...while the victim is accepted after the restart.
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
}
private func expectNoSideEffects(_ recorder: Recorder) { private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.barrierCount == 0) #expect(recorder.barrierCount == 0)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
@@ -123,9 +123,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: false, hasSignature: false,
signatureValid: false, signatureValid: false,
existingNoisePublicKey: nil, existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32), announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .reject(.missingSignature)) #expect(decision == .reject(.missingSignature))
@@ -138,9 +136,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true, hasSignature: true,
signatureValid: false, signatureValid: false,
existingNoisePublicKey: nil, existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32), announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .reject(.invalidSignature)) #expect(decision == .reject(.invalidSignature))
@@ -152,9 +148,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true, hasSignature: true,
signatureValid: true, signatureValid: true,
existingNoisePublicKey: Data(repeating: 0xAA, count: 32), existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32), announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .reject(.keyMismatch)) #expect(decision == .reject(.keyMismatch))
@@ -168,51 +162,13 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true, hasSignature: true,
signatureValid: true, signatureValid: true,
existingNoisePublicKey: noiseKey, existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey, announcedNoisePublicKey: noiseKey
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .verified) #expect(decision == .verified)
#expect(decision.isVerified) #expect(decision.isVerified)
} }
@Test
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
let noiseKey = Data(repeating: 0xCC, count: 32)
// Attacker replays the victim's noiseKey/peerID with their own signing
// key and a valid self-signature; the pinned key must win.
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
)
#expect(decision == .reject(.signingKeyMismatch))
#expect(!decision.isVerified)
}
@Test
func trustPolicyAcceptsMatchingPinnedSigningKey() {
let noiseKey = Data(repeating: 0xCC, count: 32)
let signingKey = Data(repeating: 0x99, count: 32)
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: signingKey,
announcedSigningPublicKey: signingKey
)
#expect(decision == .verified)
}
@Test @Test
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() { func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
let directNew = BLEAnnounceResponsePolicy.plan( let directNew = BLEAnnounceResponsePolicy.plan(
@@ -6,12 +6,12 @@ import Testing
@Suite("BLE peer registry tests") @Suite("BLE peer registry tests")
struct BLEPeerRegistryTests { struct BLEPeerRegistryTests {
@Test("upserted announces track new, reconnect, and rename transitions") @Test("upserted announces track new, reconnect, and rename transitions")
func upsertVerifiedAnnounceTracksTransitions() throws { func upsertVerifiedAnnounceTracksTransitions() {
var registry = BLEPeerRegistry() var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788") let peerID = PeerID(str: "1122334455667788")
let firstSeen = Date(timeIntervalSince1970: 100) let firstSeen = Date(timeIntervalSince1970: 100)
let firstResult = registry.upsertVerifiedAnnounce( let first = registry.upsertVerifiedAnnounce(
peerID: peerID, peerID: peerID,
nickname: "alice", nickname: "alice",
noisePublicKey: Data([1, 2, 3]), noisePublicKey: Data([1, 2, 3]),
@@ -19,7 +19,6 @@ struct BLEPeerRegistryTests {
isConnected: true, isConnected: true,
now: firstSeen now: firstSeen
) )
let first = try #require(firstResult)
#expect(first.isNewPeer) #expect(first.isNewPeer)
#expect(!first.wasDisconnected) #expect(!first.wasDisconnected)
@@ -28,7 +27,7 @@ struct BLEPeerRegistryTests {
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
registry.markDisconnected(peerID) registry.markDisconnected(peerID)
let reconnectResult = registry.upsertVerifiedAnnounce( let reconnect = registry.upsertVerifiedAnnounce(
peerID: peerID, peerID: peerID,
nickname: "alice-renamed", nickname: "alice-renamed",
noisePublicKey: Data([1, 2, 3]), noisePublicKey: Data([1, 2, 3]),
@@ -36,7 +35,6 @@ struct BLEPeerRegistryTests {
isConnected: true, isConnected: true,
now: firstSeen.addingTimeInterval(1) now: firstSeen.addingTimeInterval(1)
) )
let reconnect = try #require(reconnectResult)
#expect(!reconnect.isNewPeer) #expect(!reconnect.isNewPeer)
#expect(reconnect.wasDisconnected) #expect(reconnect.wasDisconnected)
@@ -44,85 +42,6 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed") #expect(registry.info(for: peerID)?.nickname == "alice-renamed")
} }
@Test("pinned signing key cannot be silently replaced by a later announce")
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x11, count: 32)
let victimSigningKey = Data(repeating: 0x42, count: 32)
let attackerSigningKey = Data(repeating: 0x66, count: 32)
let firstSeen = Date(timeIntervalSince1970: 100)
let pinResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "victim",
noisePublicKey: noiseKey,
signingPublicKey: victimSigningKey,
isConnected: true,
now: firstSeen
)
#expect(pinResult != nil)
// Attacker replays the victim's noiseKey/peerID with their own
// signing key and nickname; the upsert must be refused wholesale.
let attack = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "attacker",
noisePublicKey: noiseKey,
signingPublicKey: attackerSigningKey,
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(attack == nil)
let info = try #require(registry.info(for: peerID))
#expect(info.nickname == "victim")
#expect(info.signingPublicKey == victimSigningKey)
// A legitimate re-announce with the pinned key is still accepted.
let legit = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "victim-renamed",
noisePublicKey: noiseKey,
signingPublicKey: victimSigningKey,
isConnected: true,
now: firstSeen.addingTimeInterval(2)
)
#expect(legit != nil)
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
}
@Test("announce without a signing key keeps the pinned key")
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x11, count: 32)
let signingKey = Data(repeating: 0x42, count: 32)
let firstSeen = Date(timeIntervalSince1970: 100)
let initialResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: noiseKey,
signingPublicKey: signingKey,
isConnected: true,
now: firstSeen
)
#expect(initialResult != nil)
let update = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: noiseKey,
signingPublicKey: nil,
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(update != nil)
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
}
@Test("reachability keeps recent verified offline peers only when mesh is attached") @Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() { func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788") let offlinePeer = PeerID(str: "1122334455667788")
@@ -111,6 +111,116 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(connected) XCTAssertTrue(connected)
} }
func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async {
// Tor is bootstrapped, but the egress self-check has no fresh verdict:
// the ready path must still queue behind the async egress gate instead
// of opening sockets directly.
let context = makeContext(
permission: .authorized,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: false
)
context.manager.connect()
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
// Egress verification succeeds (awaitEgressReady returned true, which
// implies the verifier now holds a fresh cached verdict).
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let connectedAfterVerification = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterVerification)
}
func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async {
// Reconnect backoff timers call connectToRelay directly; when the
// cached egress verification has lapsed by then, the reconnect must go
// back through the gate rather than opening a socket.
let relayURL = "wss://egress-reconnect.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: true
)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
// The socket drops and the cached verification expires meanwhile.
context.torEgressVerified.value = false
context.sessionFactory.latestConnection(for: relayURL)?
.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
XCTAssertTrue(reconnectScheduled)
context.scheduler.runNext()
let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 }
XCTAssertTrue(queuedBehindGate)
// No new socket until the egress gate passes.
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let reconnected = await waitUntil {
context.sessionFactory.requestedURLs.count == 2 &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(reconnected)
}
func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async {
// A persistent unverified egress exhausts the wait attempts; a bounded
// low-frequency retry must then recover automatically once
// verification succeeds (e.g. transient canary outage ends).
let relayURL = "wss://egress-gate-retry.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: false
)
context.manager.ensureConnections(to: [relayURL])
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
// Fail-closed, with a single bounded-cadence retry scheduled.
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(context.scheduler.scheduled.count, 1)
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrTorGateRetrySeconds)
// The outage ends before the retry fires.
let attemptsBefore = context.torWaiter.awaitCallCount
context.scheduler.runNext()
let regated = await waitUntil { context.torWaiter.awaitCallCount == attemptsBefore + 1 }
XCTAssertTrue(regated)
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let recovered = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(recovered)
}
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async { func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
let relayURL = "wss://tor-eose-unblock.example" let relayURL = "wss://tor-eose-unblock.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -1449,6 +1559,7 @@ final class NostrRelayManagerTests: XCTestCase {
userTorEnabled: Bool = false, userTorEnabled: Bool = false,
torEnforced: Bool = false, torEnforced: Bool = false,
torIsReady: Bool = true, torIsReady: Bool = true,
torEgressVerified: Bool = true,
torIsForeground: Bool = true, torIsForeground: Bool = true,
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
) -> RelayManagerTestContext { ) -> RelayManagerTestContext {
@@ -1458,6 +1569,7 @@ final class NostrRelayManagerTests: XCTestCase {
let scheduler = MockRelayScheduler() let scheduler = MockRelayScheduler()
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000)) let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
let torWaiter = MockTorWaiter(isReady: torIsReady) let torWaiter = MockTorWaiter(isReady: torIsReady)
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
let torForeground = MutableBool(value: torIsForeground) let torForeground = MutableBool(value: torIsForeground)
let activationFlag = MutableBool(value: activationAllowed) let activationFlag = MutableBool(value: activationAllowed)
let manager = NostrRelayManager( let manager = NostrRelayManager(
@@ -1470,6 +1582,7 @@ final class NostrRelayManagerTests: XCTestCase {
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
torEnforced: { torEnforced }, torEnforced: { torEnforced },
torIsReady: { torWaiter.isReady }, torIsReady: { torWaiter.isReady },
torEgressVerified: { torEgressVerifiedFlag.value },
torIsForeground: { torForeground.value }, torIsForeground: { torForeground.value },
awaitTorReady: torWaiter.await(completion:), awaitTorReady: torWaiter.await(completion:),
makeSession: { sessionFactory }, makeSession: { sessionFactory },
@@ -1489,6 +1602,7 @@ final class NostrRelayManagerTests: XCTestCase {
clock: clock, clock: clock,
activationAllowed: activationFlag, activationAllowed: activationFlag,
torWaiter: torWaiter, torWaiter: torWaiter,
torEgressVerified: torEgressVerifiedFlag,
torForeground: torForeground torForeground: torForeground
) )
} }
@@ -1543,6 +1657,7 @@ private struct RelayManagerTestContext {
let clock: MutableClock let clock: MutableClock
let activationAllowed: MutableBool let activationAllowed: MutableBool
let torWaiter: MockTorWaiter let torWaiter: MockTorWaiter
let torEgressVerified: MutableBool
let torForeground: MutableBool let torForeground: MutableBool
} }
@@ -79,100 +79,6 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
} }
func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let noisePublicKey = Data(repeating: 0x11, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
let peerID = PeerID(publicKey: noisePublicKey)
let victimSigningKey = Data(repeating: 0x22, count: 32)
let attackerSigningKey = Data(repeating: 0x66, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim"
)
let pinned = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
}
XCTAssertTrue(pinned)
// Attacker upsert with a different signing key must be refused in
// full signing key AND claimed nickname stay the victim's.
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: attackerSigningKey,
claimedNickname: "attacker"
)
// Synchronous reads fence the manager's pending barrier writes.
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
// The legitimate peer (same signing key) can still update.
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim-renamed"
)
let renamed = await waitUntil {
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed"
}
XCTAssertTrue(renamed)
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
}
func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let noisePublicKey = Data(repeating: 0x13, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
let peerID = PeerID(publicKey: noisePublicKey)
let victimSigningKey = Data(repeating: 0x24, count: 32)
let attackerSigningKey = Data(repeating: 0x77, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim"
)
let pinned = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
}
XCTAssertTrue(pinned)
manager.forceSave()
// Simulated app restart: the pin must survive and still refuse a
// different signing key.
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
reloaded.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: attackerSigningKey,
claimedNickname: "attacker"
)
XCTAssertEqual(
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
}
func test_setBlocked_clearsFavoriteState() async { func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain()) let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32) let fingerprint = String(repeating: "ab", count: 32)
+1
View File
@@ -30,6 +30,7 @@ let package = Package(
"TorManager.swift", "TorManager.swift",
"TorURLSession.swift", "TorURLSession.swift",
"TorNotifications.swift", "TorNotifications.swift",
"TorEgressVerifier.swift",
], ],
linkerSettings: [ linkerSettings: [
.linkedLibrary("resolv"), .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 socksReady: Bool = false { didSet { recomputeReady() } }
private var restarting: Bool = false 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). // Whether the app must enforce Tor for all connections (fail-closed).
public var torEnforced: Bool { public var torEnforced: Bool {
#if BITCHAT_DEV_ALLOW_CLEARNET #if BITCHAT_DEV_ALLOW_CLEARNET
@@ -125,6 +133,32 @@ public final class TorManager: ObservableObject {
return await MainActor.run(body: { self.networkPermitted }) return await MainActor.run(body: { self.networkPermitted })
} }
/// Synchronous, cached view of the egress gate: `true` while a positive
/// egress verification is within its TTL (or when Tor is not enforced).
/// When this is `false`, callers must route through `awaitEgressReady()`
/// (which probes) before opening any connection never connect directly.
public var isEgressVerified: Bool {
guard torEnforced else { return true }
return egressVerifier.hasFreshVerification
}
/// Like `awaitReady`, but additionally requires that a canary request
/// 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 // MARK: - Filesystem
func dataDirectoryURL() -> URL? { func dataDirectoryURL() -> URL? {
@@ -325,6 +359,8 @@ public final class TorManager: ObservableObject {
self.socksReady = false self.socksReady = false
self.isStarting = false self.isStarting = false
} }
// Force a fresh egress self-check once Tor comes back.
Task { await egressVerifier.invalidate() }
} }
public func shutdownCompletely() { 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() // 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 // 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.isDormant = false
self.lastRestartAt = Date() self.lastRestartAt = Date()
} }
// New Arti instance means new circuits; re-verify egress after restart.
await egressVerifier.invalidate()
_ = arti_stop() _ = arti_stop()
@@ -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
View File
@@ -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
View File
@@ -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()