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 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..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 9959f179..2ee9cd13 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -135,6 +135,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() @@ -233,16 +240,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() @@ -272,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 } @@ -435,7 +441,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( @@ -1602,7 +1630,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) @@ -1610,6 +1638,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 { @@ -1618,7 +1647,7 @@ extension BLEService { nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, noisePublicKey: packet.senderID, - signingPublicKey: nil, + signingPublicKey: signingPublicKey, isVerifiedNickname: true, lastSeen: Date() )) @@ -3110,6 +3139,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/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 00f10ae3..a38dc529 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() @@ -1153,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 @@ -1175,15 +1192,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).