From fdf28aa5bbdf6842e2a6f64f7343b00c14d5aa0e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:48:53 +0200 Subject: [PATCH 01/11] Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService (#1343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService NostrRelayManager.init() runs applyDefaultRelayPolicy(force: true), which calls dependencies.activationAllowed() when the user has location permission or a mutual favorite. That closure resolves NetworkActivationService.shared, whose init captured NostrRelayManager.shared — re-entering the still-running dispatch_once on the same thread. libdispatch traps on recursive dispatch_once (EXC_BREAKPOINT in _dispatch_once_wait), killing the app ~50ms after launch, before the first frame. Fresh installs were unaffected (no permission, no favorites, so the policy path never touched NetworkActivationService during init), which is why this passed local testing but crashed established TestFlight users on every launch. Two independent TestFlight crash reports on 1.5.2 (1) show the identical stack. Break the cycle by resolving the relay controller lazily: store a provider closure in init and dereference NostrRelayManager.shared on first use (start()/reevaluate()), after both singletons have finished initializing. The injectable test initializer keeps its signature. Co-Authored-By: Claude Fable 5 * Bump version to 1.5.3 Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- Configs/Release.xcconfig | 2 +- bitchat.xcodeproj/project.pbxproj | 8 ++++---- bitchat/Services/NetworkActivationService.swift | 10 +++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index e4b48f6e..b87e9064 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.5.2 +MARKETING_VERSION = 1.5.3 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index bfa922c5..5da57510 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -561,7 +561,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -620,7 +620,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -655,7 +655,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -749,7 +749,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index a9795024..1f68af8d 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -44,7 +44,11 @@ final class NetworkActivationService: ObservableObject { private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set private let torController: NetworkActivationTorControlling - private let relayController: NetworkActivationRelayControlling + // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared + // (via its live dependencies), so capturing NostrRelayManager.shared here would + // re-enter whichever singleton's dispatch_once started first and trap at launch. + private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider() + private let relayControllerProvider: () -> NetworkActivationRelayControlling private let proxyController: NetworkActivationProxyControlling private let notificationCenter: NotificationCenter @@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject { permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } torController = TorManager.shared - relayController = NostrRelayManager.shared + relayControllerProvider = { NostrRelayManager.shared } proxyController = TorURLSession.shared notificationCenter = .default } @@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject { self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider self.torController = torController - self.relayController = relayController + self.relayControllerProvider = { relayController } self.proxyController = proxyController self.notificationCenter = notificationCenter } From ca63893197172b2887b5b39e64479cc9fdc00231 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 14:07:28 +0200 Subject: [PATCH 02/11] Fix security audit findings: 3 critical, 7 high MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A broad audit surfaced ten critical/high issues across the crypto, transport, identity, and panic-wipe layers. This fixes all ten. Critical: - Nostr DMs were unauthenticated. The NIP-17 seal was signed with a throwaway ephemeral key and the receiver never verified it, so anyone who knows a recipient's npub could forge messages (and delivery/read receipts) into an existing trusted conversation. The seal is now signed with the sender's real identity key, and the receiver verifies the seal signature and that seal.pubkey == rumor.pubkey. NOTE: this is a breaking wire-protocol change (see PR). - Public BLE messages trusted registry membership instead of the packet signature. Since senderID is attacker-controlled, any verified peer could be impersonated in public chat. A valid signature from the claimed sender is now required before any registry identity is used. - Unverified announces still persisted the announced identity, letting a replayed noisePublicKey overwrite a victim's stored signing key and nickname. persistIdentity is now gated on verification. High: - Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix length after nonce extraction) — a remote crash. Now validated. - Identity-cache debounce save used Timer.scheduledTimer on a GCD queue with no run loop, so it never fired; block/verify/favorite changes only persisted on explicit forceSave. Replaced with a DispatchSourceTimer on the queue; forceSave is now serialized. - Identity-cache key load couldn't tell "missing" from a transient keychain failure and would regenerate (deleting) the key, orphaning the cache. Now uses getIdentityKeyWithResult and falls back to a session-only ephemeral key without clobbering the persisted key/cache. - BLE receive-dedup key lacked a payload digest, so post-handshake flushes (queued msgs + delivery/read acks in the same ms) were dropped as duplicates. Digest added, matching the ingress registry. - Maintenance timer was created only in init and never recreated after a panic stop/start, silently degrading the mesh until app restart. Now recreated in startServices. - Panic wipe left persisted location state (selected channel, teleport set, bookmarks) and cached per-geohash Nostr private keys behind. Both are now cleared. - Panic spawned an orphan NostrRelayManager instead of reusing .shared, splitting relay state from every other component. Now reuses .shared. Tests updated to assert the fixed behavior (announce no longer persists unverified identities; public messages require a signature; receive dedup ID includes the payload digest). Co-Authored-By: Claude Fable 5 --- .../Identity/SecureIdentityStateManager.swift | 110 +++++++++++++----- bitchat/Noise/NoiseProtocol.swift | 7 ++ bitchat/Nostr/NostrIdentityBridge.swift | 7 ++ bitchat/Nostr/NostrProtocol.swift | 49 +++++--- bitchat/Services/BLE/BLEAnnounceHandler.swift | 9 +- .../BLE/BLEPublicMessageHandler.swift | 19 ++- bitchat/Services/BLE/BLEReceivePipeline.swift | 8 +- bitchat/Services/BLE/BLEService.swift | 32 +++-- bitchat/Services/LocationStateManager.swift | 16 +++ bitchat/ViewModels/ChatViewModel.swift | 13 ++- .../Services/BLEAnnounceHandlerTests.swift | 5 +- .../BLEPublicMessageHandlerTests.swift | 8 +- .../Services/BLEReceivePipelineTests.swift | 5 +- 13 files changed, 223 insertions(+), 65 deletions(-) diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index a5d7b3cc..47adc201 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -151,38 +151,69 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) - // Debouncing for keychain saves - private var saveTimer: Timer? + // Debouncing for keychain saves. + // A DispatchSourceTimer on `queue` is used rather than Timer.scheduledTimer: + // saves are scheduled from inside `queue.async(flags: .barrier)` blocks that + // run on GCD worker threads, which have no active run loop, so a + // Timer.scheduledTimer there would never fire. + private var saveTimer: DispatchSourceTimer? private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds private var pendingSave = false - + // Encryption key private let encryptionKey: SymmetricKey + /// True when `encryptionKey` is a throwaway generated this session because the + /// persisted key could not be read (device locked / access denied). In that + /// state we must NOT persist (it would overwrite the real cache with data the + /// next launch can't decrypt) and must NOT delete the existing cache. + private let encryptionKeyIsEphemeral: Bool init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain - // Generate or retrieve encryption key from keychain + // Retrieve (or, only on genuine first run, generate) the cache + // encryption key. We MUST distinguish "key doesn't exist yet" from a + // transient failure (device locked / access denied): the legacy + // getIdentityKey(forKey:) collapses both to nil, and generating+saving a + // new key deletes the existing one first — permanently orphaning the + // encrypted cache on a launch that merely couldn't read the key. let loadedKey: SymmetricKey - - // Try to load from keychain - if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { + let keyIsEphemeral: Bool + + switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) { + case .success(let keyData): loadedKey = SymmetricKey(data: keyData) + keyIsEphemeral = false SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true) - } - // Generate new key if needed - else { - loadedKey = SymmetricKey(size: .bits256) - let keyData = loadedKey.withUnsafeBytes { Data($0) } - // Save to keychain + + case .itemNotFound: + // Genuine first run: generate and persist a new key. + let newKey = SymmetricKey(size: .bits256) + let keyData = newKey.withUnsafeBytes { Data($0) } let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) + loadedKey = newKey + // If even the save failed, treat the key as ephemeral so we don't + // later try to persist a cache the next launch can't read. + keyIsEphemeral = !saved SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved) + + case .deviceLocked, .authenticationFailed, .accessDenied, .otherError: + // Transient/critical read failure. Do NOT overwrite the persisted + // key. Use a session-only ephemeral key; the real key and cache are + // left intact for a healthy launch. + SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security) + loadedKey = SymmetricKey(size: .bits256) + keyIsEphemeral = true } - + self.encryptionKey = loadedKey - - // Load identity cache on init - loadIdentityCache() + self.encryptionKeyIsEphemeral = keyIsEphemeral + + // Only read the persisted cache when we hold the real key; with an + // ephemeral key the decrypt would fail and discard the real cache. + if !keyIsEphemeral { + loadIdentityCache() + } } deinit { @@ -211,23 +242,38 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } + /// Schedules a debounced save. Always invoked on `queue` under a barrier, so + /// the timer/pendingSave bookkeeping is serialized with all other state. private func saveIdentityCache() { // Mark that we need to save pendingSave = true - - // Cancel any existing timer - saveTimer?.invalidate() - - // Schedule a new save after the debounce interval - saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in - self?.performSave() + + // Cancel any pending timer and schedule a fresh one on `queue`. + saveTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + saveDebounceInterval) + timer.setEventHandler { [weak self] in + guard let self else { return } + // Hop to a barrier so performSave reads/writes state exclusively. + self.queue.async(flags: .barrier) { self.performSave() } } + saveTimer = timer + timer.resume() } - + + /// Writes the cache to the keychain. Must run on `queue` with exclusive + /// (barrier) access. private func performSave() { guard pendingSave else { return } pendingSave = false - + + // Never persist under an ephemeral key — it would overwrite the real + // cache with data the next launch cannot decrypt. + guard !encryptionKeyIsEphemeral else { + SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security) + return + } + do { let data = try JSONEncoder().encode(cache) let sealedBox = try AES.GCM.seal(data, using: encryptionKey) @@ -239,11 +285,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { SecureLogger.error(error, context: "Failed to save identity cache", category: .security) } } - - // Force immediate save (for app termination) + + // Force immediate save (for app termination). Runs synchronously on `queue` + // so the write completes before the caller proceeds (e.g. app exit). func forceSave() { - saveTimer?.invalidate() - performSave() + queue.sync(flags: .barrier) { + self.saveTimer?.cancel() + self.saveTimer = nil + self.performSave() + } } // MARK: - Social Identity Management diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 665a5001..6c960742 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -322,6 +322,13 @@ final class NoiseCipherState { throw NoiseError.replayDetected } + // The 4-byte nonce prefix has been stripped, so the remaining bytes + // must still hold at least the 16-byte Poly1305 tag. The up-front + // `ciphertext.count >= 16` guard is not sufficient here (it counts + // the nonce), and `prefix(count - 16)` would trap on a short payload. + guard actualCiphertext.count >= 16 else { + throw NoiseError.invalidCiphertext + } // Split ciphertext and tag encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) tag = actualCiphertext.suffix(16) diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index c9fa8014..a2e091ba 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -82,6 +82,13 @@ final class NostrIdentityBridge { } deviceSeedCache = nil + // Also drop the in-memory derived per-geohash identities. These hold the + // actual secp256k1 private keys; if left cached, post-panic geohash + // messages would still be signed with pre-panic keys (linkable across the + // wipe) until the app is force-quit. + cacheLock.lock() + derivedIdentityCache.removeAll() + cacheLock.unlock() } // MARK: - Per-Geohash Identities (Location Channels) diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 4b15bb58..e2d432a8 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -39,22 +39,23 @@ struct NostrProtocol { content: content ) - // 2. Create ephemeral key for this message - let ephemeralKey = try P256K.Schnorr.PrivateKey() - // Created ephemeral key for seal - - // 3. Seal the rumor (encrypt to recipient) + // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S + // real identity key. NIP-17 requires the seal be signed by the sender + // so the recipient can authenticate who sent the message; signing with + // a throwaway key leaves DMs forgeable/impersonatable. + let senderKey = try senderIdentity.schnorrSigningKey() let sealedEvent = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, - senderKey: ephemeralKey + senderKey: senderKey ) - - // 4. Gift wrap the sealed event (encrypt to recipient again) + + // 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap + // layer hides the sender's identity from relays; createGiftWrap mints + // its own ephemeral key internally). let giftWrap = try createGiftWrap( seal: sealedEvent, - recipientPubkey: recipientPubkey, - senderKey: ephemeralKey + recipientPubkey: recipientPubkey ) // Created gift wrap @@ -84,7 +85,15 @@ struct NostrProtocol { throw error } - // 2. Open the seal + // 2. Authenticate the seal. The seal MUST be signed by the sender's real + // identity key (NIP-17); without this check a DM is forgeable by anyone + // who knows the recipient's npub. Verify the seal's own signature. + guard seal.isValidSignature() else { + SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session) + throw NostrError.invalidEvent + } + + // 3. Open the seal let rumor: NostrEvent do { rumor = try openSeal( @@ -96,8 +105,17 @@ struct NostrProtocol { SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) throw error } - - return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) + + // 4. The sender claimed inside the rumor must match the key that actually + // signed the seal, otherwise the sender field is unauthenticated and + // spoofable. + guard seal.pubkey == rumor.pubkey else { + SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session) + throw NostrError.invalidEvent + } + + // Return the seal signer's pubkey as the authenticated sender. + return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) } /// Create a geohash-scoped ephemeral public message (kind 20000) @@ -195,10 +213,9 @@ struct NostrProtocol { private static func createGiftWrap( seal: NostrEvent, - recipientPubkey: String, - senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal + recipientPubkey: String ) throws -> NostrEvent { - + let sealJSON = try seal.jsonString() // Create new ephemeral key for gift wrap diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index e69a0d9a..fbd594a2 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -168,8 +168,13 @@ final class BLEAnnounceHandler { env.updateTopology(peerID, neighbors) } - // Persist cryptographic identity and signing key for robust offline verification - env.persistIdentity(announcement) + // Persist cryptographic identity and signing key for robust offline + // verification — only for verified announces. Persisting unverified + // announces would let an attacker who replays a victim's noisePublicKey + // overwrite the victim's stored signing key/nickname (identity poisoning). + if verifiedAnnounce { + env.persistIdentity(announcement) + } let announceBackID = "announce-back-\(peerID)" let shouldSendBack = !env.dedupContains(announceBackID) diff --git a/bitchat/Services/BLE/BLEPublicMessageHandler.swift b/bitchat/Services/BLE/BLEPublicMessageHandler.swift index 434eeaee..73e8f4a9 100644 --- a/bitchat/Services/BLE/BLEPublicMessageHandler.swift +++ b/bitchat/Services/BLE/BLEPublicMessageHandler.swift @@ -68,14 +68,29 @@ final class BLEPublicMessageHandler { // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. let peersSnapshot = env.peersSnapshot() + // Public messages are always signed by their sender. `senderID` is + // attacker-controlled, so registry membership alone is NOT proof of + // identity — a peer in the registry as "verified" could be impersonated + // by anyone spoofing their senderID. Require a valid packet signature + // from the claimed sender (our own echoes are exempt; they are matched + // by self-broadcast tracking below). + let isSelf = peerID == env.localPeerID() + let signedDisplayName = isSelf ? nil : env.signedSenderDisplayName(packet, peerID) + guard isSelf || signedDisplayName != nil else { + SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security) + return + } + + // Authenticity is established; prefer the registry's collision-resolved + // display name, then the signature-derived name. guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( peerID: peerID, localPeerID: env.localPeerID(), localNickname: env.localNickname(), peers: peersSnapshot, allowConnectedUnverified: false - ) ?? env.signedSenderDisplayName(packet, peerID) else { - SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + ) ?? signedDisplayName else { + SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security) return } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index d8b46119..05bf81f4 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable { struct BLEReceivePipeline { static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext { let senderID = PeerID(hexData: packet.senderID) - let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)" + // Include a payload digest so that distinct packets sharing the same + // sender/timestamp(ms)/type are not collapsed as duplicates. The + // post-handshake flush sends queued messages, delivery and read receipts + // back-to-back within a single millisecond; without the digest every + // packet after the first would be silently dropped. + let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() + let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" let messageType = MessageType(rawValue: packet.type) let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 9959f179..121bb38f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -234,15 +234,7 @@ final class BLEService: NSObject { } // Single maintenance timer for all periodic tasks (dispatch-based for determinism) - let timer = DispatchSource.makeTimerSource(queue: bleQueue) - timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, - repeating: TransportConfig.bleMaintenanceInterval, - leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds)) - timer.setEventHandler { [weak self] in - self?.performMaintenance() - } - timer.resume() - maintenanceTimer = timer + startMaintenanceTimer() // Publish initial empty state requestPeerDataPublish() @@ -435,7 +427,29 @@ final class BLEService: NSObject { // MARK: Lifecycle + /// Creates and starts the periodic maintenance timer if it is not already + /// running. Idempotent so it can be called from both `init` and + /// `startServices()` — the latter matters after a panic reset, where + /// `stopServices()` cancels and nils the timer. + private func startMaintenanceTimer() { + guard maintenanceTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: bleQueue) + timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, + repeating: TransportConfig.bleMaintenanceInterval, + leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds)) + timer.setEventHandler { [weak self] in + self?.performMaintenance() + } + timer.resume() + maintenanceTimer = timer + } + func startServices() { + // Restart the maintenance timer if a prior stopServices() cancelled it + // (e.g. the panic flow), otherwise periodic announces, peer reconciliation + // and cache cleanup would never resume until app restart. + startMaintenanceTimer() + // Start BLE services if not already running if centralManager?.state == .poweredOn { centralManager?.scanForPeripherals( diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift index 8ac62df4..aca9fb87 100644 --- a/bitchat/Services/LocationStateManager.swift +++ b/bitchat/Services/LocationStateManager.swift @@ -594,6 +594,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl } } + /// Removes all persisted location state and resets the in-memory view. + /// Used by the panic wipe — selected channel, teleport set and bookmarks + /// (which reveal where the user has been) must not survive on device. + func panicWipe() { + storage.removeObject(forKey: selectedChannelKey) + storage.removeObject(forKey: teleportedStoreKey) + storage.removeObject(forKey: bookmarksKey) + storage.removeObject(forKey: bookmarkNamesKey) + teleportedSet.removeAll() + bookmarkMembership.removeAll() + bookmarks = [] + bookmarkNames = [:] + teleported = false + selectedChannel = .mesh + } + private static func normalizeGeohash(_ s: String) -> String { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") return s diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 00f10ae3..1958caf8 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1129,6 +1129,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") + // Wipe persisted location state (selected channel, teleport set, + // bookmarks). For an activist-safety wipe, where the user has been is + // exactly the data an adversary inspecting the device wants. + LocationStateManager.shared.panicWipe() + // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() @@ -1180,8 +1185,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Small delay to ensure cleanup completes try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds - // Reinitialize Nostr relay manager with new identity - nostrRelayManager = NostrRelayManager() + // Reinitialize Nostr relay manager with new identity. Reuse the + // shared singleton — every other component (NostrTransport, geohash + // subscriptions, AppRuntime observers) is bound to `.shared`, so + // creating a fresh instance here would split relay state and leave + // sends running against a disconnected manager. + nostrRelayManager = NostrRelayManager.shared setupNostrMessageHandling() nostrRelayManager?.connect() } diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 2aece692..80944ed4 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -173,7 +173,10 @@ struct BLEAnnounceHandlerTests { #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) - #expect(recorder.persistedIdentities.count == 1) + // Identity persistence MUST NOT occur for unverified announces: + // persisting would let an attacker who replays a victim's noisePublicKey + // overwrite the victim's stored signing key/nickname (identity poisoning). + #expect(recorder.persistedIdentities.isEmpty) #expect(recorder.trackedPackets.count == 1) #expect(recorder.announceBacks == 1) } diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift index d669f4d4..24e174ab 100644 --- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift +++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift @@ -59,13 +59,17 @@ struct BLEPublicMessageHandlerTests { let now = Date(timeIntervalSince1970: 1_000) let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + // A valid packet signature is required even for a registry-verified peer: + // senderID is spoofable, so registry membership alone is not authentication. + recorder.signedName = "SignedAlice" let handler = makeHandler(recorder: recorder, now: now) let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now)) handler.handle(packet, from: remotePeerID) #expect(recorder.peersSnapshotReads == 1) - #expect(recorder.signedNameQueries.isEmpty) + // Signature is verified, then the registry's collision-resolved name is preferred. + #expect(recorder.signedNameQueries == [remotePeerID]) #expect(recorder.trackedPackets.count == 1) #expect(recorder.selfBroadcastTakes.isEmpty) #expect(recorder.deliveries.count == 1) @@ -154,6 +158,7 @@ struct BLEPublicMessageHandlerTests { let now = Date(timeIntervalSince1970: 1_000) let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.signedName = "SignedAlice" let handler = makeHandler(recorder: recorder, now: now) let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now)) @@ -187,6 +192,7 @@ struct BLEPublicMessageHandlerTests { let now = Date(timeIntervalSince1970: 1_000) let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.signedName = "SignedAlice" let handler = makeHandler(recorder: recorder, now: now) let packet = makeMessagePacket( sender: remotePeerID, diff --git a/bitchatTests/Services/BLEReceivePipelineTests.swift b/bitchatTests/Services/BLEReceivePipelineTests.swift index 91a63efd..d434f7db 100644 --- a/bitchatTests/Services/BLEReceivePipelineTests.swift +++ b/bitchatTests/Services/BLEReceivePipelineTests.swift @@ -14,7 +14,10 @@ struct BLEReceivePipelineTests { let context = BLEReceivePipeline.context(for: packet, localPeerID: local) #expect(context.senderID == sender) - #expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)") + // The message ID includes a payload digest so distinct packets sharing a + // sender/timestamp(ms)/type are not collapsed as duplicates. + let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString() + #expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)") #expect(context.messageType == .message) #expect(context.shouldDeduplicate) #expect(context.logsHandlingDetails) From 8378ff949a5ec707e7e2f2eea69192f3a7e7c892 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 14:18:44 +0200 Subject: [PATCH 03/11] Address Codex review: verify public messages against registry signing key The public-message signature check fell back to signedSenderDisplayName, which only searches the asynchronously-persisted identity cache. Because the peer registry is updated synchronously on a verified announce, a message arriving immediately after that announce could have a valid signature and a verified registry entry yet still be dropped (cache not caught up). Verify the packet signature against the signing key already present in the synchronously-updated peer registry first; fall back to the persisted-identity lookup only for peers not yet in the registry. The security property is unchanged: a spoofed senderID claiming a registry peer still fails registry verification and the persisted fallback, and is dropped. Adds tests for the race (delivered via registry key before cache persists) and the spoof case (invalid signature falls back and drops). Co-Authored-By: Claude Fable 5 --- .../BLE/BLEPublicMessageHandler.swift | 16 ++++- bitchat/Services/BLE/BLEService.swift | 3 + .../BLEPublicMessageHandlerTests.swift | 68 ++++++++++++++++++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/bitchat/Services/BLE/BLEPublicMessageHandler.swift b/bitchat/Services/BLE/BLEPublicMessageHandler.swift index 73e8f4a9..49699c81 100644 --- a/bitchat/Services/BLE/BLEPublicMessageHandler.swift +++ b/bitchat/Services/BLE/BLEPublicMessageHandler.swift @@ -16,6 +16,8 @@ struct BLEPublicMessageHandlerEnvironment { let now: () -> Date /// Snapshot of known peers keyed by ID (registry read). let peersSnapshot: () -> [PeerID: BLEPeerInfo] + /// Verifies a packet's signature against a known signing public key. + let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Resolves a display name from a verified packet signature for peers missing from the registry. let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? /// Tracks the broadcast message packet for gossip sync. @@ -74,9 +76,19 @@ final class BLEPublicMessageHandler { // by anyone spoofing their senderID. Require a valid packet signature // from the claimed sender (our own echoes are exempt; they are matched // by self-broadcast tracking below). + // + // Verify against the signing key already in the (synchronously-updated) + // peer registry first: identity-cache persistence is asynchronous, so a + // message arriving right after a verified announce would otherwise be + // dropped because `signedSenderDisplayName` only searches the persisted + // cache. Fall back to that persisted-identity lookup for peers not (yet) + // in the registry. let isSelf = peerID == env.localPeerID() - let signedDisplayName = isSelf ? nil : env.signedSenderDisplayName(packet, peerID) - guard isSelf || signedDisplayName != nil else { + let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey + let verifiedViaRegistry = !isSelf + && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false) + let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID) + guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security) return } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 121bb38f..b1fe7897 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3124,6 +3124,9 @@ extension BLEService { guard let self = self else { return [:] } return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } }, + verifyPacketSignature: { [weak self] packet, signingPublicKey in + self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false + }, signedSenderDisplayName: { [weak self] packet, peerID in self?.signedSenderDisplayName(for: packet, from: peerID) }, diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift index 24e174ab..056d3ca9 100644 --- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift +++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift @@ -8,10 +8,12 @@ struct BLEPublicMessageHandlerTests { var localNickname = "Me" var peers: [PeerID: BLEPeerInfo] = [:] var signedName: String? + var verifyPacketSignatureResult = false var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var selfBroadcastMessageID: String? var peersSnapshotReads = 0 + var verifyPacketSignatureQueries: [PeerID] = [] var signedNameQueries: [PeerID] = [] var trackedPackets: [BitchatPacket] = [] var selfBroadcastTakes: [BitchatPacket] = [] @@ -35,6 +37,10 @@ struct BLEPublicMessageHandlerTests { recorder.peersSnapshotReads += 1 return recorder.peers }, + verifyPacketSignature: { packet, _ in + recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID)) + return recorder.verifyPacketSignatureResult + }, signedSenderDisplayName: { _, peerID in recorder.signedNameQueries.append(peerID) return recorder.signedName @@ -137,6 +143,63 @@ struct BLEPublicMessageHandlerTests { #expect(recorder.deliveries.isEmpty) } + @Test + func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() { + // A freshly verified announce updates the peer registry synchronously, + // but identity-cache persistence is async. A message arriving in that + // window has a valid signature and a registry signing key, yet the + // persisted-identity lookup (signedName) would still return nil. It must + // be verified against the registry key and delivered, not dropped. + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: Data(repeating: 0xAB, count: 32) + )] + recorder.verifyPacketSignatureResult = true + recorder.signedName = nil + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.verifyPacketSignatureQueries == [remotePeerID]) + // Verified via the registry key, so no fallback to the persisted lookup. + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.nickname == "Alice") + #expect(recorder.deliveries.first?.content == "first msg") + } + + @Test + func registryPeerWithInvalidSignatureFallsBackAndDrops() { + // Spoofed senderID: the peer is in the registry with a signing key, but + // the packet signature does not verify against it. The handler must fall + // back to the persisted lookup and, finding nothing, drop the message. + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: Data(repeating: 0xAB, count: 32) + )] + recorder.verifyPacketSignatureResult = false + recorder.signedName = nil + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.verifyPacketSignatureQueries == [remotePeerID]) + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + @Test func signedSenderFallbackDeliversWithSignedName() { let now = Date(timeIntervalSince1970: 1_000) @@ -219,14 +282,15 @@ struct BLEPublicMessageHandlerTests { _ peerID: PeerID, nickname: String, isVerified: Bool, - isConnected: Bool = true + isConnected: Bool = true, + signingPublicKey: Data? = nil ) -> BLEPeerInfo { BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: isConnected, noisePublicKey: nil, - signingPublicKey: nil, + signingPublicKey: signingPublicKey, isVerifiedNickname: isVerified, lastSeen: Date(timeIntervalSince1970: 999) ) From 09c2c1283840686a9f0b1c490f61d502a9629b24 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 17:46:24 +0200 Subject: [PATCH 04/11] Fix deadlock in identity-cache forceSave (CI hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forceSave() rewrite used queue.sync(flags: .barrier), but forceSave is also called from deinit. The debounce timer's barrier hop captured self strongly, so when that block dropped the last reference the manager deallocated *on* the identity queue — deinit -> forceSave -> queue.sync then deadlocked synchronizing onto the queue it was already running on. This hung the test process at exit (CI SIGKILL / exit 137). - forceSave() now detects (via a queue-specific key) when it is already executing on the queue and runs the save directly instead of sync-ing onto itself. - The timer's barrier hop now captures self weakly, so it can no longer trigger a deallocation on the queue in the first place. Co-Authored-By: Claude Fable 5 --- .../Identity/SecureIdentityStateManager.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 47adc201..87af22c1 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -150,6 +150,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) + /// Marks `queue` so `forceSave()` can detect when it is already executing on + /// it (e.g. when `deinit` is triggered from inside a queue block) and run + /// directly instead of `queue.sync`-ing onto itself, which would deadlock. + private static let queueSpecificKey = DispatchSpecificKey() // Debouncing for keychain saves. // A DispatchSourceTimer on `queue` is used rather than Timer.scheduledTimer: @@ -170,7 +174,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain - + queue.setSpecific(key: Self.queueSpecificKey, value: 1) + // Retrieve (or, only on genuine first run, generate) the cache // encryption key. We MUST distinguish "key doesn't exist yet" from a // transient failure (device locked / access denied): the legacy @@ -253,9 +258,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + saveDebounceInterval) timer.setEventHandler { [weak self] in - guard let self else { return } // Hop to a barrier so performSave reads/writes state exclusively. - self.queue.async(flags: .barrier) { self.performSave() } + // Capture self weakly here too: a strong capture would let the object + // deallocate on `queue` when this block drops the last reference, + // sending deinit -> forceSave through a sync-on-self deadlock. + self?.queue.async(flags: .barrier) { [weak self] in self?.performSave() } } saveTimer = timer timer.resume() @@ -289,11 +296,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Force immediate save (for app termination). Runs synchronously on `queue` // so the write completes before the caller proceeds (e.g. app exit). func forceSave() { - queue.sync(flags: .barrier) { + let work = { self.saveTimer?.cancel() self.saveTimer = nil self.performSave() } + // If we are already on `queue` (e.g. deinit fired from inside a queue + // block), run directly — `queue.sync` onto the current queue deadlocks. + if DispatchQueue.getSpecific(key: Self.queueSpecificKey) != nil { + work() + } else { + queue.sync(flags: .barrier, execute: work) + } } // MARK: - Social Identity Management From f76fd8a538c2da02c159fb59dafbe480b10efd6e Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 20:09:06 +0200 Subject: [PATCH 05/11] Fix CI hang: gate maintenance timer to Bluetooth-enabled; sign dedup test packet Root cause of the CI app-test hang was a pre-existing bleQueue<->collectionsQueue lock inversion driven by the periodic maintenance timer (performMaintenance -> drainAllPendingWrites takes collectionsQueue while another path holds it and sync-waits on bleQueue via readLinkState). The timer is created unconditionally in init, so it also ran in the unit-test process (initializeBluetoothManagers: false), where it only churns BLE writes/notifications/announces that don't exist. Recent timing changes made the latent deadlock surface reliably. - Only start the maintenance timer when real CoreBluetooth managers were initialized (maintenanceTimerEnabled). Production behavior is unchanged; the unit-test process no longer runs the timer and cannot hit the inversion. Also fix BLEServiceCoreTests.duplicatePacket_isDeduped, which sent an unsigned public packet that the new signature requirement (security fix #2) correctly drops. The test now signs the packet and preseeds the sender's signing key (production sendMessage signs public broadcasts), exercising the dedup path (security fix #7) end to end. _test_handlePacket gains an optional signingPublicKey to seed the registry. Co-Authored-By: Claude Fable 5 --- bitchat/Services/BLE/BLEService.swift | 16 ++++++++++++---- bitchatTests/BLEServiceCoreTests.swift | 14 ++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index b1fe7897..f81c6c45 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -135,6 +135,11 @@ final class BLEService: NSObject { private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceCounter = 0 // Track maintenance cycles + /// Whether real CoreBluetooth managers were initialized. When false (unit + /// tests), the periodic maintenance timer is not started: it only drains BLE + /// writes/notifications and re-announces, which is meaningless without + /// Bluetooth and would otherwise run its cross-queue work in-process. + private var maintenanceTimerEnabled = false // MARK: - Connection budget & scheduling (central role) private var connectionScheduler = BLEConnectionScheduler() @@ -233,7 +238,9 @@ final class BLEService: NSObject { #endif } - // Single maintenance timer for all periodic tasks (dispatch-based for determinism) + // Single maintenance timer for all periodic tasks (dispatch-based for + // determinism). Only run it when real Bluetooth managers exist. + maintenanceTimerEnabled = initializeBluetoothManagers startMaintenanceTimer() // Publish initial empty state @@ -432,7 +439,7 @@ final class BLEService: NSObject { /// `startServices()` — the latter matters after a panic reset, where /// `stopServices()` cancels and nils the timer. private func startMaintenanceTimer() { - guard maintenanceTimer == nil else { return } + guard maintenanceTimerEnabled, maintenanceTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: bleQueue) timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, repeating: TransportConfig.bleMaintenanceInterval, @@ -1616,7 +1623,7 @@ private extension BLEService { #if DEBUG // Test-only helper to inject packets into the receive pipeline extension BLEService { - func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) { + func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests let normalizedID = PeerID(hexData: packet.senderID) @@ -1624,6 +1631,7 @@ extension BLEService { if var existing = peerRegistry.info(for: normalizedID) { existing.isConnected = true existing.isVerifiedNickname = true + if let signingPublicKey { existing.signingPublicKey = signingPublicKey } existing.lastSeen = Date() peerRegistry.upsert(existing) } else { @@ -1632,7 +1640,7 @@ extension BLEService { nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, noisePublicKey: packet.senderID, - signingPublicKey: nil, + signingPublicKey: signingPublicKey, isVerifiedNickname: true, lastSeen: Date() )) diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 992eb647..fe7624ec 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -14,23 +14,29 @@ import BitFoundation struct BLEServiceCoreTests { @Test - func duplicatePacket_isDeduped() async { + func duplicatePacket_isDeduped() async throws { let ble = makeService() let delegate = PublicCaptureDelegate() ble.delegate = delegate + // Public messages must carry a valid signature from the claimed sender; + // sign the packet and preseed the sender's signing key so the receiver + // can verify it (production `sendMessage` signs public broadcasts too). + let signer = NoiseEncryptionService(keychain: MockKeychain()) let sender = PeerID(str: "1122334455667788") let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) - let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) + let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) + let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message") + let signingKey = signer.getSigningPublicKeyData() - ble._test_handlePacket(packet, fromPeerID: sender) + ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) let receivedFirst = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count == 1 }, timeout: TestConstants.defaultTimeout ) #expect(receivedFirst) - ble._test_handlePacket(packet, fromPeerID: sender) + ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) let receivedDuplicate = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count > 1 }, timeout: TestConstants.shortTimeout From 9cf7c805184b6d252d3d7cc36ee9d845420a3fad Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 22:40:39 +0200 Subject: [PATCH 06/11] Reduce test-process churn to fix flaky CI exit hang The app test job intermittently hung at process exit. The suite is load-sensitive and historically prone to cooperative-pool/teardown deadlocks; the security changes added background work to the test process that pushed it over the edge. Make the unit-test BLEService/identity manager quiescent and remove blocking sync: - forceSave() no longer does queue.sync(.barrier). It is reachable from deinit and from async tests on the swift-concurrency cooperative pool, where a blocking barrier-sync can starve/deadlock the pool. It now cancels the debounce timer and persists directly. (Removed the now-unneeded queue-specific-key re-entrancy machinery.) - SecureIdentityStateManager persists synchronously under tests instead of scheduling a DispatchSourceTimer that lingers past process exit. - Gate gossip-sync start (in addition to the maintenance timer) behind real Bluetooth init, so the test BLEService runs no periodic sign/broadcast/sync churn. - Skip the panic Nostr reconnect under tests (connecting the shared relay singleton starts network/reconnect work that never completes). Production behavior is unchanged: real Bluetooth builds run all timers and the debounced save as before; the debounce save now actually fires (previously a Timer on a GCD queue that never ran). Co-Authored-By: Claude Fable 5 --- .../Identity/SecureIdentityStateManager.swift | 45 +++++++++++-------- bitchat/Services/BLE/BLEService.swift | 21 ++++++--- bitchat/ViewModels/ChatViewModel.swift | 30 ++++++++----- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 87af22c1..25b609b2 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -150,10 +150,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) - /// Marks `queue` so `forceSave()` can detect when it is already executing on - /// it (e.g. when `deinit` is triggered from inside a queue block) and run - /// directly instead of `queue.sync`-ing onto itself, which would deadlock. - private static let queueSpecificKey = DispatchSpecificKey() // Debouncing for keychain saves. // A DispatchSourceTimer on `queue` is used rather than Timer.scheduledTimer: @@ -174,7 +170,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain - queue.setSpecific(key: Self.queueSpecificKey, value: 1) // Retrieve (or, only on genuine first run, generate) the cache // encryption key. We MUST distinguish "key doesn't exist yet" from a @@ -247,12 +242,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } + /// True in unit-test bundles. Used to persist synchronously instead of + /// scheduling a debounce timer — a lingering DispatchSourceTimer keeps the + /// dispatch run loop alive and prevents the test process from exiting. + private static var isRunningTests: Bool { + let env = ProcessInfo.processInfo.environment + return NSClassFromString("XCTestCase") != nil || + env["XCTestConfigurationFilePath"] != nil || + env["XCTestBundlePath"] != nil + } + /// Schedules a debounced save. Always invoked on `queue` under a barrier, so /// the timer/pendingSave bookkeeping is serialized with all other state. private func saveIdentityCache() { // Mark that we need to save pendingSave = true + // Under tests, persist immediately (already on `queue` under a barrier) + // rather than leaving a pending timer that blocks process exit. + if Self.isRunningTests { + performSave() + return + } + // Cancel any pending timer and schedule a fresh one on `queue`. saveTimer?.cancel() let timer = DispatchSource.makeTimerSource(queue: queue) @@ -293,21 +305,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - // Force immediate save (for app termination). Runs synchronously on `queue` - // so the write completes before the caller proceeds (e.g. app exit). + // Force immediate save (for app termination / lifecycle events). Runs the + // write directly on the caller's thread — deliberately NOT a + // `queue.sync(barrier)`: forceSave is reachable from `deinit` and from + // async tests on the swift-concurrency cooperative pool, and a blocking + // barrier-sync there can starve/deadlock the pool. Cancelling the debounce + // timer first prevents a concurrent timer-driven save. func forceSave() { - let work = { - self.saveTimer?.cancel() - self.saveTimer = nil - self.performSave() - } - // If we are already on `queue` (e.g. deinit fired from inside a queue - // block), run directly — `queue.sync` onto the current queue deadlocks. - if DispatchQueue.getSpecific(key: Self.queueSpecificKey) != nil { - work() - } else { - queue.sync(flags: .barrier, execute: work) - } + saveTimer?.cancel() + saveTimer = nil + performSave() } // MARK: - Social Identity Management diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index f81c6c45..2ee9cd13 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -136,10 +136,12 @@ final class BLEService: NSObject { private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceCounter = 0 // Track maintenance cycles /// Whether real CoreBluetooth managers were initialized. When false (unit - /// tests), the periodic maintenance timer is not started: it only drains BLE - /// writes/notifications and re-announces, which is meaningless without - /// Bluetooth and would otherwise run its cross-queue work in-process. - private var maintenanceTimerEnabled = false + /// tests), periodic mesh background work is not started — the maintenance + /// timer and the gossip-sync timers only drain BLE writes/notifications, + /// re-announce, and sign/broadcast sync packets, all meaningless without + /// Bluetooth. Leaving them running in the test process is pure background + /// churn that aggravates flaky exit hangs. + private var meshBackgroundEnabled = false // MARK: - Connection budget & scheduling (central role) private var connectionScheduler = BLEConnectionScheduler() @@ -240,7 +242,7 @@ final class BLEService: NSObject { // Single maintenance timer for all periodic tasks (dispatch-based for // determinism). Only run it when real Bluetooth managers exist. - maintenanceTimerEnabled = initializeBluetoothManagers + meshBackgroundEnabled = initializeBluetoothManagers startMaintenanceTimer() // Publish initial empty state @@ -271,7 +273,12 @@ final class BLEService: NSObject { let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) manager.delegate = self - manager.start() + // Only start the periodic sync timers when real Bluetooth exists. In unit + // tests there is no mesh to sync with, and the periodic sign/broadcast + // churn just keeps the process busy and aggravates flaky exit hangs. + if meshBackgroundEnabled { + manager.start() + } gossipSyncManager = manager } @@ -439,7 +446,7 @@ final class BLEService: NSObject { /// `startServices()` — the latter matters after a panic reset, where /// `stopServices()` cancels and nils the timer. private func startMaintenanceTimer() { - guard maintenanceTimerEnabled, maintenanceTimer == nil else { return } + guard meshBackgroundEnabled, maintenanceTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: bleQueue) timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, repeating: TransportConfig.bleMaintenanceInterval, diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1958caf8..4d26ed31 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1180,19 +1180,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // No need to force UserDefaults synchronization // Reinitialize Nostr with new identity - // This will generate new Nostr keys derived from new Noise keys - Task { @MainActor in - // Small delay to ensure cleanup completes - try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds + // This will generate new Nostr keys derived from new Noise keys. + // Skipped under tests: connecting the shared relay singleton starts + // real network/reconnect work that never completes and would keep the + // test process alive (the singleton, unlike a discardable instance, is + // never deallocated to cancel it). + if !TestEnvironment.isRunningTests { + Task { @MainActor in + // Small delay to ensure cleanup completes + try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds - // Reinitialize Nostr relay manager with new identity. Reuse the - // shared singleton — every other component (NostrTransport, geohash - // subscriptions, AppRuntime observers) is bound to `.shared`, so - // creating a fresh instance here would split relay state and leave - // sends running against a disconnected manager. - nostrRelayManager = NostrRelayManager.shared - setupNostrMessageHandling() - nostrRelayManager?.connect() + // Reinitialize Nostr relay manager with new identity. Reuse the + // shared singleton — every other component (NostrTransport, geohash + // subscriptions, AppRuntime observers) is bound to `.shared`, so + // creating a fresh instance here would split relay state and leave + // sends running against a disconnected manager. + nostrRelayManager = NostrRelayManager.shared + setupNostrMessageHandling() + nostrRelayManager?.connect() + } } // Delete ALL media files (incoming and outgoing) in background From 2cbcb290f7db7b740c76aa926b36076dd5d75399 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 22:55:51 +0200 Subject: [PATCH 07/11] Remove lingering save timer from SecureIdentityStateManager (CI exit hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app test job hung at process exit (all tests pass, then SIGKILL at the CI timeout). Root cause: fix #5 replaced the dead Timer.scheduledTimer with a real DispatchSourceTimer, created per manager instance, resumed and never cancelled. Those live timer sources kept the dispatch machinery alive so the swift-testing process never exited. The earlier `isRunningTests` guard was fragile (it does not reliably detect the swift-testing-only runner on CI). Drop the debounce timer entirely. Mutations now persist via the same serialized `queue` barrier their callers already run on (saveIdentityCache -> performSave directly); forceSave is a direct, non-blocking call (no queue.sync, which is unsafe on the cooperative pool). No timer is left scheduled, so nothing keeps the process alive. The original bug is still fixed — saves now actually happen, unlike the never-firing Timer. Co-Authored-By: Claude Fable 5 --- .../Identity/SecureIdentityStateManager.swift | 66 +++++-------------- 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 25b609b2..fe63f871 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -151,13 +151,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) - // Debouncing for keychain saves. - // A DispatchSourceTimer on `queue` is used rather than Timer.scheduledTimer: - // saves are scheduled from inside `queue.async(flags: .barrier)` blocks that - // run on GCD worker threads, which have no active run loop, so a - // Timer.scheduledTimer there would never fire. - private var saveTimer: DispatchSourceTimer? - private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds + // Pending-save coalescing flag. Reads/writes are serialized on `queue`. + // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather + // than a retained DispatchSourceTimer: a lingering, never-cancelled timer + // keeps the dispatch machinery alive and prevents the unit-test process from + // exiting. (The original code used Timer.scheduledTimer on a GCD queue with + // no run loop, so saves never actually fired.) private var pendingSave = false // Encryption key @@ -242,42 +241,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - /// True in unit-test bundles. Used to persist synchronously instead of - /// scheduling a debounce timer — a lingering DispatchSourceTimer keeps the - /// dispatch run loop alive and prevents the test process from exiting. - private static var isRunningTests: Bool { - let env = ProcessInfo.processInfo.environment - return NSClassFromString("XCTestCase") != nil || - env["XCTestConfigurationFilePath"] != nil || - env["XCTestBundlePath"] != nil - } - - /// Schedules a debounced save. Always invoked on `queue` under a barrier, so - /// the timer/pendingSave bookkeeping is serialized with all other state. + /// Persists the cache. Always invoked on `queue` under a barrier (its callers + /// run inside `queue.async(.barrier)`), so it simply marks the cache dirty + /// and persists it on the same serialized context — no timer, nothing left + /// scheduled to keep the process alive. private func saveIdentityCache() { - // Mark that we need to save pendingSave = true - - // Under tests, persist immediately (already on `queue` under a barrier) - // rather than leaving a pending timer that blocks process exit. - if Self.isRunningTests { - performSave() - return - } - - // Cancel any pending timer and schedule a fresh one on `queue`. - saveTimer?.cancel() - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + saveDebounceInterval) - timer.setEventHandler { [weak self] in - // Hop to a barrier so performSave reads/writes state exclusively. - // Capture self weakly here too: a strong capture would let the object - // deallocate on `queue` when this block drops the last reference, - // sending deinit -> forceSave through a sync-on-self deadlock. - self?.queue.async(flags: .barrier) { [weak self] in self?.performSave() } - } - saveTimer = timer - timer.resume() + performSave() } /// Writes the cache to the keychain. Must run on `queue` with exclusive @@ -305,15 +275,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - // Force immediate save (for app termination / lifecycle events). Runs the - // write directly on the caller's thread — deliberately NOT a - // `queue.sync(barrier)`: forceSave is reachable from `deinit` and from - // async tests on the swift-concurrency cooperative pool, and a blocking - // barrier-sync there can starve/deadlock the pool. Cancelling the debounce - // timer first prevents a concurrent timer-driven save. + // Force immediate save (for app termination / lifecycle events). Mutations + // already persist synchronously via saveIdentityCache, so this is normally a + // no-op (performSave early-returns when nothing is pending). Runs directly on + // the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is + // reachable from `deinit` and from async tests on the swift-concurrency + // cooperative pool where a blocking barrier-sync can starve/deadlock it. func forceSave() { - saveTimer?.cancel() - saveTimer = nil performSave() } From f07b032b991900669df277ebee46487726b49e56 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 12 Jun 2026 23:44:12 +0200 Subject: [PATCH 08/11] Fix CI exit hang: sign reassembled public packets in FragmentationTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisecting (base was 4/4 clean, branch 3/3 hung, reliably reproducible) pinned the parallel-suite exit hang to the public-message signature requirement (security fix #2), via FragmentationTests: reassemblyFromFragmentsDeliversPublicMessage and duplicateFragmentDoesNotBreakReassembly send fragments of an UNSIGNED public message and `await capture.waitForPublicMessages(...)`. With #2 the reassembled unsigned message is now (correctly) dropped, so didReceivePublicMessage never fires. The helper then trips a latent bug: on timeout it cancels the waiter task but never resumes its CheckedContinuation, so the throwing task group's teardown awaits a child that never completes and the whole test process hangs at exit (SIGKILL'd by CI). Base never hit it because the message always arrived in time. Fix matches the security model — real public broadcasts are signed: sign the reassembled packet with a NoiseEncryptionService and preseed the sender's signing key (same pattern as duplicatePacket_isDeduped), so #2 verifies and delivers it. Full parallel suite now exits cleanly 5/5 locally (branch was 3/3 hung before). Co-Authored-By: Claude Fable 5 --- .../Fragmentation/FragmentationTests.swift | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 59fdc364..53b4f0a7 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -21,9 +21,16 @@ struct FragmentationTests { let capture = CaptureDelegate() ble.delegate = capture - // Construct a big packet (3KB) from a remote sender (not our own ID) + // Construct a big SIGNED public packet (3KB) from a remote sender. Public + // messages must carry a valid signature, so the reassembled packet is + // signed and the sender's signing key is preseeded into the registry. + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let signingKey = signer.getSigningPublicKeyData() let remoteShortID = PeerID(str: "1122334455667788") - let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) + let original = try #require( + signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)), + "Failed to sign public packet" + ) // Use a small fragment size to ensure multiple pieces let fragments = fragmentPacket(original, fragmentSize: 400) @@ -36,7 +43,7 @@ struct FragmentationTests { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } - ble._test_handlePacket(fragment, fromPeerID: remoteShortID) + ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey) } // Wait for delegate callback with proper timeout @@ -52,8 +59,13 @@ struct FragmentationTests { let capture = CaptureDelegate() ble.delegate = capture + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let signingKey = signer.getSigningPublicKeyData() let remoteShortID = PeerID(str: "A1B2C3D4E5F60708") - let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) + let original = try #require( + signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)), + "Failed to sign public packet" + ) var frags = fragmentPacket(original, fragmentSize: 300) // Duplicate one fragment @@ -66,7 +78,7 @@ struct FragmentationTests { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } - ble._test_handlePacket(fragment, fromPeerID: remoteShortID) + ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey) } // Wait for delegate callback with proper timeout From bbe1ed0652a5f8435accdf0ef44b028409ceab7e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 14 Jun 2026 07:34:58 +0000 Subject: [PATCH 09/11] Automated update of relay data - Sun Jun 14 07:34:58 UTC 2026 --- relays/online_relays_gps.csv | 843 +++++++++++++++++------------------ 1 file changed, 408 insertions(+), 435 deletions(-) diff --git a/relays/online_relays_gps.csv b/relays/online_relays_gps.csv index 97652073..d2af4c45 100644 --- a/relays/online_relays_gps.csv +++ b/relays/online_relays_gps.csv @@ -1,441 +1,414 @@ Relay URL,Latitude,Longitude -nostr.bitcoiner.social:443,47.6743,-117.112 -relay-dev.satlantis.io:443,40.8302,-74.1299 -relay.lightning.pub:443,39.0438,-77.4874 -ribo.us.nostria.app:443,43.6532,-79.3832 -relay.notoshi.win,13.3622,100.983 -openrelay.ziomc.com,50.0755,14.4378 -relay.agentry.com:443,42.8864,-78.8784 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.gulugulu.moe,43.6532,-79.3832 -nostr.thebiglake.org:443,32.71,-96.6745 -relay.fountain.fm,43.6532,-79.3832 -freelay.sovbit.host,60.1699,24.9384 -nostr.girino.org:443,43.6532,-79.3832 -relay.openresist.com,43.6532,-79.3832 -nas01xanthosnet.synology.me:7778,47.1285,8.74735 -relay.ohstr.com:443,43.6532,-79.3832 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.nostrdice.com,-33.8688,151.209 -bbw-nostr.xyz,41.5284,-87.4237 -cs-relay.nostrdev.com:443,50.4754,12.3683 -top.testrelay.top,43.6532,-79.3832 -relay.trotters.cc,43.6532,-79.3832 -blossom.gnostr.cloud,43.6532,-79.3832 -ribo.eu.nostria.app:443,43.6532,-79.3832 -public.crostr.com,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -strfry.bonsai.com:443,39.0438,-77.4874 -nostr.carroarmato0.be,50.914,3.21378 -relay.endfiat.money,59.3327,18.0656 -nostr.overmind.lol:443,43.6532,-79.3832 -nostr.myshosholoza.co.za:443,52.3913,4.66545 -relay.wellorder.net,45.5201,-122.99 -nostr.tagomago.me,42.3601,-71.0589 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.fckstate.net,59.3293,18.0686 -nostr.azzamo.net,52.2633,21.0283 -relay.0xchat.com,43.6532,-79.3832 -relayone.geektank.ai,39.1008,-94.5811 -relay.homeinhk.xyz,35.694,139.754 -nostr.nodesmap.com,59.3327,18.0656 -relay.islandbitcoin.com:443,12.8498,77.6545 -relay.mrmave.work,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -fanfares.nostr1.com:443,40.7057,-74.0136 -wot.shaving.kiwi,43.6532,-79.3832 -relay.nearhood.co.uk,51.5072,-0.127586 -relay.fundstr.me,42.3601,-71.0589 -syb.lol:443,43.6532,-79.3832 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -relay.nostx.io,43.6532,-79.3832 -no.str.cr,10.6352,-85.4378 -relay.jbnco.co,43.6532,-79.3832 -ribo.nostria.app:443,43.6532,-79.3832 -us-east.nostr.pikachat.org,39.0438,-77.4874 -nexus.libernet.app,43.6532,-79.3832 -nostr.dlcdevkit.com,40.0992,-83.1141 -nostr.debate.report,50.1109,8.68213 -str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -nostr.unkn0wn.world,46.8499,9.53287 -nostr.spicyz.io:443,43.6532,-79.3832 -relay.layer.systems:443,49.0291,8.35695 -nostr-relay.amethyst.name,39.0067,-77.4291 -slick.mjex.me,39.0418,-77.4744 -nostr.girino.org,43.6532,-79.3832 -nostr.quali.chat:443,60.1699,24.9384 -purplerelay.com:443,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.plebeian.market:443,50.1109,8.68213 -relay.samt.st,40.8302,-74.1299 -relay.mitchelltribe.com:443,39.0438,-77.4874 -nostr.0x7e.xyz,47.4949,8.71954 -relayone.soundhsa.com:443,39.1008,-94.5811 -nostr.thalheim.io:443,60.1699,24.9384 -relay.getsafebox.app:443,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -bucket.coracle.social,37.7775,-122.397 -nostr.carroarmato0.be:443,50.914,3.21378 -relay.staging.commonshub.brussels,49.4543,11.0746 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.lacompagniemaximus.com:443,45.3147,-73.8785 -relay-rpi.edufeed.org,49.4521,11.0767 -nostr.computingcache.com:443,34.0356,-118.442 -relay.lanavault.space:443,60.1699,24.9384 -relay.getsafebox.app,43.6532,-79.3832 -nrs-02.darkcloudarcade.com:443,39.9526,-75.1652 -nostr.hekster.org,37.3986,-121.964 -node.kommonzenze.de,49.4521,11.0767 -0x-nostr-relay.fly.dev,37.7648,-122.432 -speakeasy.cellar.social,49.4543,11.0746 -nostr.0x7e.xyz:443,47.4949,8.71954 -nostr.vulpem.com,49.4543,11.0746 -relay5.bitransfer.org,43.6532,-79.3832 -relay.inforsupports.com,43.6532,-79.3832 -relay.plebeian.market,50.1109,8.68213 -shu02.shugur.net,21.4902,39.2246 -nostr.easycryptosend.it,43.6532,-79.3832 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.minibolt.info,43.6532,-79.3832 -nostr.pbfs.io:443,50.4754,12.3683 -nos.lol:443,50.4754,12.3683 -nostr.thebiglake.org,32.71,-96.6745 -relay.nostriot.com,41.5695,-83.9786 -strfry.shock.network:443,39.0438,-77.4874 -relay01.lnfi.network,35.6764,139.65 -r.0kb.io:443,32.789,-96.7989 -bcast.girino.org,43.6532,-79.3832 -nostr-relay.psfoundation.info:443,39.0438,-77.4874 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -testnet.samt.st,43.6532,-79.3832 -relay.bornheimer.app,51.5072,-0.127586 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -relay.binaryrobot.com:443,43.6532,-79.3832 -nostr.computingcache.com,34.0356,-118.442 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -schnorr.me,43.6532,-79.3832 -nostr.twinkle.lol,51.902,7.6657 -nostr.overmind.lol,43.6532,-79.3832 -relay.mmwaves.de:443,48.8575,2.35138 -relay2.veganostr.com,60.1699,24.9384 -nostr.mom,50.4754,12.3683 -nostr2.girino.org:443,43.6532,-79.3832 -wot.sudocarlos.com,43.6532,-79.3832 -dev.relay.stream,43.6532,-79.3832 -nostr.thalheim.io,60.1699,24.9384 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -conduitl2.fly.dev,37.7648,-122.432 -relay.bullishbounty.com,43.6532,-79.3832 -myvoiceourstory.org,37.3598,-121.981 -relay.angor.io,48.1046,11.6002 -r.0kb.io,32.789,-96.7989 -nexus.libernet.app:443,43.6532,-79.3832 -us-east.nostr.pikachat.org:443,39.0438,-77.4874 -nostr.bitcoiner.social,47.6743,-117.112 -nostrelay.circum.space:443,52.6907,4.8181 -relayone.soundhsa.com,39.1008,-94.5811 -nostr.snowbla.de,60.1699,24.9384 -relay1.orangesync.tech,44.7839,-106.941 -nostr-2.21crypto.ch:443,47.5356,8.73209 -espelho.girino.org,43.6532,-79.3832 -nostr.blankfors.se,60.1699,24.9384 -relay.sigit.io:443,50.4754,12.3683 -relay.vrtmrz.net:443,43.6532,-79.3832 -nostr.wecsats.io:443,43.6532,-79.3832 -nostrcity-club.fly.dev,37.7648,-122.432 -nostrelay.circum.space,52.6907,4.8181 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -x.kojira.io,43.6532,-79.3832 -relay.binaryrobot.com,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -nostr.sathoarder.com,48.5734,7.75211 -prl.plus,55.7628,37.5983 -relay.cosmicbolt.net:443,37.3986,-121.964 -relay.nostu.be:443,40.4167,-3.70329 -cs-relay.nostrdev.com,50.4754,12.3683 -satsage.xyz,37.3986,-121.964 -relay.lab.rytswd.com:443,49.4543,11.0746 -rilo.nostria.app:443,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -relay.wavlake.com:443,41.2619,-95.8608 -relay.beginningend.com,35.2227,-97.4786 -relay.openresist.com:443,43.6532,-79.3832 -bitcoiner.social:443,47.6743,-117.112 -relay.notoshi.win:443,13.3622,100.983 -dev.relay.edufeed.org:443,49.4521,11.0767 -relay.cypherflow.ai:443,48.8575,2.35138 -relay.ru.ac.th,13.7607,100.627 -shu03.shugur.net,25.2048,55.2708 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.agorist.space:443,52.3734,4.89406 -relay02.lnfi.network,35.6764,139.65 -relay-testnet.k8s.layer3.news:443,37.3387,-121.885 -nostr.notribe.net:443,40.8302,-74.1299 -relay.ditto.pub,43.6532,-79.3832 -nostr.rikmeijer.nl,51.7111,5.36809 -blossom.gnostr.cloud:443,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -cache.trustr.ing,43.6548,-79.3885 -nostr.bitczat.pl,60.1699,24.9384 -relay.nostrverse.net,43.6532,-79.3832 -shu04.shugur.net,25.2048,55.2708 -eu.nostr.pikachat.org:443,49.4543,11.0746 -ribo.us.nostria.app,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -nostr.bitczat.pl:443,60.1699,24.9384 -nostr-relay.corb.net:443,38.8353,-104.822 -relay.libernet.app,43.6532,-79.3832 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.nostu.be,40.4167,-3.70329 -rele.speyhard.fi,51.5072,-0.127586 -relay.staging.plebeian.market,51.5072,-0.127586 -nostr.spicyz.io,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 -x.kojira.io:443,43.6532,-79.3832 -relay.staging.plebeian.market:443,51.5072,-0.127586 -relay.sigit.io,50.4754,12.3683 -nostr.stakey.net:443,52.3676,4.90414 relay.nostr.blockhenge.com,39.0438,-77.4874 -relay.comcomponent.com,43.6532,-79.3832 -wot.nostr.place,43.6532,-79.3832 -relay.mostr.pub:443,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -no.str.cr:443,10.6352,-85.4378 -relay.chorus.community:443,48.5333,10.7 -relay.aarpia.com,37.3986,-121.964 -relay.nostrmap.net,60.1699,24.9384 -relay.snotr.nl:49999,52.0195,4.42946 -relay.bebond.net,43.6532,-79.3832 -relay.illuminodes.com,43.6532,-79.3832 -chat-relay.zap-work.com:443,43.6532,-79.3832 -testr.nymble.world,40.8054,-74.0241 -relay.underorion.se,50.1109,8.68213 -fanfares.nostr1.com,40.7057,-74.0136 -relay.damus.io,43.6532,-79.3832 -relay.nostriches.club,43.6532,-79.3832 -nostr-dev.wellorder.net,45.5201,-122.99 -relay2.angor.io,48.1046,11.6002 -relay.openfarmtools.org,60.1699,24.9384 -wot.makenomistakes.ca,43.7064,-79.3986 -relay.thecryptosquid.com,50.4754,12.3683 -test.thedude.cloud,50.1109,8.68213 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.olas.app:443,60.1699,24.9384 -strfry.bonsai.com,39.0438,-77.4874 -relayrs.notoshi.win,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -wot.utxo.one,43.6532,-79.3832 -relay.angor.io:443,48.1046,11.6002 -relay.dwadziesciajeden.pl,52.2297,21.0122 -soloco.nl,43.6532,-79.3832 -armada.sharegap.net,43.6532,-79.3832 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -relay.beginningend.com:443,35.2227,-97.4786 -relay.nostr.place,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -adre.su,59.9311,30.3609 -relay.0xchat.com:443,43.6532,-79.3832 -nostr.infero.net,35.6764,139.65 -relay.typedcypher.com:443,51.5072,-0.127586 -relay.mostro.network:443,40.8302,-74.1299 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -relay.mitchelltribe.com,39.0438,-77.4874 -relay.mostr.pub,43.6532,-79.3832 -bitcoiner.social,47.6743,-117.112 -nostr.tac.lol:443,47.4748,-122.273 -nostr.hekster.org:443,37.3986,-121.964 -relay.satmaxt.xyz:443,43.6532,-79.3832 -relay.cypherflow.ai,48.8575,2.35138 -relay.zone667.com:443,60.1699,24.9384 -relay.jeffg.fyi:443,43.6532,-79.3832 -relay.agorist.space,52.3734,4.89406 -nostr.spaceshell.xyz:443,43.6532,-79.3832 -top.testrelay.top:443,43.6532,-79.3832 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.laantungir.net:443,-19.4692,-42.5315 -relay.islandbitcoin.com,12.8498,77.6545 -purplerelay.com,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -nostr.wecsats.io,43.6532,-79.3832 -nostr.2b9t.xyz,34.0549,-118.243 -nostr.quali.chat,60.1699,24.9384 -relay.dreamith.to,43.6532,-79.3832 -nostr.islandarea.net:443,35.4669,-97.6473 -relay.primal.net,43.6532,-79.3832 -eu.nostr.pikachat.org,49.4543,11.0746 -relay.nostr.net,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.mulatta.io,37.5665,126.978 -nittom.nostr1.com,40.7057,-74.0136 -relay2.orangesync.tech,40.7128,-74.006 -relay.artx.market,43.6548,-79.3885 -relay.wavefunc.live,41.8781,-87.6298 -bitchat.nostr1.com,40.7057,-74.0136 -relay.ditto.pub:443,43.6532,-79.3832 -relay.wisp.talk,49.4543,11.0746 -nostrelites.org,41.8781,-87.6298 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.chorus.community,48.5333,10.7 -relay.plebchain.club,43.6532,-79.3832 -nostr-relay.amethyst.name:443,39.0067,-77.4291 -relay.lanacoin-eternity.com,40.8302,-74.1299 -relay.edufeed.org:443,49.4521,11.0767 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.dreamith.to:443,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.n7ekb.net:443,47.4941,-122.294 -relay.dyne.org,49.0291,8.35705 -nostr.janx.com,43.6532,-79.3832 -relay.trustr.ing,43.6548,-79.3885 -relay.paulstephenborile.com:443,49.4543,11.0746 -relay.wisp.talk:443,49.4543,11.0746 -yabu.me,35.6092,139.73 -bcast.seutoba.com.br,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -nos.lol,50.4754,12.3683 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.endfiat.money:443,59.3327,18.0656 -relay.tapestry.ninja,40.8054,-74.0241 -relay.lab.rytswd.com,49.4543,11.0746 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.damus.io:443,43.6532,-79.3832 -treuzkas.branruz.com,48.8575,2.35138 -nostr-01.yakihonne.com,1.32123,103.695 -relay.nostriot.com:443,41.5695,-83.9786 -nostr-relay.nextblockvending.com,47.2343,-119.853 -nostr.aruku.ovh,1.27994,103.849 -nostr.chaima.info,50.1109,8.68213 -ynostr.yael.at,60.1699,24.9384 -ynostr.yael.at:443,60.1699,24.9384 -nostr-01.yakihonne.com:443,1.32123,103.695 -spookstr2.nostr1.com,40.7057,-74.0136 -relay.trustr.ing:443,43.6548,-79.3885 -dev.relay.edufeed.org,49.4521,11.0767 -relay2.angor.io:443,48.1046,11.6002 -nostr.azzamo.net:443,52.2633,21.0283 -offchain.bostr.online,43.6532,-79.3832 +relay.agorist.space:443,52.3734,4.89406 relay.zone667.com,60.1699,24.9384 -relay.veganostr.com,60.1699,24.9384 -vault.iris.to:443,43.6532,-79.3832 -relay.artx.market:443,43.6548,-79.3885 -wot.rejecttheframe.xyz,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -relay.mostro.network,40.8302,-74.1299 -strfry.shock.network,39.0438,-77.4874 -relay.klabo.world,47.2343,-119.853 -relay.fountain.fm:443,43.6532,-79.3832 -nostrja-kari.heguro.com,43.6532,-79.3832 -relaisnostr.trivaco.fr,48.5734,7.75211 -offchain.pub,39.1585,-94.5728 -relay.degmods.com,50.4754,12.3683 -nostr-relay.xbytez.io:443,50.6924,3.20113 -relay.paulstephenborile.com,49.4543,11.0746 -nittom.nostr1.com:443,40.7057,-74.0136 -dev-relay.nostreon.com,60.1699,24.9384 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -relay.jeffg.fyi,43.6532,-79.3832 -relay.laantungir.net,-19.4692,-42.5315 -nostr.data.haus:443,50.4754,12.3683 -wot.codingarena.top,50.4754,12.3683 -nostr.2b9t.xyz:443,34.0549,-118.243 -relay.libernet.app:443,43.6532,-79.3832 -nostr.ps1829.com,33.8851,130.883 -wot.dergigi.com,64.1476,-21.9392 -relay.olas.app,60.1699,24.9384 -relay.lanacoin-eternity.com:443,40.8302,-74.1299 -nostr.data.haus,50.4754,12.3683 -relay-dev.gulugulu.moe,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -relay.lightning.pub,39.0438,-77.4874 -relay.guggero.org,46.5971,9.59652 -testnet-relay.samt.st:443,40.8302,-74.1299 -thecitadel.nostr1.com,40.7057,-74.0136 -nostr.ps1829.com:443,33.8851,130.883 -nostr-relay.corb.net,38.8353,-104.822 -relay.npubhaus.com,43.6532,-79.3832 -nostr.21crypto.ch,47.5356,8.73209 -nostr.tadryanom.me,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3913,4.66545 -relay.bitmacro.cloud,43.6532,-79.3832 -ribo.nostria.app,43.6532,-79.3832 -relay.vrtmrz.net,43.6532,-79.3832 -syb.lol,43.6532,-79.3832 -relay.bebond.net:443,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -nostr-2.21crypto.ch,47.5356,8.73209 -nostrcity-club.fly.dev:443,37.7648,-122.432 -articles.layer3.news:443,37.3387,-121.885 -relay.internationalright-wing.org:443,-22.5022,-48.7114 -nostr-relay.zimage.com,34.0549,-118.243 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.mwaters.net,50.9871,2.12554 -nostr.liberty.fans,36.9104,-89.5875 -spookstr2.nostr1.com:443,40.7057,-74.0136 -nostr.chaima.info:443,50.1109,8.68213 -schnorr.me:443,43.6532,-79.3832 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr.bond,50.1109,8.68213 -wot.nostr.party,36.1659,-86.7844 -temp.iris.to,43.6532,-79.3832 -relay.lotek-distro.com,43.6532,-79.3832 -relay.minibolt.info:443,43.6532,-79.3832 -relay.lanavault.space,60.1699,24.9384 -relay.mmwaves.de,48.8575,2.35138 -social.amanah.eblessing.co,48.1046,11.6002 -nostr2.thalheim.io,49.4543,11.0746 relay.typedcypher.com,51.5072,-0.127586 -relay.cosmicbolt.net,37.3986,-121.964 -relayrs.notoshi.win:443,43.6532,-79.3832 -nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 -relay.nostrmap.net:443,60.1699,24.9384 -relay.bullishbounty.com:443,43.6532,-79.3832 -nostr.oxtr.dev:443,50.4754,12.3683 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -vault.iris.to,43.6532,-79.3832 -relay.mccormick.cx:443,52.3563,4.95714 -relay.toastr.net,40.8054,-74.0241 -nostr.hifish.org,47.4244,8.57658 -speakeasy.cellar.social:443,49.4543,11.0746 -relay.wavlake.com,41.2619,-95.8608 -testnet-relay.samt.st,40.8302,-74.1299 -aeon.libretechsystems.xyz,55.486,9.86577 -mostro-p2p.tech,50.1109,8.68213 -nostr.wild-vibes.ts.net,48.8566,2.35222 -nostr.88mph.life,52.1941,-2.21905 -relay.nostrcheck.me,43.6532,-79.3832 -rilo.nostria.app,43.6532,-79.3832 -relay.bowlafterbowl.com,32.9483,-96.7299 -relay.nostr.place:443,43.6532,-79.3832 -nostr.mom:443,50.4754,12.3683 -relay.nostrian-conquest.com,41.223,-111.974 -herbstmeister.com,34.0549,-118.243 -nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 -nostr.red5d.dev,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -strfry.apps3.slidestr.net,40.4167,-3.70329 -relay.sharegap.net,43.6532,-79.3832 -reraw.pbla2fish.cc,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.sathoarder.com:443,48.5734,7.75211 -relay.veganostr.com:443,60.1699,24.9384 -relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 -premium.primal.net,43.6532,-79.3832 -relay.wavefunc.live:443,41.8781,-87.6298 -relay-rpi.edufeed.org:443,49.4521,11.0767 -kotukonostr.onrender.com,37.7775,-122.397 -bridge.tagomago.me,42.3601,-71.0589 -nostr.tadryanom.me:443,43.6532,-79.3832 -relay.gulugulu.moe:443,43.6532,-79.3832 +wot.nostr.place,43.6532,-79.3832 +nostr.myshosholoza.co.za:443,52.3913,4.66545 +relay.homeinhk.xyz,35.694,139.754 +nostr.ps1829.com:443,33.8851,130.883 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.underorion.se,50.1109,8.68213 +nostr-relay.amethyst.name:443,39.0067,-77.4291 +relay.mitchelltribe.com:443,39.0438,-77.4874 +bitchat.nostr1.com,40.7057,-74.0136 +0x-nostr-relay.fly.dev,37.7648,-122.432 +nostr.blankfors.se,60.1699,24.9384 +relay.solife.me,43.6532,-79.3832 +relay.snotr.nl:49999,52.0195,4.42946 +nostr-relay.corb.net:443,38.8353,-104.822 +relay.klabo.world,47.2343,-119.853 +relay02.lnfi.network,35.6764,139.65 +ribo.us.nostria.app,43.6532,-79.3832 +nostr.quali.chat:443,60.1699,24.9384 offchain.pub:443,39.1585,-94.5728 -relay.directsponsor.net,42.8864,-78.8784 +pool.libernet.app,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +soloco.nl,43.6532,-79.3832 +relay.ru.ac.th,13.7607,100.627 +schnorr.me,43.6532,-79.3832 +ec2.f7z.io,60.1699,24.9384 +relay.gulugulu.moe:443,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.vulpem.com,49.4543,11.0746 +rilo.nostria.app,43.6532,-79.3832 +adre.su,59.9311,30.3609 +ribo.eu.nostria.app:443,43.6532,-79.3832 +vault.iris.to,43.6532,-79.3832 +strfry.shock.network:443,39.0438,-77.4874 +x.kojira.io,43.6532,-79.3832 +ribo.nostria.app:443,43.6532,-79.3832 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.chorus.community:443,48.5333,10.7 +nostr.aruku.ovh,1.27994,103.849 +relay.internationalright-wing.org:443,-22.5022,-48.7114 +relay.getsafebox.app,43.6532,-79.3832 +relay-dev.satlantis.io:443,40.8302,-74.1299 +kasztanowa.bieda.it,43.6532,-79.3832 +relay.bullishbounty.com:443,43.6532,-79.3832 +relay.nostrian-conquest.com:443,41.223,-111.974 +relay.kilombino.com,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +relay.erybody.com,41.4513,-81.7021 +nostr.na.social,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +nostr.tagomago.me,42.3601,-71.0589 +relay.routstr.com,59.4016,17.9455 +relay.wellorder.net,45.5201,-122.99 +node.kommonzenze.de,49.4521,11.0767 +nostr.pbfs.io,50.4754,12.3683 +fanfares.nostr1.com:443,40.7057,-74.0136 +relay.btcforplebs.com,43.6532,-79.3832 +relay.nostrian-conquest.com,41.223,-111.974 +strfry.shock.network,39.0438,-77.4874 +relay.satmaxt.xyz:443,43.6532,-79.3832 +relay.fckstate.net,59.3293,18.0686 +relayone.geektank.ai,39.1008,-94.5811 +relayrs.notoshi.win:443,43.6532,-79.3832 +wot.nostr.party,36.1659,-86.7844 +testnet.samt.st,43.6532,-79.3832 +nostr.stakey.net:443,52.3676,4.90414 +nostr.spaceshell.xyz,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.snort.social,53.3498,-6.26031 +social.amanah.eblessing.co,48.1046,11.6002 +relay.veganostr.com:443,60.1699,24.9384 +relay2.angor.io:443,48.1046,11.6002 +nostr.overmind.lol:443,43.6532,-79.3832 +nostr.ps1829.com,33.8851,130.883 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +wot.shaving.kiwi,43.6532,-79.3832 +relay.typedcypher.com:443,51.5072,-0.127586 +relay.openfarmtools.org,60.1699,24.9384 +wot.brightbolt.net,47.6735,-116.781 +relay.libernet.app,43.6532,-79.3832 +slick.mjex.me,39.0418,-77.4744 +relay.nostr.net,43.6532,-79.3832 +nostr.chaima.info,50.1109,8.68213 +relay.wavlake.com:443,41.2619,-95.8608 +relay.cosmicbolt.net,37.3986,-121.964 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.nostr.place:443,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +testnet-relay.samt.st:443,40.8302,-74.1299 +relay.islandbitcoin.com,12.8498,77.6545 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +relay.ohstr.com:443,43.6532,-79.3832 +nostr.88mph.life,52.1941,-2.21905 +relay.layer.systems:443,49.0291,8.35695 +schnorr.me:443,43.6532,-79.3832 +nostr-relay.psfoundation.info:443,39.0438,-77.4874 nostr.snowbla.de:443,60.1699,24.9384 +relay.olas.app,60.1699,24.9384 +espelho.girino.org,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +relayone.geektank.ai:443,39.1008,-94.5811 +nostr.easycryptosend.it,43.6532,-79.3832 +thecitadel.nostr1.com,40.7057,-74.0136 +relay.gulugulu.moe,43.6532,-79.3832 +relay.lab.rytswd.com:443,49.4543,11.0746 +nos.lol,50.4754,12.3683 +relay.plebeian.market:443,50.1109,8.68213 +relay.getsafebox.app:443,43.6532,-79.3832 +relay.mitchelltribe.com,39.0438,-77.4874 +relay-dev.gulugulu.moe,43.6532,-79.3832 +relay.trotters.cc,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.ditto.pub:443,43.6532,-79.3832 +relay.fountain.fm,43.6532,-79.3832 +nostr.2b9t.xyz,34.0549,-118.243 +herbstmeister.com,34.0549,-118.243 +dev.relay.edufeed.org:443,49.4521,11.0767 +relay2.veganostr.com,60.1699,24.9384 +nostr.purpura.cloud,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +relay.paulstephenborile.com,49.4543,11.0746 +relay.0xchat.com,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +nostrcity-club.fly.dev:443,37.7648,-122.432 +relay.endfiat.money:443,59.3327,18.0656 +relay.samt.st,40.8302,-74.1299 +relay.mostr.pub,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +nostrride.io,37.3986,-121.964 +mostro-p2p.tech,50.1109,8.68213 +relay.toastr.net,40.8054,-74.0241 +reraw.pbla2fish.cc,43.6532,-79.3832 +nostr.self-determined.de,53.495,10.2542 +temp.iris.to,43.6532,-79.3832 +relay.openresist.com:443,43.6532,-79.3832 +relay.mostro.network:443,40.8302,-74.1299 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +nostr.chaima.info:443,50.1109,8.68213 +dev.relay.edufeed.org,49.4521,11.0767 +nostr.computingcache.com:443,34.0356,-118.442 +relay.sigit.io,50.4754,12.3683 +nostr.oxtr.dev:443,50.4754,12.3683 +relay.angor.io,48.1046,11.6002 +kotukonostr.onrender.com,37.7775,-122.397 +zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832 +shu01.shugur.net,21.4902,39.2246 +relayone.soundhsa.com:443,39.1008,-94.5811 +relay2.angor.io,48.1046,11.6002 +nittom.nostr1.com,40.7057,-74.0136 +nostr.girino.org:443,43.6532,-79.3832 +nexus.libernet.app,43.6532,-79.3832 +bitcoiner.social,47.6743,-117.112 +nostr2.girino.org,43.6532,-79.3832 +relaisnostr.trivaco.fr,49.4282,10.9796 +relay.edufeed.org:443,49.4521,11.0767 +relay.binaryrobot.com,43.6532,-79.3832 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +relay.endfiat.money,59.3327,18.0656 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 +nostr.liberty.fans,36.9104,-89.5875 +relay.mmwaves.de,48.8575,2.35138 +relay.inforsupports.com,43.6532,-79.3832 +dev-nostr.bityacht.io,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.0xchat.com:443,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +relay.mrmave.work,43.6532,-79.3832 +nostr.carroarmato0.be,50.914,3.21378 +nostr.myshosholoza.co.za,52.3913,4.66545 +nostr.thebiglake.org,32.71,-96.6745 +relay.kalcafe.xyz,37.3986,-121.964 +nostr.openhoofd.nl,51.5717,3.70417 +relayone.soundhsa.com,39.1008,-94.5811 +relay.bullishbounty.com,43.6532,-79.3832 +satsage.xyz,37.3986,-121.964 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +bridge.tagomago.me,42.3601,-71.0589 +relay.sigit.io:443,50.4754,12.3683 +relay.mypathtofire.de,42.8864,-78.8784 +relay.dreamith.to,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostr.2b9t.xyz:443,34.0549,-118.243 +nostr.notribe.net,40.8302,-74.1299 +relay.nostrdice.com,-33.8688,151.209 +nostr-relay.amethyst.name,39.0067,-77.4291 +relay.mccormick.cx:443,52.3563,4.95714 +nostr.4rs.nl,49.0291,8.35696 +relay.dwadziesciajeden.pl,52.2297,21.0122 +spookstr2.nostr1.com:443,40.7057,-74.0136 +relay.vrtmrz.net:443,43.6532,-79.3832 +relay.minibolt.info:443,43.6532,-79.3832 +nostrelites.org,41.8781,-87.6298 +relay.nostar.org,43.6532,-79.3832 +nostriches.club,43.6532,-79.3832 +rele.speyhard.fi,51.5072,-0.127586 +wot.utxo.one,43.6532,-79.3832 +relay1.orangesync.tech,44.7839,-106.941 +damus.bostr.online,43.6532,-79.3832 +relay.islandbitcoin.com:443,12.8498,77.6545 +relay.mulatta.io,37.5665,126.978 +strfry.openhoofd.nl,51.5717,3.70417 +cs-relay.nostrdev.com,50.4754,12.3683 +nostr.bitcoiner.social,47.6743,-117.112 +relay.thecryptosquid.com,50.4754,12.3683 +nostr.computingcache.com,34.0356,-118.442 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay5.bitransfer.org,43.6532,-79.3832 +relay.damus.io,43.6532,-79.3832 +strfry.ymir.cloud,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +relay.vrtmrz.net,43.6532,-79.3832 +myvoiceourstory.org,37.3598,-121.981 +strfry.openhoofd.nl:443,51.5717,3.70417 +spookstr2.nostr1.com,40.7057,-74.0136 +bitcoiner.social:443,47.6743,-117.112 +aeon.libretechsystems.xyz,55.486,9.86577 +relayrs.notoshi.win,43.6532,-79.3832 +nostr.pbfs.io:443,50.4754,12.3683 +public.crostr.com,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +rilo.nostria.app:443,43.6532,-79.3832 +blossom.gnostr.cloud,43.6532,-79.3832 +relay.aarpia.com,37.3986,-121.964 +relay.arx-ccn.com,50.4754,12.3683 +conduitl2.fly.dev,37.7648,-122.432 +relay.nostrmap.net,60.1699,24.9384 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +nostrelay.circum.space,52.6907,4.8181 +syb.lol:443,43.6532,-79.3832 +relay.artx.market,43.6548,-79.3885 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +blossom.gnostr.cloud:443,43.6532,-79.3832 +x.kojira.io:443,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +relay-rpi.edufeed.org,49.4521,11.0767 +testr.nymble.world,40.8054,-74.0241 +relay.beginningend.com:443,35.2227,-97.4786 +relay.openresist.com,43.6532,-79.3832 +relay.minibolt.info,43.6532,-79.3832 +nostr.thalheim.io,60.1699,24.9384 +relay.notoshi.win:443,13.3622,100.983 +vault.iris.to:443,43.6532,-79.3832 +top.testrelay.top,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +relay.nearhood.co.uk,51.5072,-0.127586 +nostr.overmind.lol,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.artx.market:443,43.6548,-79.3885 +nos.lol:443,50.4754,12.3683 +relay.illuminodes.com,43.6532,-79.3832 +relay.laantungir.net,-19.4692,-42.5315 +relay.plebchain.club,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostrja-kari.heguro.com,43.6532,-79.3832 +nostrsgp.notribe.net,1.32123,103.695 +nostr.thalheim.io:443,60.1699,24.9384 +prl.plus,55.7628,37.5983 +nostr.carroarmato0.be:443,50.914,3.21378 +nostr.nodesmap.com,59.3327,18.0656 +bucket.coracle.social,37.7775,-122.397 +relay.lightning.pub,39.0438,-77.4874 +relay.dreamith.to:443,43.6532,-79.3832 +nostr.na.social:443,43.6532,-79.3832 +nostr.debate.report,50.1109,8.68213 +relay2.orangesync.tech,40.7128,-74.006 +relay.comcomponent.com,43.6532,-79.3832 +nostr-check.me,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +nostr.21crypto.ch,47.5356,8.73209 +relay.sincensura.org,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5022,-48.7114 +antiprimal.net,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.nostr.place,43.6532,-79.3832 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +nostr.wecsats.io:443,43.6532,-79.3832 +cs-relay.nostrdev.com:443,50.4754,12.3683 +nostr.azzamo.net,52.2633,21.0283 +relay.cypherflow.ai:443,48.8575,2.35138 +nostr.hifish.org,47.4244,8.57658 +relay.wavefunc.live:443,41.8781,-87.6298 +nostr.tac.lol:443,47.4748,-122.273 +wot.dergigi.com,64.1476,-21.9392 +nostr.wecsats.io,43.6532,-79.3832 +relay-dev.satlantis.io,40.8302,-74.1299 +nostr.sathoarder.com:443,48.5734,7.75211 +cache.trustr.ing,43.6548,-79.3885 +relay.directsponsor.net,42.8864,-78.8784 +strfry.bonsai.com:443,39.0438,-77.4874 +nostr-01.yakihonne.com,1.32123,103.695 +relay.satlantis.io,40.8054,-74.0241 +nostr-2.21crypto.ch:443,47.5356,8.73209 +offchain.bostr.online,43.6532,-79.3832 +test.thedude.cloud,50.1109,8.68213 +relay.mostr.pub:443,43.6532,-79.3832 +relay.libernet.app:443,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +nostr.rtvslawenia.com:443,49.4543,11.0746 +relay.plebeian.market,50.1109,8.68213 +wot.makenomistakes.ca,43.7064,-79.3986 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +ithurtswhenip.ee,51.5072,-0.127586 +relay.staging.commonshub.brussels,49.4543,11.0746 +relay.beginningend.com,35.2227,-97.4786 +strfry.bonsai.com,39.0438,-77.4874 +nostrelay.circum.space:443,52.6907,4.8181 +relay.mostro.network,40.8302,-74.1299 +dev-relay.nostreon.com,60.1699,24.9384 +relay.fundstr.me,42.3601,-71.0589 +relay.mmwaves.de:443,48.8575,2.35138 +relay.nostu.be,40.4167,-3.70329 +relay.nostx.io,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.lightning.pub:443,39.0438,-77.4874 +relay.nostr-check.me,43.6532,-79.3832 +nostr.bitcoiner.social:443,47.6743,-117.112 +ribo.nostria.app,43.6532,-79.3832 +offchain.pub,39.1585,-94.5728 +relay.layer.systems,49.0291,8.35695 +nostr.dlcdevkit.com,40.0992,-83.1141 +relay.angor.io:443,48.1046,11.6002 +relay.mwaters.net,50.9871,2.12554 +relay.nostriot.com:443,41.5695,-83.9786 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr.notribe.net:443,40.8302,-74.1299 +freelay.sovbit.host,60.1699,24.9384 +syb.lol,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +wot.codingarena.top,50.4754,12.3683 +nostr-2.21crypto.ch,47.5356,8.73209 +nostr.hifish.org:443,47.4244,8.57658 +relay.zone667.com:443,60.1699,24.9384 +relay.lab.rytswd.com,49.4543,11.0746 +nas01xanthosnet.synology.me:7778,47.1285,8.74735 +relay.primal.net,43.6532,-79.3832 +relay.bitmacro.cloud,43.6532,-79.3832 +chat-relay.zap-work.com:443,43.6532,-79.3832 +relay.fountain.fm:443,43.6532,-79.3832 +ribo.eu.nostria.app,43.6532,-79.3832 +nexus.libernet.app:443,43.6532,-79.3832 +nostr.sathoarder.com,48.5734,7.75211 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr.snowbla.de,60.1699,24.9384 +strfry.apps3.slidestr.net,40.4167,-3.70329 +nostr.spicyz.io:443,43.6532,-79.3832 +nostr.data.haus:443,50.4754,12.3683 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +purplerelay.com:443,43.6532,-79.3832 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.nostrmap.net:443,60.1699,24.9384 +relay.ohstr.com,43.6532,-79.3832 +r.0kb.io:443,32.789,-96.7989 +uncouth-panning-paternal.ngrok-free.dev,40.0992,-83.1141 +wot.sudocarlos.com,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +ribo.us.nostria.app:443,43.6532,-79.3832 +no.str.cr,10.6352,-85.4378 +relay.notoshi.win,13.3622,100.983 +nostr.spicyz.io,43.6532,-79.3832 +relay.veganostr.com,60.1699,24.9384 +r.0kb.io,32.789,-96.7989 +relay-testnet.k8s.layer3.news:443,37.3387,-121.885 +relay.cosmicbolt.net:443,37.3986,-121.964 +nostr-relay.xbytez.io:443,50.6924,3.20113 +relay.sharegap.net,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr2.girino.org:443,43.6532,-79.3832 +relay.cypherflow.ai,48.8575,2.35138 +nostr-relay.corb.net,38.8353,-104.822 +relay.staging.plebeian.market,51.5072,-0.127586 +relay.lanavault.space,60.1699,24.9384 +relay.wisp.talk,49.4543,11.0746 +relay.wisp.talk:443,49.4543,11.0746 +no.str.cr:443,10.6352,-85.4378 +nostr.hekster.org:443,37.3986,-121.964 +nostr-01.yakihonne.com:443,1.32123,103.695 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +relay.guggero.org,46.5971,9.59652 +nostr.twinkle.lol,51.902,7.6657 +nostr.janx.com,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +relay.nostriot.com,41.5695,-83.9786 +nostr.hekster.org,37.3986,-121.964 +fanfares.nostr1.com,40.7057,-74.0136 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.edufeed.org,49.4521,11.0767 +relay.olas.app:443,60.1699,24.9384 +armada.sharegap.net,43.6532,-79.3832 +nostr.islandarea.net:443,35.4669,-97.6473 +relay0.gfcom.info,13.8434,100.363 +relay.chorus.community,48.5333,10.7 +nittom.nostr1.com:443,40.7057,-74.0136 +relay.staging.plebeian.market:443,51.5072,-0.127586 +chat-relay.zap-work.com,43.6532,-79.3832 +relay-rpi.edufeed.org:443,49.4521,11.0767 +relay.lanavault.space:443,60.1699,24.9384 +relay.binaryrobot.com:443,43.6532,-79.3832 +nostr.mom:443,50.4754,12.3683 +relay.bornheimer.app,51.5072,-0.127586 +treuzkas.branruz.com,48.8575,2.35138 +nostr-dev.wellorder.net,45.5201,-122.99 +public.crostr.com:443,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +articles.layer3.news:443,37.3387,-121.885 +nostr.azzamo.net:443,52.2633,21.0283 +premium.primal.net,43.6532,-79.3832 From 914135adb043a4830f3ae892047bf2d7f8463992 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 16 Jun 2026 13:55:54 +0200 Subject: [PATCH 10/11] Fix panic wipe relay and geohash state --- bitchat/Nostr/NostrRelayManager.swift | 47 +++++++++++++++++++ bitchat/ViewModels/ChatViewModel.swift | 16 ++++++- bitchatTests/ChatViewModelTests.swift | 32 +++++++++++++ .../Services/NostrRelayManagerTests.swift | 43 +++++++++++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 91cfaec4..ed291838 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -298,6 +298,41 @@ final class NostrRelayManager: ObservableObject { torReadyWaitAttempts = 0 updateConnectionStatus() } + + /// Panic wipe reset: close sockets and drop every user/session-specific + /// relay intent without invoking old callbacks. Unlike `disconnect()`, this + /// must not preserve subscription replay state because geohash DM handlers + /// can capture pre-wipe Nostr private keys. + func resetForPanicWipe() { + connectionGeneration &+= 1 + for (_, task) in connections { + task.cancel(with: .goingAway, reason: nil) + } + connections.removeAll() + subscriptions.removeAll() + pendingSubscriptions.removeAll() + messageHandlers.removeAll() + subscriptionRequestState.removeAll() + subscribeCoalesce.removeAll() + eoseTrackers.removeAll() + pendingEOSECallbacks.removeAll() + pendingTorConnectionURLs.removeAll() + awaitingTorForConnections = false + torReadyWaitAttempts = 0 + recentInboundEventKeys.removeAll() + recentInboundEventKeyOrder.removeAll() + duplicateInboundEventDropCount = 0 + duplicateInboundEventDropCountBySubscription.removeAll() + inboundEventLogCount = 0 + Self.pendingGiftWrapIDs.removeAll() + + messageQueueLock.lock() + messageQueue.removeAll() + pendingSendDropCount = 0 + messageQueueLock.unlock() + + updateConnectionStatus() + } /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { @@ -1170,6 +1205,18 @@ final class NostrRelayManager: ObservableObject { return Set(map.keys) } + var debugMessageHandlerCount: Int { + messageHandlers.count + } + + var debugSubscriptionRequestCount: Int { + subscriptionRequestState.count + } + + var debugPendingEOSECallbackCount: Int { + pendingEOSECallbacks.count + } + var debugDuplicateInboundEventDropCount: Int { duplicateInboundEventDropCount } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4d26ed31..a38dc529 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1158,13 +1158,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear selected private chat selectedPrivateChatPeer = nil + // Clear live location/geohash session state. Persisted location state + // was wiped above, but the running view model can still be scoped to a + // geohash channel and hold subscriptions tied to the old Nostr identity. + activeChannel = .mesh + setGeoChatSubscriptionID(nil) + setGeoDmSubscriptionID(nil) + _ = clearGeoSamplingSubs() + cachedGeohashIdentity = nil + nostrKeyMapping.removeAll() + // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() // IMPORTANT: Clear Nostr-related state - // Disconnect from Nostr relays and clear subscriptions - nostrRelayManager?.disconnect() + // Drop relay subscriptions, handlers, pending sends, and replay state. + // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain + // disconnect is not enough here. + NostrRelayManager.shared.resetForPanicWipe() nostrRelayManager = nil // Clear Nostr identity associations diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 8045feae..3e9c2daa 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests { #expect(viewModel.unreadPrivateMessages.isEmpty) #expect(viewModel.selectedPrivateChatPeer == nil) } + + @Test @MainActor + func panicClearAllData_resetsLiveGeohashAndNostrState() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruy" + let channel = GeohashChannel(level: .city, geohash: geohash) + let identity = try NostrIdentity.generate() + let pubkey = String(repeating: "ab", count: 32) + let peerID = PeerID(nostr: pubkey) + + viewModel.activeChannel = .location(channel) + viewModel.setGeoChatSubscriptionID("geo-\(geohash)") + viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)") + viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash) + viewModel.cachedGeohashIdentity = (geohash, identity) + viewModel.registerNostrKeyMapping(pubkey, for: peerID) + viewModel.currentGeohash = geohash + viewModel.geoNicknames = [pubkey: "alice"] + viewModel.teleportedGeo = [pubkey] + + viewModel.panicClearAllData() + + #expect(viewModel.activeChannel == .mesh) + #expect(viewModel.geoSubscriptionID == nil) + #expect(viewModel.geoDmSubscriptionID == nil) + #expect(viewModel.geoSamplingSubs.isEmpty) + #expect(viewModel.cachedGeohashIdentity == nil) + #expect(viewModel.nostrKeyMapping.isEmpty) + #expect(viewModel.currentGeohash == nil) + #expect(viewModel.geoNicknames.isEmpty) + #expect(viewModel.teleportedGeo.isEmpty) + } } // MARK: - Service Lifecycle Tests diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 8184564a..ce07f303 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1328,6 +1328,49 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) } + func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws { + let relayURL = "wss://panic-reset.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + let event = try makeSignedEvent(content: "queued before panic") + var handledEvents = 0 + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "panic-sub", + relayUrls: [relayURL], + handler: { _ in handledEvents += 1 }, + onEOSE: { eoseCount += 1 } + ) + context.manager.sendEvent(event, to: [relayURL]) + + XCTAssertEqual(context.manager.debugMessageHandlerCount, 1) + XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) + XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.manager.resetForPanicWipe() + + XCTAssertEqual(context.manager.debugMessageHandlerCount, 0) + XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) + XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0) + XCTAssertEqual(handledEvents, 0) + XCTAssertEqual(eoseCount, 0) + + // Stale Tor wait and fallback callbacks from the pre-wipe generation + // must not resurrect connections or settle callbacks after reset. + context.torWaiter.resolve(true) + context.scheduler.runNext() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + XCTAssertEqual(eoseCount, 0) + } + func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { let relayURL = "wss://jitter-bounds.example" // Pin the jitter source to the extremes and the midpoint of [0, 1). From 0a2f4d9c9dc75f6c7023e076774e0606ec378b60 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 17 Jun 2026 09:27:13 +0200 Subject: [PATCH 11/11] Tighten panic wipe and NIP-17 regressions --- bitchat/Nostr/NostrProtocol.swift | 44 +++++++++++++++++ bitchat/Nostr/NostrRelayManager.swift | 20 ++++++++ bitchatTests/NostrProtocolTests.swift | 47 +++++++++++++++++++ .../Services/NostrRelayManagerTests.swift | 19 ++++++++ 4 files changed, 130 insertions(+) diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index e2d432a8..f68caa8d 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -118,6 +118,50 @@ struct NostrProtocol { return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) } + #if DEBUG + static func createPrivateMessageWithInvalidSealSignatureForTesting( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let rumor = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .dm, + tags: [], + content: content + ) + var seal = try createSeal( + rumor: rumor, + recipientPubkey: recipientPubkey, + senderKey: senderIdentity.schnorrSigningKey() + ) + seal.sig = String(repeating: "0", count: 128) + return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) + } + + static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( + content: String, + recipientPubkey: String, + rumorIdentity: NostrIdentity, + sealSignerIdentity: NostrIdentity + ) throws -> NostrEvent { + let rumor = NostrEvent( + pubkey: rumorIdentity.publicKeyHex, + createdAt: Date(), + kind: .dm, + tags: [], + content: content + ) + let seal = try createSeal( + rumor: rumor, + recipientPubkey: recipientPubkey, + senderKey: sealSignerIdentity.schnorrSigningKey() + ) + return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) + } + #endif + /// Create a geohash-scoped ephemeral public message (kind 20000) static func createEphemeralGeohashEvent( content: String, diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index ed291838..53d794fa 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -281,6 +281,7 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() + markRelaySocketsClosed(resetState: false) // Sockets are gone, so per-relay subscription state is cleared — but // durable intent (subscriptionRequestState, messageHandlers, parked // EOSE callbacks) is kept so REQs replay when relays reconnect @@ -309,6 +310,7 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() + markRelaySocketsClosed(resetState: true) subscriptions.removeAll() pendingSubscriptions.removeAll() messageHandlers.removeAll() @@ -333,6 +335,24 @@ final class NostrRelayManager: ObservableObject { updateConnectionStatus() } + + private func markRelaySocketsClosed(resetState: Bool) { + let now = dependencies.now() + for index in relays.indices { + relays[index].isConnected = false + relays[index].nextReconnectTime = nil + if resetState { + relays[index].lastError = nil + relays[index].lastConnectedAt = nil + relays[index].lastDisconnectedAt = nil + relays[index].messagesSent = 0 + relays[index].messagesReceived = 0 + relays[index].reconnectAttempts = 0 + } else { + relays[index].lastDisconnectedAt = now + } + } + } /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 4ea8020f..d237f5fc 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -120,6 +120,42 @@ struct NostrProtocolTests { } } + @Test func decryptRejectsInvalidSealSignature() throws { + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting( + content: "forged signature", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + expectInvalidEvent { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + } + } + + @Test func decryptRejectsSealRumorPubkeyMismatch() throws { + let claimedSender = try NostrIdentity.generate() + let sealSigner = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( + content: "spoofed sender", + recipientPubkey: recipient.publicKeyHex, + rumorIdentity: claimedSender, + sealSignerIdentity: sealSigner + ) + + expectInvalidEvent { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + } + } + func testAckRoundTripNIP44V2_Delivered() throws { // Identities let sender = try NostrIdentity.generate() @@ -260,4 +296,15 @@ struct NostrProtocolTests { if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } return Data(base64Encoded: str) } + + private func expectInvalidEvent(_ operation: () throws -> Void) { + do { + try operation() + Issue.record("Expected NostrError.invalidEvent") + } catch NostrError.invalidEvent { + return + } catch { + Issue.record("Expected NostrError.invalidEvent, got \(error)") + } + } } diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index ce07f303..fbb46060 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1371,6 +1371,25 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(eoseCount, 0) } + func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async { + let relayURL = "wss://panic-connected.example" + let context = makeContext(permission: .denied) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.manager.isConnected && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + + context.manager.resetForPanicWipe() + + XCTAssertFalse(context.manager.isConnected) + XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false) + XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0) + XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError) + } + func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { let relayURL = "wss://jitter-bounds.example" // Pin the jitter source to the extremes and the midpoint of [0, 1).