fix(security): improve keychain error handling [BCH-01-009]

Add proper error classification to distinguish expected states (item not
found) from critical failures (access denied, storage full) and
recoverable errors (device locked, auth failed).

Changes:
- Add KeychainReadResult and KeychainSaveResult enums with error types
- Add getIdentityKeyWithResult/saveIdentityKeyWithResult protocol methods
- Implement retry logic with exponential backoff for transient errors
- Add consistent SecureLogger logging for all keychain operations
- Update NoiseEncryptionService identity loading to handle errors gracefully
- Update MockKeychain and TrackingMockKeychain with error simulation
- Add comprehensive KeychainErrorHandlingTests (18 new tests)

Security: Applications can now properly detect and respond to critical
keychain failures, improving resilience during device lock states and
providing actionable diagnostics for access issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-01-12 10:53:02 -10:00
co-authored by Claude Opus 4.5
parent b84c36c6fa
commit 31be6b83a7
5 changed files with 623 additions and 38 deletions
@@ -0,0 +1,216 @@
//
// KeychainErrorHandlingTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
// 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 }
}
+46
View File
@@ -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] = [:]