Review fixes: unread flag + favorites-aware naming for the warning

- 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 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-11 10:09:57 +02:00
parent 0a288869d6
commit c58ad9af10
2 changed files with 51 additions and 1 deletions

View File

@ -24,6 +24,9 @@ protocol ChatPeerIdentityContext: AnyObject {
var unreadPrivateMessages: Set<PeerID> { 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()
}
}

View File

@ -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