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/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index a5d7b3cc..fe63f871 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -151,38 +151,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) - // Debouncing for keychain saves - private var saveTimer: Timer? - 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 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 +241,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } + /// 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 - - // 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() - } + performSave() } - + + /// 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,10 +274,14 @@ 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 / 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?.invalidate() performSave() } diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 018efef5..be130f10 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -323,6 +323,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..f68caa8d 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,10 +105,63 @@ 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) } + #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, @@ -195,10 +257,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/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 91cfaec4..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 @@ -298,6 +299,60 @@ 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() + markRelaySocketsClosed(resetState: true) + 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() + } + + 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]) { @@ -1170,6 +1225,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/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..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. @@ -68,14 +70,39 @@ 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). + // + // 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 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 + } + + // 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 dfea2491..26a1d175 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -149,6 +149,13 @@ 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), 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() @@ -247,16 +254,10 @@ final class BLEService: NSObject { #endif } - // 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 + // Single maintenance timer for all periodic tasks (dispatch-based for + // determinism). Only run it when real Bluetooth managers exist. + meshBackgroundEnabled = initializeBluetoothManagers + startMaintenanceTimer() // Publish initial empty state requestPeerDataPublish() @@ -286,7 +287,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 } @@ -449,7 +455,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 meshBackgroundEnabled, 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( @@ -1620,7 +1648,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) @@ -1628,6 +1656,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 { @@ -1636,7 +1665,7 @@ extension BLEService { nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, noisePublicKey: packet.senderID, - signingPublicKey: nil, + signingPublicKey: signingPublicKey, isVerifiedNickname: true, lastSeen: Date() )) @@ -3267,6 +3296,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/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/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 } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a7d8300d..c7771204 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() @@ -1156,13 +1161,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 @@ -1178,15 +1195,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 - nostrRelayManager = NostrRelayManager() - 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 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 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/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 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/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..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 @@ -59,13 +65,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) @@ -133,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) @@ -154,6 +221,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 +255,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, @@ -213,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) ) 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) diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 8184564a..fbb46060 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1328,6 +1328,68 @@ 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_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). 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