mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:45:20 +00:00
Make panic wipe deterministic and device-bound
This commit is contained in:
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
|||||||
|
|
||||||
1. **Identity and cryptographic keys**
|
1. **Identity and cryptographic keys**
|
||||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||||
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||||
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
||||||
|
|
||||||
2. **Nickname, preferences, and relationships**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
|||||||
|
|
||||||
## Your Controls
|
## Your Controls
|
||||||
|
|
||||||
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||||
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||||
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||||
- **No account:** The project operates no account record for you to request or export.
|
- **No account:** The project operates no account record for you to request or export.
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import Foundation
|
|||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct BLEIncomingFileStore {
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
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
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
@@ -24,6 +32,23 @@ struct BLEIncomingFileStore {
|
|||||||
self.dateProvider = dateProvider
|
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
|
/// Resolves (and creates) an incoming-media directory for callers that
|
||||||
/// write progressively instead of via `save` (live voice captures).
|
/// write progressively instead of via `save` (live voice captures).
|
||||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||||
@@ -113,15 +138,18 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
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,
|
for: .applicationSupportDirectory,
|
||||||
in: .userDomainMask,
|
in: .userDomainMask,
|
||||||
appropriateFor: nil,
|
appropriateFor: nil,
|
||||||
create: true
|
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 {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import BitFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
|
enum KeychainInstallLifecycleAction: Equatable {
|
||||||
|
case markerPresent
|
||||||
|
case bootstrapMarker
|
||||||
|
case clearStaleKeys
|
||||||
|
case retryLater
|
||||||
|
}
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
/// Default keychain for components that construct their own rather than
|
/// Default keychain for components that construct their own rather than
|
||||||
/// having one injected. Under test this is an in-memory keychain: the
|
/// 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
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(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
|
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||||
// device locked (identity-cache saves failed with -25308 throughout
|
// device locked (identity-cache saves failed with -25308 throughout
|
||||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||||
// restoration must be able to read the noise keys before the user
|
// restoration must be able to read the noise keys before the user
|
||||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
// migrating through device backups onto a second device.
|
||||||
|
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
reconcileInstallLifecycle()
|
||||||
migrateAccessibilityIfNeeded()
|
migrateAccessibilityIfNeeded()
|
||||||
#endif
|
#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)
|
#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
|
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||||
/// the right class on their own (saves are delete-then-add), but the
|
/// the right class on their own (saves are delete-then-add), but the
|
||||||
/// long-lived identity keys are written once and would otherwise stay
|
/// long-lived identity keys are written once and would otherwise stay
|
||||||
/// unreadable while the device is locked.
|
/// unreadable while the device is locked.
|
||||||
private func migrateAccessibilityIfNeeded() {
|
private func migrateAccessibilityIfNeeded() {
|
||||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||||
|
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
case errSecSuccess, errSecItemNotFound:
|
case errSecSuccess, errSecItemNotFound:
|
||||||
// Nothing to migrate on a fresh install; both are terminal.
|
// Nothing to migrate on a fresh install; both are terminal.
|
||||||
UserDefaults.standard.set(true, forKey: flag)
|
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:
|
default:
|
||||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||||
// leave the flag unset so the next launch retries.
|
// leave the flag unset so the next launch retries.
|
||||||
@@ -492,6 +552,15 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
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
|
return totalDeleted > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,18 +595,39 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||||
var query: [String: Any] = [
|
let primaryKeyQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
]
|
||||||
if let accessible = accessible {
|
var addQuery = primaryKeyQuery
|
||||||
query[kSecAttrAccessible as String] = accessible
|
addQuery.merge([
|
||||||
}
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||||
|
kSecAttrSynchronizable as String: false
|
||||||
|
]) { _, new in new }
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
// Delete by the item's primary key only. Value/accessibility fields
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
// 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
|
/// Load data from a custom service
|
||||||
|
|||||||
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
assemblies.values.contains { $0.messageID == message.id }
|
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
|
/// 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
|
/// 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
|
/// 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 transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
|
private var preparationGeneration: UInt64 = 0
|
||||||
|
|
||||||
init(context: any ChatMediaTransferContext) {
|
init(context: any ChatMediaTransferContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -98,13 +99,14 @@ final class ChatMediaTransferCoordinator {
|
|||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
let generation = preparationGeneration
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
do {
|
do {
|
||||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
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)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
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)
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
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"))
|
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 {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
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"))
|
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)
|
#if os(iOS)
|
||||||
func processThenSendImage(_ image: UIImage?) {
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
guard let image else { return }
|
guard let image else { return }
|
||||||
|
let generation = preparationGeneration
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
await MainActor.run { [weak self] in
|
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)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,11 +153,15 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
func processThenSendImage(from url: URL?) {
|
func processThenSendImage(from url: URL?) {
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
|
let generation = preparationGeneration
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
await MainActor.run { [weak self] in
|
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)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -170,6 +180,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
let generation = preparationGeneration
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try ImageUtils.validateImageSource(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
@@ -184,7 +195,10 @@ final class ChatMediaTransferCoordinator {
|
|||||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
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(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
@@ -201,13 +215,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
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.")
|
self.context.addSystemMessage("Image is too large to send.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
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.")
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,6 +355,19 @@ final class ChatMediaTransferCoordinator {
|
|||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
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 {
|
private extension ChatMediaTransferCoordinator {
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(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 verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
@@ -292,6 +295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
let keychain: KeychainManagerProtocol
|
||||||
|
private let panicMediaWipe: () throws -> Void
|
||||||
/// Private group membership: keys in the keychain, metadata on disk.
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
let groupStore: GroupStore
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
@@ -799,7 +803,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
readReceiptsDefaults: UserDefaults? = nil,
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
outboxStore: MessageOutboxStore? = nil,
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
sfMetrics: StoreAndForwardMetrics? = nil
|
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||||
|
panicMediaWipe: (() throws -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -814,6 +819,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
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.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
@@ -1156,6 +1168,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
// Messages are processed immediately - nothing to flush
|
// 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
|
// Clear all messages (public timelines and private chats live in the
|
||||||
// single-writer ConversationStore; the derived `messages` view and
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
// the legacy mirror empty with it)
|
// the legacy mirror empty with it)
|
||||||
@@ -1278,44 +1295,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL media files (incoming and outgoing) in background
|
// The wipe must finish before this security action returns. A detached
|
||||||
Task.detached(priority: .utility) {
|
// task could otherwise lose a race with a new capture or app exit and
|
||||||
// Skipped under tests: the test process shares the user's real
|
// leave pre-panic media behind.
|
||||||
// ~/Library/Application Support/files tree, and this detached
|
do {
|
||||||
// utility-priority wipe fires at a nondeterministic time —
|
try panicMediaWipe()
|
||||||
// deleting media that concurrently running tests (e.g. the
|
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||||
// sendImage flow) just wrote there, and the developer's real
|
} catch {
|
||||||
// app data with it.
|
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Force immediate UI update for panic mode
|
||||||
// UI updates immediately - no flushing needed
|
// UI updates immediately - no flushing needed
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,21 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
||||||
|
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
||||||
|
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
||||||
|
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
|
||||||
|
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
private func makeTestableViewModel(
|
||||||
|
panicMediaWipe: (() throws -> Void)? = nil
|
||||||
|
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
@@ -26,7 +28,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport
|
transport: transport,
|
||||||
|
panicMediaWipe: panicMediaWipe
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -1097,6 +1100,18 @@ struct ChatViewModelBluetoothTests {
|
|||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||||
|
var wipeFinished = false
|
||||||
|
let (viewModel, _) = makeTestableViewModel {
|
||||||
|
wipeFinished = true
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(wipeFinished)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("PreviewKeychainManager Tests")
|
@Suite("PreviewKeychainManager Tests")
|
||||||
struct PreviewKeychainManagerTests {
|
struct PreviewKeychainManagerTests {
|
||||||
|
|
||||||
|
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
||||||
|
func installLifecycleDecision() {
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: true,
|
||||||
|
markerRead: .success(Data([1]))
|
||||||
|
) == .markerPresent)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .success(Data([1]))
|
||||||
|
) == .clearStaleKeys)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .itemNotFound
|
||||||
|
) == .bootstrapMarker)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .deviceLocked
|
||||||
|
) == .retryLater)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||||
func previewKeychainManagerRoundTripsData() {
|
func previewKeychainManagerRoundTripsData() {
|
||||||
let manager = PreviewKeychainManager()
|
let manager = PreviewKeychainManager()
|
||||||
|
|||||||
@@ -370,6 +370,46 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
let subdirectories = [
|
||||||
|
"voicenotes/incoming",
|
||||||
|
"voicenotes/outgoing",
|
||||||
|
"images/incoming",
|
||||||
|
"images/outgoing",
|
||||||
|
"files/incoming",
|
||||||
|
"files/outgoing"
|
||||||
|
]
|
||||||
|
|
||||||
|
for subdirectory in subdirectories {
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
||||||
|
}
|
||||||
|
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
||||||
|
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data("legacy".utf8).write(to: unmanaged)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
||||||
|
for subdirectory in subdirectories {
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
||||||
|
#expect(isDirectory.boolValue)
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
|
|||||||
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
||||||
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||||
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
|
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||||
|
|
||||||
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu
|
|||||||
|
|
||||||
## Panic Wipe Coverage
|
## Panic Wipe Coverage
|
||||||
|
|
||||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
|
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.
|
||||||
|
|
||||||
## Release Review Checklist
|
## Release Review Checklist
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user