From 8428dfed666df2868038146b9a25f86872a689c7 Mon Sep 17 00:00:00 2001 From: ecgang Date: Mon, 6 Jul 2026 10:42:40 -0700 Subject: [PATCH] fix: panic wipe erases persisted last-active conversation (#1064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `panicClearAllData` wiped messages, keychain, identity defaults and location state, but never the new `conversation.lastActive` pointer, so after an emergency wipe the next launch could still restore the just-wiped DM or channel — a privacy-load-bearing gap for an activist-safety wipe. Adds `ConversationStore.clearPersistedLastActive()`, which removes the key through the store's own injected `storage` (never by reaching into `.standard`, and keeping `lastActiveKey` private), and calls it from the panic path right after `conversations.clearAll()`. Test: after a persisted restorable DM, a `clearPersistedLastActive()` makes the next launch fall back to the conversation list. NOT locally compiled (Swift/iOS; CI runs Build iOS + Swift Tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- bitchat/App/ConversationStore.swift | 8 ++++++ bitchat/ViewModels/ChatViewModel.swift | 3 +++ .../ConversationStoreLastActiveTests.swift | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 4a201c90..df467075 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -417,6 +417,14 @@ final class ConversationStore: ObservableObject { } } + /// Erases the persisted last-active conversation. Called from the panic + /// wipe so a restored DM/channel pointer cannot survive an emergency clear. + /// The store owns its injected `storage`, so the key is removed through it + /// (never by reaching into `.standard`), and `lastActiveKey` stays private. + func clearPersistedLastActive() { + storage.removeObject(forKey: lastActiveKey) + } + /// Decides what to present at launch from the value persisted last /// session. Pure aside from reading the init snapshot: performs no /// selection mutation and never writes `activeChannel`, so it cannot race diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 49f38560..15088037 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1177,6 +1177,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // single-writer ConversationStore; the derived `messages` view and // the legacy mirror empty with it) conversations.clearAll() + // Also erase the persisted last-active pointer (#1064) so a wiped + // DM/channel cannot be restored on the next launch. + conversations.clearPersistedLastActive() pendingGeohashSystemMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) diff --git a/bitchatTests/ConversationStoreLastActiveTests.swift b/bitchatTests/ConversationStoreLastActiveTests.swift index cd57869b..faf5adc4 100644 --- a/bitchatTests/ConversationStoreLastActiveTests.swift +++ b/bitchatTests/ConversationStoreLastActiveTests.swift @@ -87,6 +87,31 @@ final class ConversationStoreLastActiveTests: XCTestCase { ) } + func test_clearPersistedLastActiveErasesRestoreRecord() { + // #1064 panic wipe: clearing the persisted last-active pointer means a + // wiped DM/channel cannot be restored on the next launch. + let storage = makeStorage() + + // A DM is persisted and would otherwise restore. + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(peerID) + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .restoredDirectChat(peerID) + ) + + // The panic path erases the pointer through the store's own storage. + session.clearPersistedLastActive() + + // The next launch has nothing to restore and falls back to the list. + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .conversationList + ) + } + func test_firstLaunchPresentsConversationList() { let storage = makeStorage()