Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 10ba71b1f8 Fix panic-wipe window, frame byte bound, and delivery-side test waits
Three confirmed defects from adversarial review of the per-relay inbound
pipeline (PR #1352):

- MEDIUM-2: a detached gift-wrap decrypt spawned just before a panic wipe
  strongly captures the pre-wipe Nostr private key and could deliver
  plaintext into ChatViewModel after the wipe. Add a per-pipeline
  monotonic wipe generation (NostrInboundPipeline.wipeGeneration), bumped
  from panicClearAllData via invalidateInFlightDecrypts(); spawn sites
  capture it and every main-actor hop drops the task's result on
  mismatch. The account-mailbox path (processNostrMessage) had the same
  pre-existing hazard and gets the identical guard, capturing the
  generation atomically with the identity fetch.

- MEDIUM-1: the per-relay stream cap bounds FRAMES (256) but not BYTES;
  with the URLSession default of 1 MiB per WebSocket frame a hostile
  relay could pile up ~256 MiB. Set URLSessionWebSocketTask
  .maximumMessageSize to TransportConfig.nostrInboundMaxFrameBytes
  (512 KiB — an order of magnitude above any legitimate Nostr event or
  gift wrap we produce or expect), halving the worst case to 128 MiB,
  and correct the "cannot exhaust memory" comments to state the actual
  cap × maxFrameBytes bound.

- LOW-5: three NostrRelayManagerTests gated on messagesReceived (first
  main hop) then immediately asserted delivery-side state that only
  lands after off-main verification plus a second main hop. Wait on the
  delivery-side state (receivedIDs / duplicate-drop counts) directly,
  keeping all assertions.

Also: brief comment documenting pendingGiftWrapIDs growth (LOW-6) and a
regression test that a panic wipe issued after spawn drops the decrypted
result while leaving the pipeline usable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:08:29 +02:00
jackandClaude Fable 5 2d5250e49a Verify Nostr inbound per relay to avoid head-of-line blocking
The single global inbound consumer serialized ALL relay traffic behind
one Schnorr-verification queue, so a burst of EVENT frames from one
busy/malicious relay stalled DMs, OKs, EOSEs, and events from every other
relay (codex P2). Give each relay connection its own bounded AsyncStream +
detached serial consumer instead: N relays verify in parallel while each
relay's frames stay in arrival order (per-relay ordering preserves the
per-subscription ordering that actually matters, since a subscription's
events for a relay all arrive on that relay's socket).

Continuations live in a lock-guarded Sendable router so the non-isolated
socket receive callback can route a frame to the right relay stream with
no per-frame main hop; the main actor owns pipeline start/teardown, wired
into connect, disconnect, panic wipe, per-relay disconnect, retry, and the
default-relay revoke path. Streams use .bufferingNewest so a relay flooding
faster than it verifies sheds its OWN oldest frames — it can neither exhaust
memory nor starve other relays.

Security invariants are unchanged: signature verified exactly once, off
main; dedup pre-check-before / record-after-verify (forged copies still
can't poison the dedup set); the atomic main-actor check-and-record in
deliverVerifiedInboundEvent; the inner NIP-17 seal check untouched.

Tests: existing per-relay in-order-delivery and tampered-signature
dedup-poison tests still pass; add a cross-relay non-blocking test proving
a large backlog on relay A does not delay a later frame on relay B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:08:55 +02:00
jackandGitHub fef775bae2 Merge branch 'main' into perf/nostr-inbound-offmain-verify-once 2026-07-01 23:52:20 +02:00
jackandClaude Fable 5 8af6fc2d5f Verify Nostr inbound signatures once, off the main actor
Every inbound relay event was Schnorr-verified TWICE, both times on the
main actor: once in NostrRelayManager.handleParsedMessage and again in
each NostrInboundPipeline / GeoPresenceTracker handler. Each
verification re-serializes the event JSON, hashes it, and runs a
secp256k1 Schnorr verify, so busy geohashes paid double crypto on the
UI thread. Geohash gift-wrap NIP-17 decryption (two ECDH+ChaCha layers)
also ran on the main actor.

Changes:
- NostrRelayManager now owns a serial off-main inbound pipeline
  (AsyncStream + single consumer task): frames are parsed and verified
  in arrival order off the main actor, preserving per-subscription
  delivery order. This is the single verification point for the whole
  inbound path; downstream handlers only ever see verified events.
- Dedup stays two-phase and unpoisonable: a cheap main-actor duplicate
  LOOKUP runs before verification (duplicate fan-in from several relays
  never pays for crypto — previously every duplicate was verified), and
  events are RECORDED as seen only after the signature verifies, so a
  forged-signature copy can never suppress the genuine event.
- NostrInboundPipeline and GeoPresenceTracker drop their redundant
  re-verification; geohash gift-wrap decryption moves off the main
  actor following the existing account-mailbox Task.detached pattern
  (atomic main-actor check-and-record, then decrypt off-main, then hop
  back for state updates).
- Tests: relay-level coverage for tampered gift wraps and for in-order
  delivery of back-to-back frames; pipeline-level tampered-signature
  tests move to the relay boundary where the invariant now lives; the
  mock relay connection queues frames emitted before receive re-arms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:40:23 +02:00
18 changed files with 664 additions and 1508 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
+246 -84
View File
@@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
let task = base.webSocketTask(with: url)
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
}
}
@@ -61,11 +68,6 @@ struct NostrRelayManagerDependencies {
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
/// Synchronous cached egress-gate check: `true` only while a positive Tor
/// egress verification is within its TTL (or Tor is not enforced). When
/// `false`, connections must be queued behind `awaitTorReady`, which runs
/// the async egress self-check.
var torEgressVerified: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
@@ -88,14 +90,10 @@ private extension NostrRelayManagerDependencies {
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torEgressVerified: { TorManager.shared.isEgressVerified },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
// 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()
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
@@ -115,7 +113,10 @@ private extension NostrRelayManagerDependencies {
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
@@ -221,7 +222,33 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -275,7 +302,70 @@ final class NostrRelayManager: ObservableObject {
}
.store(in: &cancellables)
}
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays
func connect() {
// Global network policy gate
@@ -290,6 +380,8 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked
@@ -319,6 +411,7 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true)
subscriptions.removeAll()
pendingSubscriptions.removeAll()
@@ -569,6 +662,7 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
@@ -634,14 +728,8 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods
/// Every path that opens a relay socket funnels through this check (initial
/// connect, reconnect backoff timers, subscription-triggered connects,
/// manual retry). It must hold connections back when Tor isn't bootstrapped
/// OR when the runtime egress self-check has no fresh positive verdict
/// otherwise the already-bootstrapped path would open sockets without ever
/// running the egress canary.
private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
shouldUseTor && !dependencies.torIsReady()
}
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
@@ -689,20 +777,16 @@ final class NostrRelayManager: ObservableObject {
guard ready else {
self.torReadyWaitAttempts += 1
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
self.queueConnectionsUntilTorReady(pending)
} else {
// Still fail-closed (no network), but unblock any callers
// waiting on EOSE so the UI doesn't hang indefinitely.
// Queued subscriptions/sends are kept; a bounded-cadence
// retry (below) re-enters the gate so a transient failure
// (Tor stall, canary outage keeping the egress unverified)
// recovers automatically, and any later trigger (e.g. app
// foreground) also re-enters it.
SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session)
// Queued subscriptions/sends are kept and flush if a later
// trigger (e.g. app foreground) brings Tor up.
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
self.torReadyWaitAttempts = 0
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
self.scheduleTorGateRetry(pending)
}
return
}
@@ -712,24 +796,6 @@ final class NostrRelayManager: ObservableObject {
}
}
/// After the Tor-gate wait attempts are exhausted, keep a low-frequency
/// retry alive so the gate re-opens without an external trigger once the
/// transient failure clears. Bounded cadence: one wait cycle per
/// `nostrTorGateRetrySeconds`; the egress verifier additionally throttles
/// actual canary probes to one per its `minRetryInterval`.
private func scheduleTorGateRetry(_ relayUrls: [String]) {
guard !relayUrls.isEmpty else { return }
let generation = connectionGeneration
dependencies.scheduleAfter(TransportConfig.nostrTorGateRetrySeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
// Void after disconnect/reset: those paths bump the generation.
guard generation == self.connectionGeneration else { return }
self.queueConnectionsUntilTorReady(relayUrls)
}
}
}
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
/// would only be unblocked by Tor-readiness retry exhaustion (several
@@ -936,7 +1002,11 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task
task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages
receiveMessage(from: task, relayUrl: urlString)
@@ -995,14 +1065,13 @@ final class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Hand the raw frame to this relay's serial inbound pipeline:
// parsing and signature verification run off-main, in arrival
// order, independently of every other relay's pipeline. Routing
// through the lock-guarded router keeps this off the main actor
// (no per-frame main hop).
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
// Continue receiving
Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl)
@@ -1020,35 +1089,55 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers)
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
/// relays never pays for Schnorr verification. Recording happens only
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
}
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event(let subId, let event):
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
@@ -1143,6 +1232,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
@@ -1231,8 +1321,9 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
}
@@ -1325,6 +1416,77 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
+20 -4
View File
@@ -55,6 +55,26 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Bounded per-relay inbound frame buffer. Each relay connection owns its
// own serial verify pipeline; if a relay floods faster than its Schnorr
// verification drains, the oldest buffered frames for THAT relay are
// dropped (bufferingNewest) so one relay cannot stall other relays.
// Nostr inbound is already best-effort (relays are redundant and events
// replay), so dropping a flooding relay's backlog is safe. Together with
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
// cap × maxFrameBytes (128 MiB) per hostile relay bounded, not zero.
static let nostrInboundPerRelayBufferCap: Int = 256
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
// .maximumMessageSize (oversized frames fail the receive instead of
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
// and most public relays reject events beyond ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
@@ -171,10 +191,6 @@ enum TransportConfig {
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
// After Tor-gate wait attempts are exhausted (Tor never ready, or egress
// self-check unverified), retry the whole gate at this bounded cadence so
// a transient failure (e.g. canary outage) recovers without user action.
static let nostrTorGateRetrySeconds: TimeInterval = 30.0
static let nostrPendingSendQueueCap: Int = 200
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
+5
View File
@@ -1177,6 +1177,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrRelayManager.shared.resetForPanicWipe()
// Clearing relay handlers stops NEW events, but a detached gift-wrap
// decrypt spawned just before the wipe still holds a pre-wipe key and
// ciphertext; bump the pipeline's wipe generation so its result is
// dropped at the main-actor delivery hop instead of landing here.
nostrCoordinator.inbound.invalidateInFlightDecrypts()
nostrRelayManager = nil
// Clear Nostr identity associations
+2 -1
View File
@@ -103,7 +103,8 @@ final class GeoPresenceTracker {
else {
return
}
guard event.isValidSignature() else { return }
// The signature was already verified (exactly once, off the main
// actor) by NostrRelayManager before delivery.
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh)
+135 -91
View File
@@ -75,20 +75,38 @@ extension ChatViewModel: NostrInboundPipelineContext {
}
}
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
/// payloads out. Pure transformation plus dedup no relay lifecycle.
/// The inbound Nostr hot path: verified relay events in, chat messages /
/// Noise payloads out. Pure transformation plus dedup no relay lifecycle.
///
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
/// dominate real relay traffic; events are recorded only AFTER verification so
/// a forged-signature copy can never poison the dedup set; gift-wrap
/// verification for the account mailbox runs off-main with an atomic
/// main-actor check-and-record.
/// Every event arriving here already had its Schnorr signature verified
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
/// pipeline (which records events into its own dedup cache only AFTER
/// verification, so forged copies can't suppress genuine events). This
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two
/// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record.
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
/// relay handlers so no NEW events flow, but a detached decrypt task
/// spawned just BEFORE the wipe which strongly captures a pre-wipe Nostr
/// private key and ciphertext survives it. Spawn sites capture this
/// value; the task compares it at its main-actor hops and drops its result
/// (no delivery; the captured identity and plaintext die with the task)
/// if `invalidateInFlightDecrypts()` bumped it in between.
@MainActor private(set) var wipeGeneration: UInt64 = 0
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
/// with pre-wipe keys can never land in post-wipe state.
@MainActor
func invalidateInFlightDecrypts() {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context
self.presence = presence
@@ -97,17 +115,15 @@ final class NostrInboundPipeline {
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
// Only verified events are recorded, so a forged-signature copy can
// never poison the dedup set and suppress the genuine event.
// Cheap rejects (kind, dedup lookup) duplicates dominate real
// traffic. The signature was already verified (exactly once, off the
// main actor) by NostrRelayManager before delivery.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id)
else {
return
}
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
@@ -176,15 +192,14 @@ final class NostrInboundPipeline {
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
// Cheap rejects (kind, dedup lookup) the signature was already
// verified (exactly once, off the main actor) by NostrRelayManager.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
@@ -264,104 +279,126 @@ final class NostrInboundPipeline {
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Dedup lookup before Schnorr verification; record only after it passes.
// Cheap dedup pre-check only; processGeohashGiftWrap does the
// authoritative main-actor check-and-record before the off-main
// NIP-17 unwrap. The outer signature was already verified (exactly
// once, off the main actor) by NostrRelayManager.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
context.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
// Capture the wipe generation at spawn, alongside the per-geohash
// identity (private key) the detached task strongly captures. A panic
// wipe between spawn and delivery bumps the generation, and the task
// drops its result instead of delivering plaintext post-wipe.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Dedup lookup before Schnorr verification; record only after it passes.
// Cheap dedup pre-check only; see subscribeGiftWrap.
if context.hasProcessedNostrEvent(giftWrap.id) {
return
}
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
// Spawn-time wipe-generation capture; see subscribeGiftWrap.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
}
}
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
/// layers) runs off the main actor; results hop back for state updates.
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
/// to the sampling path.
///
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
/// hop means a panic wipe happened in between, so the task bails without
/// decrypting (first hop) or without delivering the plaintext (second
/// hop) the captured identity and any decrypted material are simply
/// dropped with the task.
private func processGeohashGiftWrap(
_ giftWrap: NostrEvent,
id: NostrIdentity,
verbose: Bool,
wipeGeneration: UInt64
) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
guard self.wipeGeneration == wipeGeneration else { return true }
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
return
}
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
await MainActor.run {
// A panic wipe during the off-main decrypt must not let the
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
// with the wipe on the main actor.
guard self.wipeGeneration == wipeGeneration else { return }
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return }
// Cheap dedup pre-check only; Schnorr verification runs off-main in
// processNostrMessage, which then does the authoritative
// check-and-record. Recording stays after verification so a
// forged-signature copy can never poison the dedup set and suppress
// the genuine event.
// Cheap dedup pre-check only; processNostrMessage does the
// authoritative check-and-record before the off-main NIP-17 unwrap.
// The outer signature was already verified (exactly once, off the
// main actor) by NostrRelayManager, and only verified events are
// recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
@@ -370,7 +407,6 @@ final class NostrInboundPipeline {
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
@@ -380,8 +416,13 @@ final class NostrInboundPipeline {
return false
}
if alreadyProcessed { return }
let currentIdentity: NostrIdentity? = await MainActor.run {
context.currentNostrIdentity()
// Fetch the identity and the wipe generation in ONE main-actor hop:
// the generation then vouches for exactly this identity. A wipe after
// this point bumps the generation and the delivery hop below drops
// the decrypted result (same guard as processGeohashGiftWrap; this
// account-mailbox path had the identical hazard).
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
(context.currentNostrIdentity(), self.wipeGeneration)
}
guard let currentIdentity else { return }
@@ -413,6 +454,9 @@ final class NostrInboundPipeline {
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
@@ -382,10 +382,12 @@ struct ChatNostrCoordinatorContextTests {
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -398,30 +400,76 @@ struct ChatNostrCoordinatorContextTests {
// The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "pre-wipe secret",
messageID: "gm-wipe-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Spawn the detached decrypt (it strongly captures the pre-wipe
// identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe
// guard were broken.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered)
}
// NOTE: Inbound Schnorr signature verification (and the forged-copy
// dedup-poisoning invariant) is enforced once, off the main actor, at the
// relay boundary see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
// The inbound pipeline only ever sees verified events.
@Test @MainActor
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// A forged-signature copy is rejected WITHOUT entering the dedup set...
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
#expect(context.recordedNostrEventIDs.isEmpty)
// Fan-in of the same (already verified) gift wrap from several relays
// records and processes exactly once.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
+12 -27
View File
@@ -352,31 +352,11 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
// NOTE: Tampered-signature rejection is enforced once, off the main
// actor, at the relay boundary (events only reach the inbound pipeline
// after verification) see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
@@ -565,9 +545,14 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 50_000_000)
// Gift-wrap decryption runs off the main actor; wait for the ack
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
)
#expect(didAck)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
+5 -20
View File
@@ -326,26 +326,11 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
}
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
let (viewModel, _) = makeTestableViewModel()
let sampleGeohash = "u4pru"
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", sampleGeohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
var invalid = signed
invalid.sig = String(repeating: "0", count: 128)
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
}
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
// invariant) is enforced once, off the main actor, at the relay boundary
// the sampling path only ever sees verified events. See
// NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
// MARK: - Test Helper
@@ -1,403 +0,0 @@
//
// 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)
}
}
@@ -77,8 +77,10 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
/// public-message ingest scheduling. Schnorr signature verification is
/// NOT part of this path anymore it runs exactly once, off the main
/// actor, in `NostrRelayManager` before delivery.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
@@ -106,8 +108,9 @@ final class PerformanceBaselineTests: XCTestCase {
/// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
/// this path runs hundreds of times a minute in busy geohashes. It is a
/// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
+175 -131
View File
@@ -111,116 +111,6 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(connected)
}
func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async {
// Tor is bootstrapped, but the egress self-check has no fresh verdict:
// the ready path must still queue behind the async egress gate instead
// of opening sockets directly.
let context = makeContext(
permission: .authorized,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: false
)
context.manager.connect()
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
// Egress verification succeeds (awaitEgressReady returned true, which
// implies the verifier now holds a fresh cached verdict).
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let connectedAfterVerification = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterVerification)
}
func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async {
// Reconnect backoff timers call connectToRelay directly; when the
// cached egress verification has lapsed by then, the reconnect must go
// back through the gate rather than opening a socket.
let relayURL = "wss://egress-reconnect.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: true
)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
// The socket drops and the cached verification expires meanwhile.
context.torEgressVerified.value = false
context.sessionFactory.latestConnection(for: relayURL)?
.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
XCTAssertTrue(reconnectScheduled)
context.scheduler.runNext()
let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 }
XCTAssertTrue(queuedBehindGate)
// No new socket until the egress gate passes.
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let reconnected = await waitUntil {
context.sessionFactory.requestedURLs.count == 2 &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(reconnected)
}
func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async {
// A persistent unverified egress exhausts the wait attempts; a bounded
// low-frequency retry must then recover automatically once
// verification succeeds (e.g. transient canary outage ends).
let relayURL = "wss://egress-gate-retry.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: false
)
context.manager.ensureConnections(to: [relayURL])
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
// Fail-closed, with a single bounded-cadence retry scheduled.
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(context.scheduler.scheduled.count, 1)
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrTorGateRetrySeconds)
// The outage ends before the retry fires.
let attemptsBefore = context.torWaiter.awaitCallCount
context.scheduler.runNext()
let regated = await waitUntil { context.torWaiter.awaitCallCount == attemptsBefore + 1 }
XCTAssertTrue(regated)
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let recovered = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(recovered)
}
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
let relayURL = "wss://tor-eose-unblock.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -749,11 +639,15 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let countedOnBothRelays = await waitUntil {
// Wait on the DELIVERY-side state: handler dispatch and the duplicate
// drop both land on the second main hop, after off-main verification.
let settled = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == 1
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertTrue(settled)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
@@ -786,12 +680,17 @@ final class NostrRelayManagerTests: XCTestCase {
)
}
let countedOnEveryRelay = await waitUntil {
// Wait on the DELIVERY-side state: the winner's handler dispatch and
// the losers' duplicate drops both land on the second main hop, after
// off-main verification messagesReceived (first hop) settles sooner.
let settled = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
}
} &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
}
XCTAssertTrue(countedOnEveryRelay)
XCTAssertTrue(settled)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual(
@@ -824,16 +723,153 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let countedOnBothRelays = await waitUntil {
// Wait on the DELIVERY-side state (second main hop, after off-main
// verification), not just messagesReceived (first main hop) the
// handler only fires after verify + a second hop.
let genuineDelivered = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id]
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertTrue(genuineDelivered)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
}
/// The relay boundary is the single signature-verification point for the
/// whole inbound path (downstream pipelines no longer re-verify), so a
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped
/// here and must not poison the dedup cache against the genuine copy.
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
let firstRelayURL = "wss://giftwrap-one.example"
let secondRelayURL = "wss://giftwrap-two.example"
let context = makeContext(permission: .denied)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "psst",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let tampered = invalidSignatureCopy(of: giftWrap)
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "gift-wraps",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap)
// Wait on the DELIVERY-side state (second main hop, after off-main
// verification), not just messagesReceived (first main hop) the
// handler only fires after verify + a second hop.
let genuineDelivered = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [giftWrap.id]
}
XCTAssertTrue(genuineDelivered)
XCTAssertEqual(receivedIDs, [giftWrap.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
}
/// Signature verification runs off-main in a per-relay serial consumer;
/// several frames buffered on one socket must still be delivered to the
/// handler in that relay's arrival order.
func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws {
let relayURL = "wss://ordered.example"
let context = makeContext(permission: .denied)
let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") }
var receivedIDs: [String] = []
context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in
receivedIDs.append(event.id)
}
let subscriptionSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionSent)
for event in events {
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
}
let allDelivered = await waitUntil(timeout: 5.0) {
receivedIDs.count == events.count
}
XCTAssertTrue(allDelivered)
XCTAssertEqual(receivedIDs, events.map(\.id))
}
/// Each relay owns its own off-main verify pipeline, so a large backlog of
/// EVENT frames on one relay must NOT head-of-line-block a frame that
/// arrives on a different relay. Under the previous single global consumer,
/// relay B's single event (emitted after relay A's whole burst) could only
/// be delivered once every frame in A's backlog had been Schnorr-verified;
/// with per-relay pipelines B verifies concurrently and lands before A's
/// backlog drains.
func test_receiveEvent_busyRelayDoesNotBlockOtherRelayDelivery() async throws {
let busyRelayURL = "wss://busy-relay.example"
let quietRelayURL = "wss://quiet-relay.example"
let context = makeContext(permission: .denied)
// Distinct subscriptions per relay so dedup never coalesces A vs. B.
let busyEvents = try (0..<200).map { try makeSignedEvent(content: "busy-\($0)") }
let quietEvent = try makeSignedEvent(content: "quiet")
var busyDeliveredCount = 0
var quietDeliveredAfterBusyCount = -1 // busy-count observed when B lands
context.manager.subscribe(filter: makeFilter(), id: "busy", relayUrls: [busyRelayURL]) { _ in
busyDeliveredCount += 1
}
context.manager.subscribe(filter: makeFilter(), id: "quiet", relayUrls: [quietRelayURL]) { _ in
if quietDeliveredAfterBusyCount < 0 {
quietDeliveredAfterBusyCount = busyDeliveredCount
}
}
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: busyRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: quietRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
// Flood relay A first, then emit a single frame on relay B.
for event in busyEvents {
try context.sessionFactory.latestConnection(for: busyRelayURL)?.emitEventMessage(subscriptionID: "busy", event: event)
}
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
// The signal: B did not have to wait for A's entire backlog. If the two
// pipelines were globally serialized, B could only land after all 200 of
// A's frames, so busyDeliveredCount would be 200 when B arrived.
XCTAssertLessThan(
quietDeliveredAfterBusyCount,
busyEvents.count,
"relay B was head-of-line blocked behind relay A's backlog"
)
// Both relays still drain fully and in order.
let allDelivered = await waitUntil(timeout: 5.0) {
busyDeliveredCount == busyEvents.count
}
XCTAssertTrue(allDelivered)
}
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied)
@@ -1559,7 +1595,6 @@ final class NostrRelayManagerTests: XCTestCase {
userTorEnabled: Bool = false,
torEnforced: Bool = false,
torIsReady: Bool = true,
torEgressVerified: Bool = true,
torIsForeground: Bool = true,
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
) -> RelayManagerTestContext {
@@ -1569,7 +1604,6 @@ final class NostrRelayManagerTests: XCTestCase {
let scheduler = MockRelayScheduler()
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
let torWaiter = MockTorWaiter(isReady: torIsReady)
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
let torForeground = MutableBool(value: torIsForeground)
let activationFlag = MutableBool(value: activationAllowed)
let manager = NostrRelayManager(
@@ -1582,7 +1616,6 @@ final class NostrRelayManagerTests: XCTestCase {
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
torEnforced: { torEnforced },
torIsReady: { torWaiter.isReady },
torEgressVerified: { torEgressVerifiedFlag.value },
torIsForeground: { torForeground.value },
awaitTorReady: torWaiter.await(completion:),
makeSession: { sessionFactory },
@@ -1602,7 +1635,6 @@ final class NostrRelayManagerTests: XCTestCase {
clock: clock,
activationAllowed: activationFlag,
torWaiter: torWaiter,
torEgressVerified: torEgressVerifiedFlag,
torForeground: torForeground
)
}
@@ -1657,7 +1689,6 @@ private struct RelayManagerTestContext {
let clock: MutableClock
let activationAllowed: MutableBool
let torWaiter: MockTorWaiter
let torEgressVerified: MutableBool
let torForeground: MutableBool
}
@@ -1810,7 +1841,11 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
receiveHandler = completionHandler
if !pendingResults.isEmpty {
completionHandler(pendingResults.removeFirst())
} else {
receiveHandler = completionHandler
}
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
@@ -1843,15 +1878,24 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func emitRawString(_ string: String) throws {
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.string(string)))
deliver(.success(.string(string)))
}
private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject)
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.data(data)))
deliver(.success(.data(data)))
}
// Frames emitted before the manager re-arms `receive` are queued so
// back-to-back emissions model a socket with several buffered frames.
private var pendingResults: [Result<URLSessionWebSocketTask.Message, Error>] = []
private func deliver(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
if let handler = receiveHandler {
receiveHandler = nil
handler(result)
} else {
pendingResults.append(result)
}
}
}
-1
View File
@@ -30,7 +30,6 @@ let package = Package(
"TorManager.swift",
"TorURLSession.swift",
"TorNotifications.swift",
"TorEgressVerifier.swift",
],
linkerSettings: [
.linkedLibrary("resolv"),
@@ -1,365 +0,0 @@
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,14 +59,6 @@ public final class TorManager: ObservableObject {
private var socksReady: Bool = false { didSet { recomputeReady() } }
private var restarting: Bool = false
/// Runtime egress self-check: proves the proxied session actually exits via
/// Tor before relay connections are opened (defense-in-depth against a
/// platform silently ignoring the SOCKS proxy). Cached for a few minutes.
public let egressVerifier = TorEgressVerifier(
ttl: 300,
probe: TorEgressVerifier.liveProbe()
)
// Whether the app must enforce Tor for all connections (fail-closed).
public var torEnforced: Bool {
#if BITCHAT_DEV_ALLOW_CLEARNET
@@ -133,32 +125,6 @@ public final class TorManager: ObservableObject {
return await MainActor.run(body: { self.networkPermitted })
}
/// Synchronous, cached view of the egress gate: `true` while a positive
/// egress verification is within its TTL (or when Tor is not enforced).
/// When this is `false`, callers must route through `awaitEgressReady()`
/// (which probes) before opening any connection never connect directly.
public var isEgressVerified: Bool {
guard torEnforced else { return true }
return egressVerifier.hasFreshVerification
}
/// Like `awaitReady`, but additionally requires that a canary request
/// through the proxied session positively verifies Tor egress. Returns
/// `false` if Tor never became ready, or if the egress self-check could
/// not positively verify a Tor exit (non-Tor egress detected, or canary
/// unreachable unverified). Callers must fail closed on `false` never
/// fall back to a direct connection.
nonisolated
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
let ready = await awaitReady(timeout: timeout)
guard ready else { return false }
// Clearnet dev builds don't route through Tor, so the canary would
// (correctly) report non-Tor; skip it there.
let enforced = await MainActor.run { self.torEnforced }
guard enforced else { return true }
return await egressVerifier.verify()
}
// MARK: - Filesystem
func dataDirectoryURL() -> URL? {
@@ -359,8 +325,6 @@ public final class TorManager: ObservableObject {
self.socksReady = false
self.isStarting = false
}
// Force a fresh egress self-check once Tor comes back.
Task { await egressVerifier.invalidate() }
}
public func shutdownCompletely() {
@@ -389,7 +353,6 @@ public final class TorManager: ObservableObject {
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
// Clearing it here races with startup and defeats the grace period
}
await self.egressVerifier.invalidate()
}
}
@@ -405,8 +368,6 @@ public final class TorManager: ObservableObject {
self.isDormant = false
self.lastRestartAt = Date()
}
// New Arti instance means new circuits; re-verify egress after restart.
await egressVerifier.invalidate()
_ = arti_stop()
@@ -1,90 +0,0 @@
// 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()
@@ -1,73 +0,0 @@
#!/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' ' '
@@ -1,166 +0,0 @@
#!/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()