Make panic wipe deterministic and device-bound

This commit is contained in:
jack
2026-07-25 21:43:11 +02:00
committed by jack
parent ca18843bb0
commit aa3021c9ca
11 changed files with 317 additions and 69 deletions
@@ -4,6 +4,14 @@ import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
private static let mediaSubdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
@@ -24,6 +32,23 @@ struct BLEIncomingFileStore {
self.dateProvider = dateProvider
}
/// Panic-wipe every managed incoming and outgoing media artifact before
/// returning. Recreating the directory tree keeps later capture/receive
/// paths usable without allowing a detached cleanup task to race them.
func panicWipe() throws {
let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true),
withIntermediateDirectories: true,
attributes: nil
)
}
}
/// Resolves (and creates) an incoming-media directory for callers that
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
@@ -113,15 +138,18 @@ struct BLEIncomingFileStore {
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func rootDirectory() throws -> URL {
try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
+102 -12
View File
@@ -11,6 +11,13 @@ import BitFoundation
import Foundation
import Security
enum KeychainInstallLifecycleAction: Equatable {
case markerPresent
case bootstrapMarker
case clearStaleKeys
case retryLater
}
final class KeychainManager: KeychainManagerProtocol {
/// Default keychain for components that construct their own rather than
/// having one injected. Under test this is an in-memory keychain: the
@@ -41,27 +48,80 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
private static let installMarkerAccount = "install_lifecycle_marker"
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
// device locked (identity-cache saves failed with -25308 throughout
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
// restoration must be able to read the noise keys before the user
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
// unlocks. ThisDeviceOnly prevents private identities and group keys from
// migrating through device backups onto a second device.
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
init() {
#if os(iOS)
reconcileInstallLifecycle()
migrateAccessibilityIfNeeded()
#endif
}
static func installLifecycleAction(
containerKnowsMarker: Bool,
markerRead: KeychainReadResult
) -> KeychainInstallLifecycleAction {
switch markerRead {
case .success:
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
case .itemNotFound:
return .bootstrapMarker
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
return .retryLater
}
}
#if os(iOS)
/// Keychain items can survive app removal while the app container and its
/// UserDefaults do not. The first version carrying this marker bootstraps
/// without deleting existing users' identities. On a later reinstall, a
/// surviving keychain marker plus a missing defaults marker proves the app
/// container was replaced, so stale secrets are removed before use.
private func reconcileInstallLifecycle() {
let defaults = UserDefaults.standard
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
switch Self.installLifecycleAction(
containerKnowsMarker: containerKnowsMarker,
markerRead: markerRead
) {
case .markerPresent:
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
case .bootstrapMarker:
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
}
case .clearStaleKeys:
_ = deleteAllKeychainData()
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
case .retryLater:
// Do not guess that a temporarily unreadable marker is absent.
// Leaving the defaults flag unchanged lets a later construction
// retry once protected keychain data is available.
break
}
}
/// One-time upgrade of items created under WhenUnlocked. New saves get
/// the right class on their own (saves are delete-then-add), but the
/// long-lived identity keys are written once and would otherwise stay
/// unreadable while the device is locked.
private func migrateAccessibilityIfNeeded() {
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
default:
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
@@ -491,6 +551,15 @@ final class KeychainManager: KeychainManagerProtocol {
}
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
#if os(iOS)
// The non-secret marker is intentionally recreated after a panic so a
// later uninstall/reinstall can still be distinguished from an in-place
// upgrade. It never restores any wiped identity or relationship data.
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
UserDefaults.standard.set(true, forKey: Self.installMarkerDefaultsKey)
}
#endif
return totalDeleted > 0
}
@@ -526,18 +595,39 @@ final class KeychainManager: KeychainManagerProtocol {
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
var query: [String: Any] = [
let primaryKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecValueData as String: data
kSecAttrAccount as String: key
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
var addQuery = primaryKeyQuery
addQuery.merge([
kSecValueData as String: data,
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
kSecAttrSynchronizable as String: false
]) { _, new in new }
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
// Delete by the item's primary key only. Value/accessibility fields
// are add attributes, not valid selectors for replacing an existing
// item; including them can leave the old item in place and make the
// subsequent add fail as a duplicate.
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(deleteStatus)),
context: "Unable to replace custom-service keychain item",
category: .keychain
)
return
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(addStatus)),
context: "Unable to save custom-service keychain item",
category: .keychain
)
}
}
/// Load data from a custom service
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
assemblies.values.contains { $0.messageID == message.id }
}
/// Stop every live file handle/player before the panic media directory is
/// removed. This prevents an in-flight assembly from continuing to write
/// through an unlinked file after the wipe returns.
func resetForPanic() {
for assembly in Array(assemblies.values) {
cancelAssembly(assembly)
}
for player in drainingPlayers.values {
player.stop()
}
drainingPlayers.removeAll(keepingCapacity: false)
finishedBursts.removeAll(keepingCapacity: false)
updatePublicTalkerIndicator()
}
/// Called for every inbound private message: when it is the finalized
/// voice note of a burst we assembled (matched by burst ID in the file
/// name), swap it into the existing live bubble and report `true` so the
@@ -78,6 +78,7 @@ final class ChatMediaTransferCoordinator {
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
private var preparationGeneration: UInt64 = 0
init(context: any ChatMediaTransferContext) {
self.context = context
@@ -98,13 +99,14 @@ final class ChatMediaTransferCoordinator {
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
let generation = preparationGeneration
Task.detached(priority: .userInitiated) { [weak self] in
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
@@ -116,13 +118,13 @@ final class ChatMediaTransferCoordinator {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
}
}
@@ -132,11 +134,15 @@ final class ChatMediaTransferCoordinator {
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
let generation = preparationGeneration
Task.detached { [weak self] in
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else {
try? FileManager.default.removeItem(at: processedURL)
return
}
self.sendImage(from: processedURL)
}
} catch {
@@ -147,11 +153,15 @@ final class ChatMediaTransferCoordinator {
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
let generation = preparationGeneration
Task.detached { [weak self] in
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else {
try? FileManager.default.removeItem(at: processedURL)
return
}
self.sendImage(from: processedURL)
}
} catch {
@@ -170,6 +180,7 @@ final class ChatMediaTransferCoordinator {
}
let targetPeer = context.selectedPrivateChatPeer
let generation = preparationGeneration
do {
try ImageUtils.validateImageSource(at: sourceURL)
@@ -184,7 +195,10 @@ final class ChatMediaTransferCoordinator {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else {
try? FileManager.default.removeItem(at: prepared.outputURL)
return
}
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer
@@ -201,13 +215,13 @@ final class ChatMediaTransferCoordinator {
} catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.context.addSystemMessage("Image is too large to send.")
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
@@ -341,6 +355,19 @@ final class ChatMediaTransferCoordinator {
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
/// Invalidates detached preparation work and cancels every transfer that
/// reached the transport. Stale tasks check the generation before they
/// can recreate a message or send after a panic wipe.
func resetForPanic() {
preparationGeneration &+= 1
let transferIDs = Set(transferIdToMessageIDs.keys)
transferIdToMessageIDs.removeAll(keepingCapacity: false)
messageIDToTransferId.removeAll(keepingCapacity: false)
for transferID in transferIDs {
context.cancelTransfer(transferID)
}
}
}
private extension ChatMediaTransferCoordinator {
+35 -38
View File
@@ -177,7 +177,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
context: self,
sweepsOnInit: !TestEnvironment.isRunningTests
)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
@@ -292,6 +295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var nostrRelayManager: NostrRelayManager?
private let userDefaults = UserDefaults.standard
let keychain: KeychainManagerProtocol
private let panicMediaWipe: () throws -> Void
/// Private group membership: keys in the keychain, metadata on disk.
let groupStore: GroupStore
private let nicknameKey = "bitchat.nickname"
@@ -799,7 +803,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationManager: LocationChannelManager = .shared,
readReceiptsDefaults: UserDefaults? = nil,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
sfMetrics: StoreAndForwardMetrics? = nil,
panicMediaWipe: (() throws -> Void)? = nil
) {
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -814,6 +819,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
self.keychain = keychain
self.panicMediaWipe = panicMediaWipe ?? {
// Unit tests share the developer's real Application Support
// directory. Production uses the managed store; tests that need
// to exercise the wipe inject a temporary-directory closure.
guard !TestEnvironment.isRunningTests else { return }
try BLEIncomingFileStore().panicWipe()
}
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
@@ -1156,6 +1168,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func panicClearAllData() {
// Messages are processed immediately - nothing to flush
// Invalidate detached media preparation and close live capture file
// handles before clearing state or removing the media directory.
mediaTransferCoordinator.resetForPanic()
liveVoiceCoordinator.resetForPanic()
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
@@ -1279,44 +1296,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
}
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// utility-priority wipe fires at a nondeterministic time
// deleting media that concurrently running tests (e.g. the
// sendImage flow) just wrote there, and the developer's real
// app data with it.
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
// Delete the entire files directory and recreate it
if FileManager.default.fileExists(atPath: filesDir.path) {
try FileManager.default.removeItem(at: filesDir)
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
}
// Recreate empty directory structure
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
} catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
}
// BCH-01-013: Clear iOS app switcher snapshots
// These are stored in Library/Caches/Snapshots/<bundle_id>/
#if os(iOS)
Self.clearAppSwitcherSnapshots()
#endif
// The wipe must finish before this security action returns. A detached
// task could otherwise lose a race with a new capture or app exit and
// leave pre-panic media behind.
do {
try panicMediaWipe()
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
} catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
}
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
// the host user's real cache tree just as the default media wipe does.
#if os(iOS)
if !TestEnvironment.isRunningTests {
Self.clearAppSwitcherSnapshots()
}
#endif
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed