From c58ad9af1090463fdb4bb5ee9b6d154b7d16fc7e Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 11 Aug 2026 10:09:57 +0200 Subject: [PATCH] Review fixes: unread flag + favorites-aware naming for the warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P1: a background identity change appended the warning without marking the chat unread — a security event nobody was looking at stayed invisible until the conversation was manually opened. It now sets the unread flag unless the chat is open. - Codex P2: offline key rotation is the common case here, and resolveNickname falls back to an anon prefix precisely then (no mesh nickname, no social identity for the unverified new fingerprint). The persisted favorite relationship's nickname is preferred. Both pinned by tests. Co-Authored-By: Claude Fable 5 --- .../ChatPeerIdentityCoordinator.swift | 19 ++++++++++- ...tPeerIdentityCoordinatorContextTests.swift | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 2f6f7270..b67fae5f 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -24,6 +24,9 @@ protocol ChatPeerIdentityContext: AnyObject { var unreadPrivateMessages: Set { get } /// Clears the peer's unread flag (single-writer store intent). func markPrivateChatRead(_ peerID: PeerID) + /// Sets the peer's unread flag (shared requirement with the inbound DM + /// paths; witness on `ChatViewModel`). + func markPrivateChatUnread(_ peerID: PeerID) /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat /// (dedup by ID, order preserved, unread carried, old chat removed). func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) @@ -621,12 +624,20 @@ extension ChatPeerIdentityCoordinator { // inherit the thread's earned trust under the same nickname. Only // warn when there is a conversation to protect. if wasSelected || !context.privateMessages(for: newPeerID).isEmpty { + // Offline key rotation is the common case here (a favorite came + // back with new keys), and resolveNickname has no mesh nickname + // and no social identity for a fingerprint nobody verified yet — + // the persisted favorite relationship still knows who this is. + let favoriteNickname = newPeerID.noiseKey + .flatMap { context.favoriteRelationship(forNoiseKey: $0)?.peerNickname } + .flatMap { $0.isEmpty ? nil : $0 } + let displayName = favoriteNickname ?? resolveNickname(for: newPeerID) let notice = BitchatMessage( sender: "system", content: String( format: String(localized: "system.identity.key_changed", defaultValue: "%@'s identity key changed — this can mean a new device or a reset. earlier verification no longer applies; verify them again before trusting this chat.", comment: "Private-chat system warning after a peer's Noise identity key changed; placeholder is the peer's name"), locale: .current, - resolveNickname(for: newPeerID) + displayName ), timestamp: Date(), isRelay: false, @@ -636,6 +647,12 @@ extension ChatPeerIdentityCoordinator { senderPeerID: context.myPeerID ) context.appendPrivateMessage(notice, to: newPeerID) + // A security event nobody is looking at must not stay silent: + // surface it through the unread indicator unless the chat is + // open right now. + if !wasSelected { + context.markPrivateChatUnread(newPeerID) + } context.notifyUIChanged() } } diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index d14f766a..0def7e2c 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -46,6 +46,10 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { unreadPrivateMessages.remove(peerID) } + func markPrivateChatUnread(_ peerID: PeerID) { + unreadPrivateMessages.insert(peerID) + } + @discardableResult func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { var chat = privateChats[peerID] ?? [] @@ -390,6 +394,35 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(notice?.content.contains("identity key changed") == true) #expect(notice?.content.contains("alice") == true) #expect(context.privateChats[oldPeerID] == nil) + // A background security event must surface via the unread indicator. + #expect(context.unreadPrivateMessages.contains(newPeerID)) + } + + @Test @MainActor + func migrateNoiseKeyUpdate_namesOfflineFavoritesFromTheRelationship() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let oldPeerID = PeerID(str: "5555555555555555") + let newKey = Data(repeating: 0xCD, count: 32) + let newPeerID = PeerID(hexData: newKey) + // Offline key rotation: no mesh nickname, no social identity for the + // unverified new fingerprint — only the favorite relationship knows + // who this is. + context.favoriteRelationshipsByNoiseKey[newKey] = FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: newKey, + peerNostrPublicKey: nil, + peerNickname: "carol", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) + context.privateChats[oldPeerID] = [makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 1))] + + coordinator.migrateNoiseKeyUpdate(oldPeerID: oldPeerID, newPeerID: newPeerID) + + let notice = (context.privateChats[newPeerID] ?? []).last + #expect(notice?.content.contains("carol") == true) } @Test @MainActor