Harden panic keychain and media cleanup

This commit is contained in:
jack
2026-07-25 23:49:41 +02:00
parent 6cd7a43b4b
commit 086a942b08
5 changed files with 935 additions and 141 deletions
+379 -111
View File
@@ -18,6 +18,47 @@ enum KeychainInstallLifecycleAction: Equatable {
case retryLater
}
/// Process-local fail-closed gate for an unresolved install lifecycle.
///
/// A blocked caller may perform one synchronous reconciliation attempt.
/// Concurrent callers fail closed instead of reading while that cleanup is
/// in flight. Once reconciliation succeeds, access remains open.
final class KeychainInstallAccessGate: @unchecked Sendable {
private let lock = NSLock()
private var blocked = false
private var reconciliationInProgress = false
func block() {
lock.lock()
blocked = true
lock.unlock()
}
func allowsAccess(reconcile: () -> Bool) -> Bool {
lock.lock()
if !blocked {
lock.unlock()
return true
}
guard !reconciliationInProgress else {
lock.unlock()
return false
}
reconciliationInProgress = true
lock.unlock()
let completed = reconcile()
lock.lock()
if completed {
blocked = false
}
reconciliationInProgress = false
lock.unlock()
return completed
}
}
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
@@ -48,6 +89,22 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
private let installAccessGate = KeychainInstallAccessGate()
/// Every generic-password service owned by this app, including names used
/// by older releases. Keep custom services here so one-time security
/// migrations and panic deletion cannot silently miss them.
private static let additionalApplicationOwnedServices = [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox",
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"bitchat.keychain",
"bitchat",
"com.bitchat"
]
// 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
@@ -58,15 +115,27 @@ final class KeychainManager: KeychainManagerProtocol {
init() {
#if os(iOS)
reconcileInstallLifecycle()
migrateAccessibilityIfNeeded()
if reconcileInstallLifecycle() {
migrateAccessibilityIfNeeded()
} else {
installAccessGate.block()
}
#endif
}
static func installLifecycleAction(
containerKnowsMarker: Bool,
cleanupPending: Bool = false,
markerRead: KeychainReadResult
) -> KeychainInstallLifecycleAction {
// Once a reinstall cleanup has started, its container-local latch
// must win even if the keychain marker was deleted before a later
// keychain operation failed. Otherwise the next launch could mistake
// a partial cleanup for a fresh bootstrap and preserve stale secrets.
if cleanupPending {
return .clearStaleKeys
}
switch markerRead {
case .success:
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
@@ -77,42 +146,158 @@ final class KeychainManager: KeychainManagerProtocol {
}
}
static func applicationOwnedKeychainServices(primaryService: String) -> [String] {
var seen = Set<String>()
return ([primaryService] + additionalApplicationOwnedServices).filter {
seen.insert($0).inserted
}
}
/// Runs every service update even after one failure. Successful updates
/// are idempotent, while returning false keeps the one-time flag unset so
/// a later unlocked launch retries the incomplete migration.
static func migrateAccessibilityForApplicationOwnedServices(
primaryService: String,
updateService: (String) -> OSStatus
) -> Bool {
var completed = true
for serviceName in applicationOwnedKeychainServices(
primaryService: primaryService
) {
let status = updateService(serviceName)
if status != errSecSuccess && status != errSecItemNotFound {
completed = false
}
}
return completed
}
/// Deletes every declared service even after one failure. An empty scope
/// is already clean, while any other status leaves the cleanup
/// incomplete so its durable retry marker remains set.
static func deleteApplicationOwnedKeychainServices(
primaryService: String,
deleteService: (String) -> OSStatus
) -> Bool {
var completed = true
for serviceName in applicationOwnedKeychainServices(
primaryService: primaryService
) {
let status = deleteService(serviceName)
if status != errSecSuccess && status != errSecItemNotFound {
completed = false
}
}
return completed
}
/// The app currently has an application-group entitlement, not a
/// keychain-access-group entitlement. Keep the historical group cleanup
/// probe as best effort without making its expected -34018 response block
/// panic recovery forever.
static func completedApplicationGroupDelete(status: OSStatus) -> Bool {
status == errSecSuccess
|| status == errSecItemNotFound
|| status == -34018
}
#if os(iOS)
private static let installMarkerAccount = "install_lifecycle_marker"
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
private static let installCleanupPendingDefaultsKey =
"keychain.installLifecycleCleanup.pending"
/// 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() {
@discardableResult
private func reconcileInstallLifecycle() -> Bool {
let defaults = UserDefaults.standard
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
let cleanupPending = defaults.bool(
forKey: Self.installCleanupPendingDefaultsKey
)
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
switch Self.installLifecycleAction(
containerKnowsMarker: containerKnowsMarker,
cleanupPending: cleanupPending,
markerRead: markerRead
) {
case .markerPresent:
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
return true
case .bootstrapMarker:
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
}
// A missing marker is the intentional bootstrap path for both a
// fresh install and the first marker-carrying upgrade. Preserve
// existing users' identities even if marker creation must retry
// on a later construction.
return true
case .clearStaleKeys:
_ = deleteAllKeychainData()
// Establish a container-local retry latch before deleting the
// surviving keychain marker. If the process exits or any keychain
// operation fails, the next launch retries even when that marker
// can no longer be read.
defaults.set(true, forKey: Self.installCleanupPendingDefaultsKey)
guard defaults.synchronize(),
defaults.bool(forKey: Self.installCleanupPendingDefaultsKey)
else {
SecureLogger.error(
"Could not persist reinstall keychain-cleanup intent",
category: .security
)
return false
}
guard deleteAllKeychainData() else {
SecureLogger.error(
"Reinstall keychain cleanup incomplete; retry remains pending",
category: .security
)
return false
}
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
defaults.removeObject(
forKey: Self.installCleanupPendingDefaultsKey
)
guard defaults.synchronize(),
defaults.bool(forKey: Self.installMarkerDefaultsKey),
!defaults.bool(
forKey: Self.installCleanupPendingDefaultsKey
)
else {
// Preserve the fail-closed state in memory and make one more
// best-effort persistence attempt before startup continues.
defaults.set(
true,
forKey: Self.installCleanupPendingDefaultsKey
)
_ = defaults.synchronize()
SecureLogger.error(
"Could not commit reinstall keychain-cleanup state; retry remains pending",
category: .security
)
return false
}
return true
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
// An established container may keep using ordinary protected-data
// semantics: reads fail while locked and recover after unlock. A
// container that has not committed the marker must stay blocked
// until the marker becomes readable and this state machine can
// distinguish bootstrap from reinstall.
return containerKnowsMarker
}
}
@@ -124,30 +309,59 @@ final class KeychainManager: KeychainManagerProtocol {
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let update: [String: Any] = [
kSecAttrAccessible as String: Self.itemAccessibility
]
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
switch status {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
let completed = Self.migrateAccessibilityForApplicationOwnedServices(
primaryService: service
) { serviceName in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName
]
return SecItemUpdate(
query as CFDictionary,
update as CFDictionary
)
}
if completed {
// Missing services on a fresh install are terminal, but the flag is
// set only after every application-owned service was considered.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
default:
SecureLogger.info(
"Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly",
category: .keychain
)
} else {
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
SecureLogger.warning(
"Keychain accessibility migration deferred for at least one application-owned service",
category: .keychain
)
}
}
#endif
private func installAccessAllowed() -> Bool {
#if os(iOS)
return installAccessGate.allowsAccess { [self] in
guard reconcileInstallLifecycle() else { return false }
migrateAccessibilityIfNeeded()
return true
}
#else
return true
#endif
}
// MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
guard installAccessAllowed() else {
SecureLogger.logKeyOperation(.save, keyType: key, success: false)
return false
}
let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
@@ -155,11 +369,16 @@ final class KeychainManager: KeychainManagerProtocol {
}
func getIdentityKey(forKey key: String) -> Data? {
guard installAccessAllowed() else { return nil }
let fullKey = "identity_\(key)"
return retrieveData(forKey: fullKey)
}
func deleteIdentityKey(forKey key: String) -> Bool {
guard installAccessAllowed() else {
SecureLogger.logKeyOperation(.delete, keyType: key, success: false)
return false
}
let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result
@@ -170,12 +389,14 @@ final class KeychainManager: KeychainManagerProtocol {
/// Get identity key with detailed result for proper error handling
/// Distinguishes between missing keys (expected) and critical failures
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
let fullKey = "identity_\(key)"
return retrieveDataWithResult(forKey: fullKey)
}
/// Save identity key with detailed result and retry logic for transient errors
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
guard installAccessAllowed() else { return .accessDenied }
let fullKey = "identity_\(key)"
return saveDataWithResult(keyData, forKey: fullKey)
}
@@ -445,123 +666,165 @@ final class KeychainManager: KeychainManagerProtocol {
// Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool {
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
var totalDeleted = 0
// Search without service restriction to catch all items
let ownedServices = Set(
Self.applicationOwnedKeychainServices(
primaryService: service
)
)
var enumerationCompleted = true
let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)
if searchStatus == errSecSuccess, let items = result as? [[String: Any]] {
let searchStatus = SecItemCopyMatching(
searchQuery as CFDictionary,
&result
)
switch searchStatus {
case errSecSuccess:
guard let items = result as? [[String: Any]] else {
enumerationCompleted = false
SecureLogger.error(
"Unable to decode application-owned keychain inventory",
category: .security
)
break
}
// Preserve the access-group sweep for custom services that are
// not yet in the declared legacy-service list.
for item in items {
var shouldDelete = false
let account = item[kSecAttrAccount as String] as? String ?? ""
let service = item[kSecAttrService as String] as? String ?? ""
let accessGroup = item[kSecAttrAccessGroup as String] as? String
// More precise deletion criteria:
// 1. Check for our specific app group
// 2. OR check for our exact service name
// 3. OR check for known legacy service names
if accessGroup == appGroup {
shouldDelete = true
} else if service == self.service {
shouldDelete = true
} else if [
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"bitchat.keychain",
"bitchat",
"com.bitchat"
].contains(service) {
shouldDelete = true
let account =
item[kSecAttrAccount as String] as? String ?? ""
let itemService =
item[kSecAttrService as String] as? String ?? ""
let accessGroup =
item[kSecAttrAccessGroup as String] as? String
guard accessGroup == appGroup
|| ownedServices.contains(itemService)
else {
continue
}
if shouldDelete {
// Build delete query with all available attributes for precise deletion
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
if !account.isEmpty {
deleteQuery[kSecAttrAccount as String] = account
}
if !service.isEmpty {
deleteQuery[kSecAttrService as String] = service
}
// Add access group if present
if let accessGroup = item[kSecAttrAccessGroup as String] as? String,
!accessGroup.isEmpty && accessGroup != "test" {
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
}
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess {
totalDeleted += 1
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
}
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
if !account.isEmpty {
deleteQuery[kSecAttrAccount as String] = account
}
if !itemService.isEmpty {
deleteQuery[kSecAttrService as String] = itemService
}
if let accessGroup,
!accessGroup.isEmpty,
accessGroup != "test" {
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
}
let status = SecItemDelete(deleteQuery as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
enumerationCompleted = false
SecureLogger.error(
NSError(domain: "Keychain", code: Int(status)),
context: "Unable to delete enumerated application-owned keychain item",
category: .keychain
)
}
}
case errSecItemNotFound:
break
default:
enumerationCompleted = false
SecureLogger.error(
NSError(domain: "Keychain", code: Int(searchStatus)),
context: "Unable to enumerate application-owned keychain items",
category: .keychain
)
}
// Also try to delete by known service names and app group
// This catches any items that might have been missed above
let knownServices = [
self.service, // Current service name
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain",
"bitchat",
"com.bitchat"
]
for serviceName in knownServices {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess {
totalDeleted += 1
// Bulk deletion by every application-owned service is authoritative
// and idempotent. It also verifies that every known service scope is
// empty even when the inventory pass found no items.
let servicesCompleted =
Self.deleteApplicationOwnedKeychainServices(
primaryService: service
) { serviceName in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(status)),
context: "Unable to delete application-owned keychain service \(serviceName)",
category: .keychain
)
}
return status
}
}
// Also delete by app group to ensure complete cleanup
// Historical builds attempted this application-group identifier as a
// keychain access group. It is not currently entitled, so -34018
// means the scope is inapplicable rather than partially deleted.
let groupQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: appGroup
]
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
if groupStatus == errSecSuccess {
totalDeleted += 1
let groupCompleted = Self.completedApplicationGroupDelete(
status: groupStatus
)
if !groupCompleted {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(groupStatus)),
context: "Unable to delete historical application-group keychain items",
category: .keychain
)
}
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
var markerCompleted = true
#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)
// upgrade. Do not commit the container-side marker here: reinstall
// reconciliation may still need to retry an incomplete cleanup.
if case .success = saveDataWithResult(
Data([1]),
forKey: Self.installMarkerAccount
) {
markerCompleted = true
} else {
markerCompleted = false
SecureLogger.error(
"Unable to restore install-lifecycle keychain marker",
category: .security
)
}
#endif
return totalDeleted > 0
let completed =
enumerationCompleted
&& servicesCompleted
&& groupCompleted
&& markerCompleted
if completed {
SecureLogger.warning(
"Panic mode keychain cleanup completed",
category: .keychain
)
} else {
SecureLogger.error(
"Panic mode keychain cleanup incomplete",
category: .security
)
}
return completed
}
// MARK: - Security Utilities
@@ -587,6 +850,7 @@ final class KeychainManager: KeychainManagerProtocol {
// MARK: - Debug
func verifyIdentityKeyExists() -> Bool {
guard installAccessAllowed() else { return false }
let key = "identity_noiseStaticKey"
return retrieveData(forKey: key) != nil
}
@@ -595,6 +859,7 @@ final class KeychainManager: KeychainManagerProtocol {
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
guard installAccessAllowed() else { return }
let primaryKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -641,6 +906,7 @@ final class KeychainManager: KeychainManagerProtocol {
/// Load custom-service data without collapsing `itemNotFound` and
/// protected-data/keychain failures into the same nil result.
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -655,6 +921,7 @@ final class KeychainManager: KeychainManagerProtocol {
/// Delete data from a custom service
func delete(key: String, service customService: String) {
guard installAccessAllowed() else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -666,6 +933,7 @@ final class KeychainManager: KeychainManagerProtocol {
/// Delete every item stored under a custom service
func deleteAll(service customService: String) {
guard installAccessAllowed() else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -72,16 +72,79 @@ extension ChatViewModel: ChatMediaTransferContext {
}
}
/// Synchronous boundary between detached image writers and panic deletion.
///
/// Invalidation closes admission before waiting for writers that already
/// entered. Those writers never need the main actor while inside the boundary,
/// so a synchronous panic transaction can safely join them and then delete
/// every output before reporting completion.
private final class ImagePreparationBarrier: @unchecked Sendable {
private let condition = NSCondition()
private var generation: UInt64 = 0
private var activeOperations = 0
var currentGeneration: UInt64 {
condition.lock()
defer { condition.unlock() }
return generation
}
func isCurrent(_ candidate: UInt64) -> Bool {
condition.lock()
defer { condition.unlock() }
return generation == candidate
}
func performIfCurrent<T>(
generation candidate: UInt64,
operation: () throws -> T
) rethrows -> T? {
condition.lock()
guard generation == candidate else {
condition.unlock()
return nil
}
activeOperations += 1
condition.unlock()
defer {
condition.lock()
activeOperations -= 1
if activeOperations == 0 {
condition.broadcast()
}
condition.unlock()
}
return try operation()
}
func invalidateAndWait() {
condition.lock()
generation &+= 1
while activeOperations > 0 {
condition.wait()
}
condition.unlock()
}
}
@MainActor
final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
private let imagePreparationBarrier = ImagePreparationBarrier()
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
private var preparationGeneration: UInt64 = 0
init(context: any ChatMediaTransferContext) {
init(
context: any ChatMediaTransferContext,
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
try ChatMediaPreparation.prepareImagePacket(from: $0)
}
) {
self.context = context
self.prepareImagePacket = prepareImagePacket
}
func sendVoiceNote(at url: URL) {
@@ -99,14 +162,17 @@ final class ChatMediaTransferCoordinator {
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
let generation = preparationGeneration
let generation = imagePreparationBarrier.currentGeneration
Task.detached(priority: .userInitiated) { [weak self] in
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
await MainActor.run { [weak self] in
guard let self, self.preparationGeneration == generation else { return }
guard let self,
self.imagePreparationBarrier.isCurrent(generation) else {
return
}
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
@@ -118,13 +184,19 @@ 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, self.preparationGeneration == generation else { return }
guard let self,
self.imagePreparationBarrier.isCurrent(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, self.preparationGeneration == generation else { return }
guard let self,
self.imagePreparationBarrier.isCurrent(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"))
}
}
@@ -134,12 +206,21 @@ final class ChatMediaTransferCoordinator {
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
let generation = preparationGeneration
Task.detached { [weak self] in
let generation = imagePreparationBarrier.currentGeneration
let barrier = imagePreparationBarrier
Task.detached(priority: .userInitiated) { [weak self, barrier] in
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run { [weak self] in
guard let self, self.preparationGeneration == generation else {
guard let processedURL = try barrier.performIfCurrent(
generation: generation,
operation: {
try ImageUtils.processImage(image)
}
) else {
return
}
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
try? FileManager.default.removeItem(at: processedURL)
return
}
@@ -153,12 +234,21 @@ final class ChatMediaTransferCoordinator {
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
let generation = preparationGeneration
Task.detached { [weak self] in
let generation = imagePreparationBarrier.currentGeneration
let barrier = imagePreparationBarrier
Task.detached(priority: .userInitiated) { [weak self, barrier] in
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run { [weak self] in
guard let self, self.preparationGeneration == generation else {
guard let processedURL = try barrier.performIfCurrent(
generation: generation,
operation: {
try ImageUtils.processImage(at: url)
}
) else {
return
}
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
try? FileManager.default.removeItem(at: processedURL)
return
}
@@ -180,7 +270,7 @@ final class ChatMediaTransferCoordinator {
}
let targetPeer = context.selectedPrivateChatPeer
let generation = preparationGeneration
let generation = imagePreparationBarrier.currentGeneration
do {
try ImageUtils.validateImageSource(at: sourceURL)
@@ -190,12 +280,22 @@ final class ChatMediaTransferCoordinator {
return
}
Task.detached(priority: .userInitiated) { [weak self] in
let prepareImagePacket = self.prepareImagePacket
let barrier = imagePreparationBarrier
Task.detached(priority: .userInitiated) { [weak self, barrier] in
do {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
guard let prepared = try barrier.performIfCurrent(
generation: generation,
operation: {
try prepareImagePacket(sourceURL)
}
) else {
return
}
await MainActor.run { [weak self] in
guard let self, self.preparationGeneration == generation else {
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
try? FileManager.default.removeItem(at: prepared.outputURL)
return
}
@@ -214,14 +314,20 @@ 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, self.preparationGeneration == generation else { return }
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(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, self.preparationGeneration == generation else { return }
await MainActor.run { [weak self, barrier] in
guard let self,
barrier.isCurrent(generation) else {
return
}
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
@@ -357,10 +463,11 @@ final class ChatMediaTransferCoordinator {
}
/// 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.
/// reached the transport. Closing image-preparation admission and joining
/// active synchronous writers ensures the following panic media deletion
/// is the last filesystem mutation before the transaction can complete.
func resetForPanic() {
preparationGeneration &+= 1
imagePreparationBarrier.invalidateAndWait()
let transferIDs = Set(transferIdToMessageIDs.keys)
transferIdToMessageIDs.removeAll(keepingCapacity: false)
messageIDToTransferId.removeAll(keepingCapacity: false)
@@ -14,11 +14,25 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
// every default-constructed component under test, which access it from
// arbitrary threads.
private let lock = NSLock()
private let installAccessGate: KeychainInstallAccessGate
private let reconcileInstallAccess: () -> Bool
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
init() {}
init(
installAccessGate: KeychainInstallAccessGate = KeychainInstallAccessGate(),
reconcileInstallAccess: @escaping () -> Bool = { true }
) {
self.installAccessGate = installAccessGate
self.reconcileInstallAccess = reconcileInstallAccess
}
private func installAccessAllowed() -> Bool {
installAccessGate.allowsAccess(reconcile: reconcileInstallAccess)
}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
guard installAccessAllowed() else { return false }
lock.lock()
defer { lock.unlock() }
storage[key] = keyData
@@ -26,12 +40,14 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
}
func getIdentityKey(forKey key: String) -> Data? {
guard installAccessAllowed() else { return nil }
lock.lock()
defer { lock.unlock() }
return storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
guard installAccessAllowed() else { return false }
lock.lock()
defer { lock.unlock() }
storage.removeValue(forKey: key)
@@ -51,6 +67,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
func secureClear(_ string: inout String) {}
func verifyIdentityKeyExists() -> Bool {
guard installAccessAllowed() else { return false }
lock.lock()
defer { lock.unlock() }
return storage["identity_noiseStaticKey"] != nil
@@ -58,6 +75,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
// BCH-01-009: New methods with proper error classification
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
lock.lock()
defer { lock.unlock() }
if let data = storage[key] {
@@ -67,6 +85,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
guard installAccessAllowed() else { return .accessDenied }
lock.lock()
defer { lock.unlock() }
storage[key] = keyData
@@ -76,24 +95,38 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
guard installAccessAllowed() else { return }
lock.lock()
defer { lock.unlock() }
serviceStorage[service, default: [:]][key] = data
}
func load(key: String, service: String) -> Data? {
guard case .success(let data) = loadWithResult(key: key, service: service) else {
return nil
}
return data
}
func loadWithResult(key: String, service: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
lock.lock()
defer { lock.unlock() }
return serviceStorage[service]?[key]
guard let data = serviceStorage[service]?[key] else {
return .itemNotFound
}
return .success(data)
}
func delete(key: String, service: String) {
guard installAccessAllowed() else { return }
lock.lock()
defer { lock.unlock() }
serviceStorage[service]?.removeValue(forKey: key)
}
func deleteAll(service: String) {
guard installAccessAllowed() else { return }
lock.lock()
defer { lock.unlock() }
serviceStorage.removeValue(forKey: service)
@@ -16,6 +16,11 @@
import Testing
import Foundation
import BitFoundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
// MARK: - Mock Context
@@ -203,6 +208,99 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws {
let context = MockChatMediaTransferContext()
let sourceURL = try makeCoordinatorTestImageURL()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg")
let preparer = PausedImagePreparer(outputURL: outputURL)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareImagePacket: { sourceURL in
try preparer.prepare(sourceURL)
}
)
defer {
preparer.release()
try? FileManager.default.removeItem(at: sourceURL)
try? FileManager.default.removeItem(at: outputURL)
}
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ preparer.hasStarted },
timeout: TestConstants.longTimeout
))
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + .milliseconds(100)
) {
preparer.release()
}
coordinator.resetForPanic()
// The synchronous reset boundary cannot return while a pre-panic
// writer can still create output. The real panic path deletes media
// immediately after this method returns.
#expect(preparer.hasFinished)
try? FileManager.default.removeItem(at: outputURL)
#expect(await TestHelpers.waitUntil(
{ !FileManager.default.fileExists(atPath: outputURL.path) },
timeout: TestConstants.longTimeout
))
await Task.yield()
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(context.systemMessages.isEmpty)
}
@Test @MainActor
func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws {
let sourceURL = try makeCoordinatorTestImageURL()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("released-context-\(UUID().uuidString).jpg")
let preparer = PausedImagePreparer(outputURL: outputURL)
var context: MockChatMediaTransferContext? = MockChatMediaTransferContext()
var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator(
context: context!,
prepareImagePacket: { sourceURL in
try preparer.prepare(sourceURL)
}
)
weak var weakContext: MockChatMediaTransferContext?
weak var weakCoordinator: ChatMediaTransferCoordinator?
weakContext = context
weakCoordinator = coordinator
defer {
preparer.release()
try? FileManager.default.removeItem(at: sourceURL)
try? FileManager.default.removeItem(at: outputURL)
}
coordinator?.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ preparer.hasStarted },
timeout: TestConstants.longTimeout
))
coordinator = nil
context = nil
#expect(weakCoordinator == nil)
#expect(weakContext == nil)
preparer.release()
#expect(await TestHelpers.waitUntil(
{ preparer.hasFinished },
timeout: TestConstants.longTimeout
))
#expect(await TestHelpers.waitUntil(
{ !FileManager.default.fileExists(atPath: outputURL.path) },
timeout: TestConstants.longTimeout
))
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
@@ -222,3 +320,91 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
}
private final class PausedImagePreparer: @unchecked Sendable {
private let condition = NSCondition()
private let outputURL: URL
private var started = false
private var released = false
private var finished = false
init(outputURL: URL) {
self.outputURL = outputURL
}
var hasStarted: Bool {
condition.lock()
defer { condition.unlock() }
return started
}
var hasFinished: Bool {
condition.lock()
defer { condition.unlock() }
return finished
}
func prepare(_ sourceURL: URL) throws -> ChatPreparedImage {
condition.lock()
started = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
let data = Data("prepared image".utf8)
try data.write(to: outputURL, options: .atomic)
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
condition.lock()
finished = true
condition.broadcast()
condition.unlock()
return ChatPreparedImage(outputURL: outputURL, packet: packet)
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private func makeCoordinatorTestImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("coordinator-image-\(UUID().uuidString).png")
#if os(iOS)
let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16))
.image { context in
UIColor.systemBlue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
}
guard let data = image.pngData() else {
throw CoordinatorImageTestError.encodingFailed
}
#else
let image = NSImage(size: NSSize(width: 16, height: 16))
image.lockFocus()
NSColor.systemBlue.setFill()
NSRect(x: 0, y: 0, width: 16, height: 16).fill()
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let data = bitmap.representation(using: .png, properties: [:]) else {
throw CoordinatorImageTestError.encodingFailed
}
#endif
try data.write(to: url, options: .atomic)
return url
}
private enum CoordinatorImageTestError: Error {
case encodingFailed
}
@@ -1,4 +1,5 @@
import Foundation
import Security
import Testing
import BitFoundation
@testable import bitchat
@@ -24,6 +25,77 @@ struct PreviewKeychainManagerTests {
containerKnowsMarker: false,
markerRead: .deviceLocked
) == .retryLater)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
cleanupPending: true,
markerRead: .itemNotFound
) == .clearStaleKeys)
}
@Test("Accessibility migration covers custom services and retries after any incomplete update")
func accessibilityMigrationCoversEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let completed = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.favorites"
? errSecInteractionNotAllowed
: errSecItemNotFound
}
#expect(!completed)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let retryCompleted = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { _ in errSecSuccess }
#expect(retryCompleted)
}
@Test("Keychain cleanup is complete only when every owned scope is clean")
func keychainCleanupRequiresEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let partialCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.outbox"
? errSecInteractionNotAllowed
: errSecSuccess
}
#expect(!partialCleanup)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let emptyCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { _ in errSecItemNotFound }
#expect(emptyCleanup)
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
#expect(!KeychainManager.completedApplicationGroupDelete(
status: errSecInteractionNotAllowed
))
}
@Test("Preview keychain manager stores identity and service-scoped data in memory")
@@ -72,4 +144,132 @@ struct PreviewKeychainManagerTests {
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
}
}
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
let gate = KeychainInstallAccessGate()
var cleanupCanComplete = false
var reconciliationAttempts = 0
var manager: PreviewKeychainManager!
manager = PreviewKeychainManager(
installAccessGate: gate
) {
reconciliationAttempts += 1
guard cleanupCanComplete else { return false }
return manager.deleteAllKeychainData()
}
let staleIdentity = Data([1, 2, 3])
let staleFavorite = Data([4, 5, 6])
let staleOutbox = Data([7, 8, 9])
let staleCustom = Data([10, 11, 12])
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "identity_noiseStaticKey"
))
#expect(manager.verifyIdentityKeyExists())
manager.save(
key: "favorite",
data: staleFavorite,
service: "chat.bitchat.favorites",
accessible: nil
)
manager.save(
key: "outbox",
data: staleOutbox,
service: "chat.bitchat.outbox",
accessible: nil
)
manager.save(
key: "custom",
data: staleCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
gate.block()
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(!manager.verifyIdentityKeyExists())
if case .accessDenied = manager.getIdentityKeyWithResult(
forKey: "noiseStaticKey"
) {
} else {
Issue.record("Expected blocked identity read to fail closed")
}
for (key, service) in [
("favorite", "chat.bitchat.favorites"),
("outbox", "chat.bitchat.outbox"),
("custom", "chat.bitchat.future-custom")
] {
#expect(manager.load(key: key, service: service) == nil)
if case .accessDenied = manager.loadWithResult(
key: key,
service: service
) {
} else {
Issue.record(
"Expected blocked \(service) read to fail closed"
)
}
}
#expect(!manager.saveIdentityKey(
Data([13]),
forKey: "replacement"
))
if case .accessDenied = manager.saveIdentityKeyWithResult(
Data([14]),
forKey: "replacement"
) {
} else {
Issue.record("Expected blocked identity save to fail closed")
}
let failedAttempts = reconciliationAttempts
#expect(failedAttempts > 0)
cleanupCanComplete = true
// The first access retries cleanup synchronously. It must not return
// any surviving value from before the reinstall.
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(reconciliationAttempts == failedAttempts + 1)
#expect(manager.load(
key: "favorite",
service: "chat.bitchat.favorites"
) == nil)
#expect(manager.load(
key: "outbox",
service: "chat.bitchat.outbox"
) == nil)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == nil)
let replacementIdentity = Data([21, 22, 23])
let replacementCustom = Data([24, 25, 26])
#expect(manager.saveIdentityKey(
replacementIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.getIdentityKey(
forKey: "noiseStaticKey"
) == replacementIdentity)
manager.save(
key: "custom",
data: replacementCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == replacementCustom)
}
}