diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 462185a1..0b812f2d 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -97,7 +97,7 @@ class NostrRelayManager: ObservableObject { ) { messageHandlers[id] = handler - SecureLogger.log("📡 Setting up subscription '\(id)' with filter - kinds: \(filter.kinds ?? []), since: \(filter.since ?? 0)", + SecureLogger.log("📡 Subscribing to Nostr filter id=\(id) kinds=\(filter.kinds ?? []) since=\(filter.since ?? 0)", category: SecureLogger.session, level: .debug) let req = NostrRequest.subscribe(id: id, filters: [filter]) diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index aa82a7d6..bca48112 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -176,6 +176,18 @@ class FavoritesPersistenceService: ObservableObject { func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? { favorites[peerNoisePublicKey] } + + /// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey) + /// Falls back to scanning favorites and matching on derived peer ID. + func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? { + // Quick sanity: peerID should be 16 hex chars (8 bytes) + guard peerID.count == 16 else { return nil } + for (pubkey, rel) in favorites { + let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey) + if derived == peerID { return rel } + } + return nil + } /// Update Nostr public key for a peer func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) { diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 10912041..1bf2bd4e 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -5,31 +5,72 @@ import Foundation final class MessageRouter { private let mesh: Transport private let nostr: NostrTransport + private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages init(mesh: Transport, nostr: NostrTransport) { self.mesh = mesh self.nostr = nostr self.nostr.senderPeerID = mesh.myPeerID + + // Observe favorites changes to learn Nostr mapping and flush queued messages + NotificationCenter.default.addObserver( + forName: .favoriteStatusChanged, + object: nil, + queue: .main + ) { [weak self] note in + guard let self = self else { return } + if let data = note.userInfo?["peerPublicKey"] as? Data { + let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data) + Task { @MainActor in + self.flushOutbox(for: peerID) + } + } + // Handle key updates + if let newKey = note.userInfo?["peerPublicKey"] as? Data, + let _ = note.userInfo?["isKeyUpdate"] as? Bool { + let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey) + Task { @MainActor in + self.flushOutbox(for: peerID) + } + } + } } func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { - if mesh.isPeerConnected(peerID) { + let hasMesh = mesh.isPeerConnected(peerID) + let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID) + if hasMesh && hasEstablished { + SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - } else { + } else if canSendViaNostr(peerID: peerID) { + SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + } else { + // Queue for later (when mesh connects or Nostr mapping appears) + if outbox[peerID] == nil { outbox[peerID] = [] } + outbox[peerID]?.append((content, recipientNickname, messageID)) + SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { - if mesh.isPeerConnected(peerID) { + // Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam + if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { + SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", + category: SecureLogger.session, level: .debug) mesh.sendReadReceipt(receipt, to: peerID) } else { + SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", + category: SecureLogger.session, level: .debug) nostr.sendReadReceipt(receipt, to: peerID) } } func sendDeliveryAck(_ messageID: String, to peerID: String) { - if mesh.isPeerConnected(peerID) { + if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { mesh.sendDeliveryAck(for: messageID, to: peerID) } else { nostr.sendDeliveryAck(for: messageID, to: peerID) @@ -43,4 +84,40 @@ final class MessageRouter { nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } } + + // MARK: - Outbox Management + private func canSendViaNostr(peerID: String) -> Bool { + guard let noiseKey = Data(hexString: peerID) else { return false } + if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + fav.peerNostrPublicKey != nil { + return true + } + return false + } + + func flushOutbox(for peerID: String) { + guard let queued = outbox[peerID], !queued.isEmpty else { return } + SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", + category: SecureLogger.session, level: .debug) + // Prefer mesh if connected; else try Nostr if mapping exists + for (content, nickname, messageID) in queued { + if mesh.isPeerConnected(peerID) { + SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) + mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) + } else if canSendViaNostr(peerID: peerID) { + SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) + nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) + } else { + continue + } + } + // Remove all flushed items (remaining ones, if any, will be re-queued on next call) + outbox[peerID]?.removeAll() + } + + func flushAllOutbox() { + for key in outbox.keys { flushOutbox(for: key) } + } } diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 2e758a1c..5b7ba90e 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -13,6 +13,15 @@ final class NostrTransport: Transport { // Provide BLE short peer ID for BitChat embedding var senderPeerID: String = "" + // Throttle READ receipts to avoid relay rate limits + private struct QueuedRead { + let receipt: ReadReceipt + let peerID: String + } + private var readQueue: [QueuedRead] = [] + private var isSendingReadAcks = false + private let readAckInterval: TimeInterval = 0.35 // ~3 per second + var myPeerID: String { senderPeerID } var myNickname: String { "" } func setNickname(_ nickname: String) { /* not used for Nostr */ } @@ -34,37 +43,137 @@ final class NostrTransport: Transport { func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { Task { @MainActor in - guard let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - let recipientNostrPubkey = fav.peerNostrPublicKey else { return } + // Resolve favorite by full noise key or by short peerID fallback + var recipientNostrPubkey: String? + if let noiseKey = Data(hexString: peerID), + let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + recipientNostrPubkey = fav.peerNostrPublicKey + } + if recipientNostrPubkey == nil, peerID.count == 16 { + recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey + } + guard let recipientNpub = recipientNostrPubkey else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } - guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID), - let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return } + SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) + // Convert recipient npub -> hex (x-only) + let recipientHex: String + do { + let (hrp, data) = try Bech32.decode(recipientNpub) + guard hrp == "npub" else { + SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error) + return + } + recipientHex = data.hexEncodedString() + } catch { + SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error) + return + } + guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { + SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error) + return + } + guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { + SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error) + return + } + SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", + category: SecureLogger.session, level: .debug) NostrRelayManager.shared.sendEvent(event) } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { + // Enqueue and process with throttling to avoid relay rate limits + readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) + processReadQueueIfNeeded() + } + + private func processReadQueueIfNeeded() { + guard !isSendingReadAcks else { return } + guard !readQueue.isEmpty else { return } + isSendingReadAcks = true + sendNextReadAck() + } + + private func sendNextReadAck() { + guard !readQueue.isEmpty else { isSendingReadAcks = false; return } + let item = readQueue.removeFirst() Task { @MainActor in - guard let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - let recipientNostrPubkey = fav.peerNostrPublicKey else { return } - guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } - guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID), - let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return } + var recipientNostrPubkey: String? + if let noiseKey = Data(hexString: item.peerID), + let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + recipientNostrPubkey = fav.peerNostrPublicKey + } + if recipientNostrPubkey == nil, item.peerID.count == 16 { + recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey + } + guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return } + guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } + SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", + category: SecureLogger.session, level: .debug) + // Convert recipient npub -> hex + let recipientHex: String + do { + let (hrp, data) = try Bech32.decode(recipientNpub) + guard hrp == "npub" else { scheduleNextReadAck(); return } + recipientHex = data.hexEncodedString() + } catch { scheduleNextReadAck(); return } + guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { + SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error) + scheduleNextReadAck(); return + } + guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { + SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error) + scheduleNextReadAck(); return + } + SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", + category: SecureLogger.session, level: .debug) NostrRelayManager.shared.sendEvent(event) + scheduleNextReadAck() + } + } + + private func scheduleNextReadAck() { + DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in + guard let self = self else { return } + self.isSendingReadAcks = false + self.processReadQueueIfNeeded() } } func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { Task { @MainActor in - guard let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - let recipientNostrPubkey = fav.peerNostrPublicKey else { return } + var recipientNostrPubkey: String? + if let noiseKey = Data(hexString: peerID), + let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + recipientNostrPubkey = fav.peerNostrPublicKey + } + if recipientNostrPubkey == nil, peerID.count == 16 { + recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey + } + guard let recipientNpub = recipientNostrPubkey else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" - guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID), - let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return } + SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", + category: SecureLogger.session, level: .debug) + // Convert recipient npub -> hex + let recipientHex: String + do { + let (hrp, data) = try Bech32.decode(recipientNpub) + guard hrp == "npub" else { return } + recipientHex = data.hexEncodedString() + } catch { return } + guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { + SecureLogger.log("NostrTransport: failed to embed favorite notification", category: SecureLogger.session, level: .error) + return + } + guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { + SecureLogger.log("NostrTransport: failed to build Nostr event for favorite notification", category: SecureLogger.session, level: .error) + return + } + SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", + category: SecureLogger.session, level: .debug) NostrRelayManager.shared.sendEvent(event) } } @@ -72,12 +181,34 @@ final class NostrTransport: Transport { func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendDeliveryAck(for messageID: String, to peerID: String) { Task { @MainActor in - guard let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - let recipientNostrPubkey = fav.peerNostrPublicKey else { return } + var recipientNostrPubkey: String? + if let noiseKey = Data(hexString: peerID), + let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + recipientNostrPubkey = fav.peerNostrPublicKey + } + if recipientNostrPubkey == nil, peerID.count == 16 { + recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey + } + guard let recipientNpub = recipientNostrPubkey else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } - guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID), - let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return } + SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", + category: SecureLogger.session, level: .info) + let recipientHex: String + do { + let (hrp, data) = try Bech32.decode(recipientNpub) + guard hrp == "npub" else { return } + recipientHex = data.hexEncodedString() + } catch { return } + guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { + SecureLogger.log("NostrTransport: failed to embed DELIVERED ack", category: SecureLogger.session, level: .error) + return + } + guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { + SecureLogger.log("NostrTransport: failed to build Nostr event for DELIVERED ack", category: SecureLogger.session, level: .error) + return + } + SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", + category: SecureLogger.session, level: .info) NostrRelayManager.shared.sendEvent(event) } } diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 24116ce0..29fe5308 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -19,6 +19,8 @@ class PrivateChatManager: ObservableObject { var sentReadReceipts: Set = [] // Made accessible for ChatViewModel weak var meshService: Transport? + // Route acks/receipts via MessageRouter (chooses mesh or Nostr) + weak var messageRouter: MessageRouter? init(meshService: Transport? = nil) { self.meshService = meshService @@ -91,8 +93,15 @@ class PrivateChatManager: ObservableObject { privateChats[senderPeerID] = [] } - // Add message - privateChats[senderPeerID]?.append(message) + // Deduplicate by ID: replace existing message if present, else append + if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) { + privateChats[senderPeerID]?[idx] = message + } else { + privateChats[senderPeerID]?.append(message) + } + + // Sanitize chat to avoid duplicate IDs and sort by timestamp + sanitizeChat(for: senderPeerID) // Mark as unread if not in this chat if selectedPeer != senderPeerID { @@ -109,6 +118,25 @@ class PrivateChatManager: ObservableObject { sendReadReceipt(for: message) } } + + /// Remove duplicate messages by ID and keep chronological order + func sanitizeChat(for peerID: String) { + guard let arr = privateChats[peerID] else { return } + var seen = Set() + var deduped: [BitchatMessage] = [] + for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { + if !seen.contains(msg.id) { + seen.insert(msg.id) + deduped.append(msg) + } else { + // Replace previous with the latest occurrence (which is later in sort) + if let index = deduped.firstIndex(where: { $0.id == msg.id }) { + deduped[index] = msg + } + } + } + privateChats[peerID] = deduped + } /// Mark messages from a peer as read func markAsRead(from peerID: String) { @@ -183,7 +211,16 @@ class PrivateChatManager: ObservableObject { readerNickname: meshService?.myNickname ?? "" ) - // Send through mesh service's read receipt method - meshService?.sendReadReceipt(receipt, to: senderPeerID) + // Route via MessageRouter to avoid handshakeRequired spam when session isn't established + if let router = messageRouter { + SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", + category: SecureLogger.session, level: .debug) + Task { @MainActor in + router.sendReadReceipt(receipt, to: senderPeerID) + } + } else { + // Fallback: preserve previous behavior + meshService?.sendReadReceipt(receipt, to: senderPeerID) + } } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 5016b238..184bd3b4 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -168,6 +168,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return nil } + + // Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex) + @MainActor + func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> String? { + // Check known peers for a noise key match + if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) { + return match.id + } + // Also search cache mapping + if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) { + return pair.key + } + return nil + } private var peerIndex: [String: BitchatPeer] = [:] // MARK: - Autocomplete Properties @@ -277,6 +291,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.unifiedPeerService = UnifiedPeerService(meshService: meshService) let nostrTransport = NostrTransport() self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) + // Route receipts from PrivateChatManager through MessageRouter + self.privateChatManager.messageRouter = self.messageRouter self.autocompleteService = AutocompleteService() // Wire up dependencies @@ -312,6 +328,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Initialize Nostr services Task { @MainActor in nostrRelayManager = NostrRelayManager.shared + SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug) nostrRelayManager?.connect() // Small delay to ensure read receipts are fully loaded @@ -320,6 +337,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Set up Nostr message handling directly setupNostrMessageHandling() + + // Attempt to flush any queued outbox after Nostr comes online + messageRouter.flushAllOutbox() // End startup phase after 2 seconds // During startup phase, we: @@ -863,6 +883,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Send via appropriate transport (BLE if connected, else Nostr when possible) if isConnected || (isMutualFavorite && hasNostrKey) { messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) + // Optimistically mark as sent for both transports; delivery/read will update subsequently + if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[idx].deliveryStatus = .sent + } } else { // Update delivery status to failed if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -1469,27 +1493,51 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] { - var allMessages: [BitchatMessage] = [] - - // Get messages stored under the ephemeral peer ID + var combined: [BitchatMessage] = [] + + // Gather messages under the ephemeral peer ID if let ephemeralMessages = privateChats[peerID] { - allMessages.append(contentsOf: ephemeralMessages) + combined.append(contentsOf: ephemeralMessages) } - - // Also check if we have messages stored under the stable Noise key hex - // This happens for Nostr messages which use stable keys to persist across reconnections + + // Also include messages stored under the stable Noise key (Nostr path) if let peer = unifiedPeerService.getPeer(by: peerID) { let noiseKeyHex = peer.noisePublicKey.hexEncodedString() - - // Only add if it's different from the ephemeral peer ID (to avoid duplicates) - if noiseKeyHex != peerID, - let nostrMessages = privateChats[noiseKeyHex] { - allMessages.append(contentsOf: nostrMessages) + if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] { + combined.append(contentsOf: nostrMessages) } } - - // Sort by timestamp to maintain chronological order - return allMessages.sorted { $0.timestamp < $1.timestamp } + + // De-duplicate by message ID: keep the item with the most advanced delivery status. + // This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures + // we show the row whose status has already progressed to delivered/read. + func statusRank(_ s: DeliveryStatus?) -> Int { + guard let s = s else { return 0 } + switch s { + case .failed: return 1 + case .sending: return 2 + case .sent: return 3 + case .partiallyDelivered: return 4 + case .delivered: return 5 + case .read: return 6 + } + } + + var bestByID: [String: BitchatMessage] = [:] + for msg in combined { + if let existing = bestByID[msg.id] { + let lhs = statusRank(existing.deliveryStatus) + let rhs = statusRank(msg.deliveryStatus) + if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) { + bestByID[msg.id] = msg + } + } else { + bestByID[msg.id] = msg + } + } + + // Return chronologically sorted, de-duplicated list + return bestByID.values.sorted { $0.timestamp < $1.timestamp } } @MainActor @@ -2395,7 +2443,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: String) { - SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .info) + SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .debug) // Handle all main actor work async Task { @MainActor in @@ -2424,13 +2472,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let noiseKeyHex = peer.noisePublicKey.hexEncodedString() shortIDToNoiseKey[peerID] = noiseKeyHex } + + // Flush any queued messages for this peer via router + messageRouter.flushOutbox(for: peerID) } // Connection messages removed to reduce chat noise } func didDisconnectFromPeer(_ peerID: String) { - SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .info) + SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .debug) // Remove ephemeral session from identity manager SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) @@ -2902,7 +2953,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { ) if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] } - privateChats[targetPeerID]?.append(message) + if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == messageId }) { + privateChats[targetPeerID]?[idx] = message + } else { + privateChats[targetPeerID]?.append(message) + } + // Sanitize to avoid duplicate IDs + privateChatManager.sanitizeChat(for: targetPeerID) trimPrivateChatMessagesIfNeeded(for: targetPeerID) // Mirror to ephemeral if applicable @@ -2910,14 +2967,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id, ephemeralPeerID != targetPeerID { if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] } - if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) { + if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == messageId }) { + privateChats[ephemeralPeerID]?[idx] = message + } else { privateChats[ephemeralPeerID]?.append(message) trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID) } + privateChatManager.sanitizeChat(for: ephemeralPeerID) } // Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key if !wasReadBefore, let key = actualSenderNoiseKey { + SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) } @@ -2931,6 +2992,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey { let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname) + SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) sentReadReceipts.insert(messageId) } @@ -3409,10 +3471,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Add or update messages for the new peer ID if var existingMessages = privateChats[peerID] { - // Merge with existing messages, avoiding duplicates - let existingIds = Set(existingMessages.map { $0.id }) - for msg in migratedMessages where !existingIds.contains(msg.id) { - existingMessages.append(msg) + // Merge with existing messages, replace-by-id semantics + for msg in migratedMessages { + if let i = existingMessages.firstIndex(where: { $0.id == msg.id }) { + existingMessages[i] = msg + } else { + existingMessages.append(msg) + } } existingMessages.sort { $0.timestamp < $1.timestamp } privateChats[peerID] = existingMessages @@ -3421,6 +3486,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { privateChats[peerID] = migratedMessages } trimPrivateChatMessagesIfNeeded(for: peerID) + privateChatManager.sanitizeChat(for: peerID) // Update selectedPrivateChatPeer if it was pointing to an old ID if needsSelectedUpdate { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 0a34f955..5505dbdf 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -956,6 +956,13 @@ struct ContentView: View { if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) { return stable } + } else if privatePeerID.count == 64 { + // If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID + if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { + if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) { + return short + } + } } return privatePeerID }() diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md new file mode 100644 index 00000000..cba3aa2e --- /dev/null +++ b/docs/privacy-assessment.md @@ -0,0 +1,64 @@ +BitChat Privacy Assessment +========================== + +Scope +- Mesh transport (BLE) behavior and metadata minimization +- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted) +- Read receipts and delivery acknowledgments +- Logging/telemetry posture and controls + +Summary +- No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps. +- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts. +- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads. +- UI and storage remain ephemeral; message content is not persisted to disk. Minimal state (e.g., read-receipt IDs) is stored for UX and is bounded/cleaned. +- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed. + +BLE Privacy Considerations +- Announce content: Unchanged — nickname + Noise public key only. +- Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity. +- Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses. +- Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation. +- Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters. +- RSSI gating: The acceptance threshold adapts to nearby density (approx. -95 to -80 dBm) to reduce long-distance observations in dense areas and improve connectivity in sparse ones. +- Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals). +- GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT. + +Mesh Routing and Multi-hop Limits +- Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods. +- TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays. + +Nostr Private Messaging Fallback +- Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites). +- Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext. +- Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation. +- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata. +- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available. + +Read Receipts and Delivery Acks +- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists. +- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs. +- Coalescing (optional future): When entering a chat with many unread, only send READ for the latest message, marking older as read locally to reduce metadata. + +Data Retention and State +- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed. +- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory. +- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action. +- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports. + +Logging and Telemetry +- Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers. +- Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`. +- Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug. +- OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable. + +Residual Risks and Mitigations +- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization. +- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts. +- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range. + +Recommendations (Next) +- Add optional coalesced READ behavior for large backlogs. +- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts. +- Allow user-configurable Nostr relay set with a “private relays only” toggle. +