diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index c6af3d33..574bdf9f 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -10,6 +10,47 @@ import BitLogger import Foundation import Security +// MARK: - Keychain Error Types +// BCH-01-009: Proper error classification to distinguish expected states from critical failures + +/// Result of a keychain read operation with proper error classification +enum KeychainReadResult { + case success(Data) + case itemNotFound // Expected: key doesn't exist yet + case accessDenied // Critical: app lacks keychain access + case deviceLocked // Recoverable: device is locked + case authenticationFailed // Recoverable: biometric/passcode failed + case otherError(OSStatus) // Unexpected error + + var isRecoverableError: Bool { + switch self { + case .deviceLocked, .authenticationFailed: + return true + default: + return false + } + } +} + +/// Result of a keychain save operation with proper error classification +enum KeychainSaveResult { + case success + case duplicateItem // Can retry with update + case accessDenied // Critical: app lacks keychain access + case deviceLocked // Recoverable: device is locked + case storageFull // Critical: no space available + case otherError(OSStatus) + + var isRecoverableError: Bool { + switch self { + case .duplicateItem, .deviceLocked: + return true + default: + return false + } + } +} + protocol KeychainManagerProtocol { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool func getIdentityKey(forKey key: String) -> Data? @@ -21,6 +62,12 @@ protocol KeychainManagerProtocol { func verifyIdentityKeyExists() -> Bool + // BCH-01-009: Methods with proper error classification + /// Get identity key with detailed result for error handling + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult + /// Save identity key with detailed result for error handling + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult + // MARK: - Generic Data Storage (consolidated from KeychainHelper) /// Save data with a custom service name func save(key: String, data: Data, service: String, accessible: CFString?) @@ -54,7 +101,181 @@ final class KeychainManager: KeychainManagerProtocol { SecureLogger.logKeyOperation(.delete, keyType: key, success: result) return result } - + + // MARK: - BCH-01-009: Methods with Proper Error Classification + + /// Get identity key with detailed result for proper error handling + /// Distinguishes between missing keys (expected) and critical failures + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + 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 { + let fullKey = "identity_\(key)" + return saveDataWithResult(keyData, forKey: fullKey) + } + + /// Internal method to save data with detailed result and retry for transient errors + private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult { + // Delete any existing item first to ensure clean state + _ = delete(forKey: key) + + // Build base query + var base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrService as String: service, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + kSecAttrLabel as String: "bitchat-\(key)" + ] + #if os(macOS) + base[kSecAttrSynchronizable as String] = false + #endif + + func attempt(addAccessGroup: Bool) -> OSStatus { + var query = base + if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup } + return SecItemAdd(query as CFDictionary, nil) + } + + #if os(iOS) + var status = attempt(addAccessGroup: true) + if status == -34018 { // Missing entitlement, retry without access group + status = attempt(addAccessGroup: false) + } + #else + let status = attempt(addAccessGroup: false) + #endif + + // Classify the result + let result = classifySaveStatus(status) + + // Log all outcomes consistently + switch result { + case .success: + SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain) + case .duplicateItem: + SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain) + case .accessDenied: + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Keychain access denied for key: \(key)", category: .keychain) + case .deviceLocked: + SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain) + case .storageFull: + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Keychain storage full for key: \(key)", category: .keychain) + case .otherError(let code): + SecureLogger.error(NSError(domain: "Keychain", code: Int(code)), + context: "Keychain save failed for key: \(key)", category: .keychain) + } + + // Retry transient errors with exponential backoff + if result.isRecoverableError && retryCount > 0 { + let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff + usleep(delayMs * 1000) + SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain) + return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1) + } + + return result + } + + /// Internal method to retrieve data with detailed result + private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + func attempt(withAccessGroup: Bool) -> OSStatus { + var q = base + if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup } + return SecItemCopyMatching(q as CFDictionary, &result) + } + + #if os(iOS) + var status = attempt(withAccessGroup: true) + if status == -34018 { status = attempt(withAccessGroup: false) } + #else + let status = attempt(withAccessGroup: false) + #endif + + // Classify the result + let readResult = classifyReadStatus(status, data: result as? Data) + + // Log all outcomes consistently + switch readResult { + case .success: + SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain) + case .itemNotFound: + // Expected case - no logging needed for missing keys + break + case .accessDenied: + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Keychain access denied for key: \(key)", category: .keychain) + case .deviceLocked: + SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain) + case .authenticationFailed: + SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain) + case .otherError(let code): + SecureLogger.error(NSError(domain: "Keychain", code: Int(code)), + context: "Keychain read failed for key: \(key)", category: .keychain) + } + + return readResult + } + + /// Classify keychain read status into meaningful categories + private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult { + switch status { + case errSecSuccess: + if let data = data { + return .success(data) + } + return .otherError(status) + case errSecItemNotFound: + return .itemNotFound + case errSecInteractionNotAllowed: + // Device is locked or in a state that doesn't allow keychain access + return .deviceLocked + case errSecAuthFailed: + return .authenticationFailed + case -34018: // errSecMissingEntitlement + return .accessDenied + case errSecNotAvailable: + return .accessDenied + default: + return .otherError(status) + } + } + + /// Classify keychain save status into meaningful categories + private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult { + switch status { + case errSecSuccess: + return .success + case errSecDuplicateItem: + return .duplicateItem + case errSecInteractionNotAllowed: + return .deviceLocked + case -34018: // errSecMissingEntitlement + return .accessDenied + case errSecNotAvailable: + return .accessDenied + case errSecDiskFull: + return .storageFull + default: + return .otherError(status) + } + } + // MARK: - Generic Operations private func save(_ value: String, forKey key: String) -> Bool { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 5d99a227..9b5ff0cb 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -199,64 +199,153 @@ final class NoiseEncryptionService { init(keychain: KeychainManagerProtocol) { self.keychain = keychain - - // Load or create static identity key (ONLY from keychain) + + // BCH-01-009: Load or create static identity key with proper error handling let loadedKey: Curve25519.KeyAgreement.PrivateKey - - // Try to load from keychain - if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"), - let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { - loadedKey = key - SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) - } - // If no identity exists, create new one - else { + + // Try to load from keychain with proper error classification + let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") + + switch noiseKeyResult { + case .success(let identityData): + if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { + loadedKey = key + SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) + } else { + // Data corrupted, regenerate + SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain) + loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain) + } + + case .itemNotFound: + // Expected case: no key exists yet, create new one + loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain) + + case .accessDenied: + // Critical error - log but proceed with ephemeral key (will be lost on restart) + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Keychain access denied - using ephemeral identity", category: .keychain) + loadedKey = Curve25519.KeyAgreement.PrivateKey() + + case .deviceLocked, .authenticationFailed: + // Recoverable error - use ephemeral key and warn + SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain) + loadedKey = Curve25519.KeyAgreement.PrivateKey() + + case .otherError(let status): + // Unexpected error - log and use ephemeral key + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Unexpected keychain error - using ephemeral identity", category: .keychain) loadedKey = Curve25519.KeyAgreement.PrivateKey() - let keyData = loadedKey.rawRepresentation - - // Save to keychain - let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey") - SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved) } - + // Now assign the final value self.staticIdentityKey = loadedKey self.staticIdentityPublicKey = staticIdentityKey.publicKey - - // Load or create signing key pair + + // BCH-01-009: Load or create signing key pair with proper error handling let loadedSigningKey: Curve25519.Signing.PrivateKey - - // Try to load from keychain - if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"), - let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { - loadedSigningKey = key - SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) - } - // If no signing key exists, create new one - else { + + let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") + + switch signingKeyResult { + case .success(let signingData): + if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { + loadedSigningKey = key + SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) + } else { + // Data corrupted, regenerate + SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain) + loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain) + } + + case .itemNotFound: + // Expected case: no key exists yet, create new one + loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain) + + case .accessDenied: + // Critical error - log but proceed with ephemeral key + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Keychain access denied - using ephemeral signing key", category: .keychain) + loadedSigningKey = Curve25519.Signing.PrivateKey() + + case .deviceLocked, .authenticationFailed: + // Recoverable error - use ephemeral key and warn + SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain) + loadedSigningKey = Curve25519.Signing.PrivateKey() + + case .otherError(let status): + // Unexpected error - log and use ephemeral key + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Unexpected keychain error - using ephemeral signing key", category: .keychain) loadedSigningKey = Curve25519.Signing.PrivateKey() - let keyData = loadedSigningKey.rawRepresentation - - // Save to keychain - let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey") - SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved) } - + // Now assign the signing keys self.signingKey = loadedSigningKey self.signingPublicKey = signingKey.publicKey - + // Initialize session manager self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) - + // Set up session callbacks sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) } - + // Start session maintenance timer startRekeyTimer() } + + // MARK: - BCH-01-009: Key Generation Helpers with Save Verification + + /// Generate and save a new Noise static key, verifying the save succeeds + private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey { + let newKey = Curve25519.KeyAgreement.PrivateKey() + let keyData = newKey.rawRepresentation + + // Save to keychain and verify success + let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey") + + switch saveResult { + case .success: + SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true) + case .duplicateItem: + // This shouldn't happen since we just tried to load, but handle it + SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain) + default: + // Save failed - log but continue with the key (it will be ephemeral) + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Failed to persist noise static key - identity will be lost on restart", + category: .keychain) + } + + return newKey + } + + /// Generate and save a new Ed25519 signing key, verifying the save succeeds + private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey { + let newKey = Curve25519.Signing.PrivateKey() + let keyData = newKey.rawRepresentation + + // Save to keychain and verify success + let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey") + + switch saveResult { + case .success: + SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true) + case .duplicateItem: + // This shouldn't happen since we just tried to load, but handle it + SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain) + default: + // Save failed - log but continue with the key (it will be ephemeral) + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Failed to persist signing key - identity will be lost on restart", + category: .keychain) + } + + return newKey + } // MARK: - Public Interface diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift index 490fc69d..464eaa3d 100644 --- a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -41,6 +41,19 @@ final class PreviewKeychainManager: KeychainManagerProtocol { storage["identity_noiseStaticKey"] != nil } + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + storage[key] = keyData + return .success + } + // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { diff --git a/bitchatTests/KeychainErrorHandlingTests.swift b/bitchatTests/KeychainErrorHandlingTests.swift new file mode 100644 index 00000000..ad48c1e3 --- /dev/null +++ b/bitchatTests/KeychainErrorHandlingTests.swift @@ -0,0 +1,216 @@ +// +// KeychainErrorHandlingTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// +// BCH-01-009: Tests for proper keychain error classification and handling + +import Testing +import Foundation +@testable import bitchat + +struct KeychainErrorHandlingTests { + + // MARK: - Error Classification Tests + + @Test func keychainReadResult_successIsNotRecoverable() throws { + let result = KeychainReadResult.success(Data([1, 2, 3])) + #expect(result.isRecoverableError == false) + } + + @Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws { + let result = KeychainReadResult.itemNotFound + #expect(result.isRecoverableError == false) + } + + @Test func keychainReadResult_deviceLockedIsRecoverable() throws { + let result = KeychainReadResult.deviceLocked + #expect(result.isRecoverableError == true) + } + + @Test func keychainReadResult_authenticationFailedIsRecoverable() throws { + let result = KeychainReadResult.authenticationFailed + #expect(result.isRecoverableError == true) + } + + @Test func keychainReadResult_accessDeniedIsNotRecoverable() throws { + let result = KeychainReadResult.accessDenied + #expect(result.isRecoverableError == false) + } + + @Test func keychainSaveResult_successIsNotRecoverable() throws { + let result = KeychainSaveResult.success + #expect(result.isRecoverableError == false) + } + + @Test func keychainSaveResult_duplicateItemIsRecoverable() throws { + let result = KeychainSaveResult.duplicateItem + #expect(result.isRecoverableError == true) + } + + @Test func keychainSaveResult_deviceLockedIsRecoverable() throws { + let result = KeychainSaveResult.deviceLocked + #expect(result.isRecoverableError == true) + } + + @Test func keychainSaveResult_storageFullIsNotRecoverable() throws { + let result = KeychainSaveResult.storageFull + #expect(result.isRecoverableError == false) + } + + // MARK: - Mock Keychain Error Simulation Tests + + @Test func mockKeychain_canSimulateReadErrors() throws { + let keychain = MockKeychain() + + // Simulate access denied error + keychain.simulatedReadError = .accessDenied + let result = keychain.getIdentityKeyWithResult(forKey: "testKey") + + switch result { + case .accessDenied: + // Expected + break + default: + throw KeychainTestError("Expected accessDenied, got \(result)") + } + } + + @Test func mockKeychain_canSimulateSaveErrors() throws { + let keychain = MockKeychain() + + // Simulate storage full error + keychain.simulatedSaveError = .storageFull + let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: "testKey") + + switch result { + case .storageFull: + // Expected + break + default: + throw KeychainTestError("Expected storageFull, got \(result)") + } + } + + @Test func mockKeychain_returnsItemNotFoundForMissingKey() throws { + let keychain = MockKeychain() + let result = keychain.getIdentityKeyWithResult(forKey: "nonExistentKey") + + switch result { + case .itemNotFound: + // Expected + break + default: + throw KeychainTestError("Expected itemNotFound, got \(result)") + } + } + + @Test func mockKeychain_returnsSuccessForExistingKey() throws { + let keychain = MockKeychain() + let testData = Data([1, 2, 3, 4, 5]) + + // First save the key + _ = keychain.saveIdentityKey(testData, forKey: "existingKey") + + // Now read it back + let result = keychain.getIdentityKeyWithResult(forKey: "existingKey") + + switch result { + case .success(let data): + #expect(data == testData) + default: + throw KeychainTestError("Expected success, got \(result)") + } + } + + @Test func mockKeychain_saveWithResultStoresData() throws { + let keychain = MockKeychain() + let testData = Data([10, 20, 30]) + + let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: "newKey") + + switch saveResult { + case .success: + // Verify data was stored + let readResult = keychain.getIdentityKeyWithResult(forKey: "newKey") + switch readResult { + case .success(let data): + #expect(data == testData) + default: + throw KeychainTestError("Expected to read back saved data") + } + default: + throw KeychainTestError("Expected save success, got \(saveResult)") + } + } + + // MARK: - NoiseEncryptionService Integration Tests + + @Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws { + let keychain = MockKeychain() + + // Create service with empty keychain - should generate new identity + let service = NoiseEncryptionService(keychain: keychain) + + // Should have generated and saved keys + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + + // Keys should be persisted + let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") + switch noiseKeyResult { + case .success: + // Expected - key was saved + break + default: + throw KeychainTestError("Expected noise key to be saved") + } + } + + @Test func noiseEncryptionService_loadsExistingIdentity() throws { + let keychain = MockKeychain() + + // Create first service to generate identity + let service1 = NoiseEncryptionService(keychain: keychain) + let originalPublicKey = service1.getStaticPublicKeyData() + let originalSigningKey = service1.getSigningPublicKeyData() + + // Create second service - should load same identity + let service2 = NoiseEncryptionService(keychain: keychain) + + #expect(service2.getStaticPublicKeyData() == originalPublicKey) + #expect(service2.getSigningPublicKeyData() == originalSigningKey) + } + + @Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws { + let keychain = MockKeychain() + keychain.simulatedReadError = .accessDenied + + // Service should still initialize with ephemeral key + let service = NoiseEncryptionService(keychain: keychain) + + // Should have an identity (ephemeral) + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + } + + @Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws { + let keychain = MockKeychain() + keychain.simulatedReadError = .deviceLocked + + // Service should still initialize with ephemeral key + let service = NoiseEncryptionService(keychain: keychain) + + // Should have an identity (ephemeral) + #expect(service.getStaticPublicKeyData().count == 32) + } +} + +// Helper error type for tests +private struct KeychainTestError: Error, CustomStringConvertible { + let message: String + init(_ message: String) { self.message = message } + var description: String { message } +} diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index 9f6673f5..76b32952 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -13,6 +13,10 @@ final class MockKeychain: KeychainManagerProtocol { private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] + // BCH-01-009: Configurable error simulation for testing + var simulatedReadError: KeychainReadResult? + var simulatedSaveError: KeychainSaveResult? + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData return true @@ -45,6 +49,25 @@ final class MockKeychain: KeychainManagerProtocol { storage["identity_noiseStaticKey"] != nil } + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let simulated = simulatedReadError { + return simulated + } + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if let simulated = simulatedSaveError { + return simulated + } + storage[key] = keyData + return .success + } + // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { @@ -76,6 +99,10 @@ final class TrackingMockKeychain: KeychainManagerProtocol { private var _secureClearDataCallCount = 0 private var _secureClearStringCallCount = 0 + // BCH-01-009: Configurable error simulation for testing + var simulatedReadError: KeychainReadResult? + var simulatedSaveError: KeychainSaveResult? + var secureClearDataCallCount: Int { lock.lock() defer { lock.unlock() } @@ -137,6 +164,25 @@ final class TrackingMockKeychain: KeychainManagerProtocol { storage["identity_noiseStaticKey"] != nil } + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let simulated = simulatedReadError { + return simulated + } + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if let simulated = simulatedSaveError { + return simulated + } + storage[key] = keyData + return .success + } + func save(key: String, data: Data, service: String, accessible: CFString?) { if serviceStorage[service] == nil { serviceStorage[service] = [:]