fix: panic wipe erases persisted last-active conversation (#1064)

`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) <noreply@anthropic.com>
This commit is contained in:
ecgang 2026-07-06 10:42:40 -07:00
parent 87f1561f34
commit 8428dfed66
3 changed files with 36 additions and 0 deletions

View File

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

View File

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

View File

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