mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
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:
@@ -10,6 +10,47 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
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 {
|
protocol KeychainManagerProtocol {
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
func getIdentityKey(forKey key: String) -> Data?
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
@@ -21,6 +62,12 @@ protocol KeychainManagerProtocol {
|
|||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool
|
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)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
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)
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||||
return 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
|
// MARK: - Generic Operations
|
||||||
|
|
||||||
private func save(_ value: String, forKey key: String) -> Bool {
|
private func save(_ value: String, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -199,64 +199,153 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
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
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain
|
// Try to load from keychain with proper error classification
|
||||||
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
||||||
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
|
||||||
loadedKey = key
|
switch noiseKeyResult {
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
case .success(let identityData):
|
||||||
}
|
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
// If no identity exists, create new one
|
loadedKey = key
|
||||||
else {
|
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()
|
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
|
// Now assign the final value
|
||||||
self.staticIdentityKey = loadedKey
|
self.staticIdentityKey = loadedKey
|
||||||
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
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
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain
|
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
|
||||||
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
|
||||||
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
switch signingKeyResult {
|
||||||
loadedSigningKey = key
|
case .success(let signingData):
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
}
|
loadedSigningKey = key
|
||||||
// If no signing key exists, create new one
|
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||||
else {
|
} 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()
|
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
|
// Now assign the signing keys
|
||||||
self.signingKey = loadedSigningKey
|
self.signingKey = loadedSigningKey
|
||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
startRekeyTimer()
|
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
|
// MARK: - Public Interface
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
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)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
private var serviceStorage: [String: [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 {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
return true
|
return true
|
||||||
@@ -45,6 +49,25 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
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)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
@@ -76,6 +99,10 @@ final class TrackingMockKeychain: KeychainManagerProtocol {
|
|||||||
private var _secureClearDataCallCount = 0
|
private var _secureClearDataCallCount = 0
|
||||||
private var _secureClearStringCallCount = 0
|
private var _secureClearStringCallCount = 0
|
||||||
|
|
||||||
|
// BCH-01-009: Configurable error simulation for testing
|
||||||
|
var simulatedReadError: KeychainReadResult?
|
||||||
|
var simulatedSaveError: KeychainSaveResult?
|
||||||
|
|
||||||
var secureClearDataCallCount: Int {
|
var secureClearDataCallCount: Int {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
@@ -137,6 +164,25 @@ final class TrackingMockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
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?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
if serviceStorage[service] == nil {
|
if serviceStorage[service] == nil {
|
||||||
serviceStorage[service] = [:]
|
serviceStorage[service] = [:]
|
||||||
|
|||||||
Reference in New Issue
Block a user