diff --git a/.azure-pipelines/azure-pipelines.yml b/.azure-pipelines/azure-pipelines.yml index de828db..e17593d 100644 --- a/.azure-pipelines/azure-pipelines.yml +++ b/.azure-pipelines/azure-pipelines.yml @@ -9,7 +9,7 @@ jobs: ios14: IMAGE_NAME: 'macos-10.15' XCODE_DEVELOPER_PATH: /Applications/Xcode_12.4.app - IOS_SIMULATORS: 'iPhone 12 mini,OS=14.3' + IOS_SIMULATORS: 'iPhone 12 mini,OS=14.4' pool: vmImage: $(IMAGE_NAME) diff --git a/Sources/TIMEncryptedStorage/Helpers/TIMKeychainStoreItem.swift b/Sources/TIMEncryptedStorage/Helpers/TIMKeychain.swift similarity index 74% rename from Sources/TIMEncryptedStorage/Helpers/TIMKeychainStoreItem.swift rename to Sources/TIMEncryptedStorage/Helpers/TIMKeychain.swift index 6a9e963..aa7dc56 100644 --- a/Sources/TIMEncryptedStorage/Helpers/TIMKeychainStoreItem.swift +++ b/Sources/TIMEncryptedStorage/Helpers/TIMKeychain.swift @@ -48,13 +48,13 @@ public final class TIMKeychain { /// - Parameters: /// - data: Data to save. /// - item: The item to identify the data. - /// - Returns: `true` if it was a success, otherwise `false` - public static func store(data: Data, item: TIMKeychainStoreItem) -> Bool { + /// - Returns: Result indicating whether it was a success or not. + public static func store(data: Data, item: TIMKeychainStoreItem) -> Result { remove(item: item) //Remove before adding to avoid override errors var mutableParameters = item.parameters mutableParameters.updateValue(data, forKey: kSecValueData as String) let status = SecItemAdd(mutableParameters as CFDictionary, nil) - return status == noErr + return mapStoreStatusToResult(status) } @@ -62,8 +62,8 @@ public final class TIMKeychain { /// - Parameters: /// - data: Data to save. /// - item: The item to identify the data. - /// - Returns: `true` if it was a success, otherwise `false` - public static func storeBiometricProtected(data: Data, item: TIMKeychainStoreItem) -> Bool { + /// - Returns: Result indicating whether it was a success or not. + public static func storeBiometricProtected(data: Data, item: TIMKeychainStoreItem) -> Result { let biometricFlag: SecAccessControlCreateFlags if #available(iOS 11.3, *) { biometricFlag = .biometryAny @@ -77,14 +77,14 @@ public final class TIMKeychain { nil ) - let result: Bool + let result: Result if let safeAccessControl = sacObject { var mutableItem = item mutableItem.enableUseAuthenticationUI(kSecUseAuthenticationUIAllow) mutableItem.enableSafeAccessControl(safeAccessControl) result = store(data: data, item: mutableItem) } else { - result = false + result = .failure(.failedToStoreData) } return result } @@ -92,29 +92,26 @@ public final class TIMKeychain { /// Gets data from the keychain. /// - Parameter item: The item that identifies the data (and which is was saved with) - /// - Returns: If there is data for the specified item, it will return the Data object, otherwise `nil` - public static func get(item: TIMKeychainStoreItem) -> Data? { - var result: AnyObject? + /// - Returns: If there is data for the specified item, it will return the Data object, otherwise a failing result with a matching error + public static func get(item: TIMKeychainStoreItem) -> Result { + var dataResult: AnyObject? var mutableParameters = item.parameters mutableParameters.updateValue(kSecMatchLimitOne, forKey: kSecMatchLimit as String) mutableParameters.updateValue(kCFBooleanTrue as Any, forKey: kSecReturnData as String) // Search - let status = withUnsafeMutablePointer(to: &result) { + let status = withUnsafeMutablePointer(to: &dataResult) { SecItemCopyMatching(mutableParameters as CFDictionary, UnsafeMutablePointer($0)) } - if status == noErr, let optData = (result as? Data) { - return optData - } else { - return nil - } + + return mapLoadStatusToResult(status, data: dataResult) } /// Gets biometric protected data from the keychain - this will prompt the user for biometric verification. /// - Parameter item: The item that identifies the data (and which is was saved with) /// - Returns: If there is data for the specified item, it will return the Data object, otherwise `nil` - public static func getBiometricProtected(item: TIMKeychainStoreItem) -> Data? { + public static func getBiometricProtected(item: TIMKeychainStoreItem) -> Result { var mutableItem = item mutableItem.enableUseAuthenticationUI(kSecUseAuthenticationUIAllow) return get(item: mutableItem) @@ -142,4 +139,34 @@ public final class TIMKeychain { mutableItem.enableUseAuthenticationUI(kSecUseAuthenticationUIFail) return hasValue(item: mutableItem) } + + static func mapStoreStatusToResult(_ status: OSStatus) -> Result { + let result: Result + switch status { + case errSecAuthFailed: + result = .failure(.authenticationFailedForData) + case noErr: + result = .success(Void()) + default: + result = .failure(.failedToStoreData) + } + return result + } + + static func mapLoadStatusToResult(_ status: OSStatus, data: AnyObject?) -> Result { + let result: Result + switch status { + case errSecAuthFailed, errSecUserCanceled: + result = Result.failure(.authenticationFailedForData) + case noErr: + if let optData = (data as? Data) { + result = .success(optData) + } else { + result = .failure(.failedToLoadData) + } + default: + result = .failure(.failedToLoadData) + } + return result + } } diff --git a/Sources/TIMEncryptedStorage/Models/ErrorModels.swift b/Sources/TIMEncryptedStorage/Models/ErrorModels.swift index bf0bcc5..e29e214 100644 --- a/Sources/TIMEncryptedStorage/Models/ErrorModels.swift +++ b/Sources/TIMEncryptedStorage/Models/ErrorModels.swift @@ -9,17 +9,15 @@ public enum TIMEncryptedStorageError: Error, LocalizedError { case invalidEncryptionMethod case invalidEncryptionKey - // MARK: - Default store / get - case failedToStoreInKeychain - case failedToLoadDataInKeychain - - // MARK: - Biometric longSecret store / get - case failedToStoreLongSecretViaBiometric - case failedToLoadLongSecretViaBiometric - // MARK: - KeySever errors case keyServiceFailed(TIMKeyServiceError) + // MARK: - Keychain errors + case keychainFailed(TIMKeychainError) + + // MARK: - Unexpected data from Keychain + case unexpectedData + public var errorDescription: String? { switch self { @@ -27,20 +25,16 @@ public enum TIMEncryptedStorageError: Error, LocalizedError { return "Failed to encrypt data with specified key." case .failedToDecryptData: return "Failed to decrypt data with specified key." - case .failedToStoreInKeychain: - return "Something went wrong, while saving data to keychain." - case .failedToLoadDataInKeychain: - return "Something went wrong, while loading data from keychain." - case .failedToStoreLongSecretViaBiometric: - return "Something went wrong, while saving the longSecret to keychain (with biometric protection)" - case .failedToLoadLongSecretViaBiometric: - return "Something went wrong, while loading the longSecret from keychain (with biometric protection)" - case .keyServiceFailed(let error): - return "The KeyService failed with error: \(error)" case .invalidEncryptionMethod: return "The encryption method is invalid. Did you remember to call the configure method?" case .invalidEncryptionKey: return "The encryption key is invalid." + case .keyServiceFailed(let error): + return "The KeyService failed with error: \(error)" + case .keychainFailed(let error): + return "The Keychain failed with error: \(error)" + case .unexpectedData: + return "The Keychain loaded unexpected data. Failed to use the data." } } } @@ -83,6 +77,28 @@ public enum TIMKeyServiceError: Error, Equatable, LocalizedError { } } +public enum TIMKeychainError : Error, LocalizedError { + /// Failed to store data + case failedToStoreData + + /// Failed to load data + case failedToLoadData + + /// Authentication failed for data retrieve (e.g. TouchID/FaceID) + case authenticationFailedForData + + public var errorDescription: String? { + switch self { + case .authenticationFailedForData: + return "The authentication failed for data, e.g. the user failed to unlock or cancelled the biometric ID prompt." + case .failedToLoadData: + return "Failed to load data from keychain." + case .failedToStoreData: + return "Failed to store data in keychain." + } + } +} + func mapKeyServerError(_ error: Error?) -> TIMKeyServiceError { guard let err = error else { return .unknown(nil, nil) diff --git a/Sources/TIMEncryptedStorage/TIM/TIMEncryptedStorage.swift b/Sources/TIMEncryptedStorage/TIM/TIMEncryptedStorage.swift index 1a4eccc..b4fd204 100644 --- a/Sources/TIMEncryptedStorage/TIM/TIMEncryptedStorage.swift +++ b/Sources/TIMEncryptedStorage/TIM/TIMEncryptedStorage.swift @@ -72,12 +72,8 @@ public final class TIMEncryptedStorage { private static func storeLongSecret(keyId: String, longSecret: String) -> Result { let keychainKey = longSecretKeychainId(keyId: keyId) - let success = TIMKeychain.storeBiometricProtected(data: Data(longSecret.utf8), item: TIMKeychainStoreItem(id: keychainKey)) - if success { - return .success(()) - } else { - return .failure(.failedToStoreLongSecretViaBiometric) - } + let result = TIMKeychain.storeBiometricProtected(data: Data(longSecret.utf8), item: TIMKeychainStoreItem(id: keychainKey)) + return result.mapError({ TIMEncryptedStorageError.keychainFailed($0) }) } private static func handleKeyServerResultAndEncryptData(keyServerResult: Result, id: StoreID, data: Data) -> Result { @@ -102,12 +98,8 @@ public final class TIMEncryptedStorage { let result: Result do { let encryptedData = try keyModel.encrypt(data: data) - let success = TIMKeychain.store(data: encryptedData, item: TIMKeychainStoreItem(id: id)) - if success { - result = .success(()) - } else { - result = .failure(.failedToStoreInKeychain) - } + let storeResult = TIMKeychain.store(data: encryptedData, item: TIMKeychainStoreItem(id: id)) + result = storeResult.mapError({ TIMEncryptedStorageError.keychainFailed($0) }) } catch let error as TIMEncryptedStorageError { result = .failure(error) @@ -120,7 +112,10 @@ public final class TIMEncryptedStorage { private static func loadFromKeychainAndDecrypt(id: StoreID, keyModel: TIMKeyModel) -> Result { let result: Result - if let encryptedData = TIMKeychain.get(item: TIMKeychainStoreItem(id: id)) { + let loadResult: Result = TIMKeychain.get(item: TIMKeychainStoreItem(id: id)) + + switch loadResult { + case .success(let encryptedData): do { let decryptedData = try keyModel.decrypt(data: encryptedData) result = .success(decryptedData) @@ -131,9 +126,10 @@ public final class TIMEncryptedStorage { catch { result = .failure(.failedToDecryptData) } - } else { - result = .failure(.failedToLoadDataInKeychain) + case .failure(let keychainError): + result = .failure(.keychainFailed(keychainError)) } + return result } } @@ -293,11 +289,17 @@ public extension TIMEncryptedStorage { // 3. Return result of store function let keychainKey = longSecretKeychainId(keyId: keyId) - if let longSecretData = TIMKeychain.getBiometricProtected(item: TIMKeychainStoreItem(id: keychainKey)), - let longSecret = String(data: longSecretData, encoding: .utf8) { - store(id: id, data: data, keyId: keyId, longSecret: longSecret, completion: completion) - } else { - completion(.failure(.failedToLoadLongSecretViaBiometric)) + let loadResult = TIMKeychain.getBiometricProtected(item: TIMKeychainStoreItem(id: keychainKey)) + + switch loadResult { + case .failure(let keychainError): + completion(.failure(.keychainFailed(keychainError))) + case .success(let longSecretData): + if let longSecret = String(data: longSecretData, encoding: .utf8) { + store(id: id, data: data, keyId: keyId, longSecret: longSecret, completion: completion) + } else { + completion(.failure(.unexpectedData)) + } } } @@ -365,14 +367,20 @@ public extension TIMEncryptedStorage { // 4. Decrypt data with encryption key // 5. Return decrypted data + longSecret let keychainKey = longSecretKeychainId(keyId: keyId) - if let longSecretData = TIMKeychain.getBiometricProtected(item: TIMKeychainStoreItem(id: keychainKey)), - let longSecret = String(data: longSecretData, encoding: .utf8) { - TIMKeyService.getKeyViaLongSecret(longSecret: longSecret, keyId: keyId) { (keyServerResult) in - let result = handleKeyServerResultAndDecryptData(keyServerResult: keyServerResult, id: id) - completion(result.map({ TIMESBiometricLoadResult(data: $0, longSecret: longSecret) })) + let longSecretResult = TIMKeychain.getBiometricProtected(item: TIMKeychainStoreItem(id: keychainKey)) + + switch longSecretResult { + case .success(let longSecretData): + if let longSecret = String(data: longSecretData, encoding: .utf8) { + TIMKeyService.getKeyViaLongSecret(longSecret: longSecret, keyId: keyId) { (keyServerResult) in + let result = handleKeyServerResultAndDecryptData(keyServerResult: keyServerResult, id: id) + completion(result.map({ TIMESBiometricLoadResult(data: $0, longSecret: longSecret) })) + } + } else { + completion(.failure(.unexpectedData)) } - } else { - completion(.failure(.failedToLoadLongSecretViaBiometric)) + case .failure(let keychainError): + completion(.failure(.keychainFailed(keychainError))) } } diff --git a/Tests/TIMEncryptedStorageTests/KeychainStoreItemTests.swift b/Tests/TIMEncryptedStorageTests/KeychainStoreItemTests.swift deleted file mode 100644 index 746cd14..0000000 --- a/Tests/TIMEncryptedStorageTests/KeychainStoreItemTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import XCTest -@testable import TIMEncryptedStorage - -final class KeychainStoreItemTests: XCTestCase { - func testKeychainStoreItem() { - let storeId: StoreID = "testStoreAndGet" - var item = TIMKeychainStoreItem(id: storeId) - XCTAssertEqual(storeId, item.parameters[kSecAttrAccount as String] as! String) - XCTAssertEqual(kSecClassGenericPassword, item.parameters[kSecClass as String] as! CFString) - XCTAssertEqual(2, item.parameters.count) - - item.enableUseAuthenticationUI(kSecUseAuthenticationUIAllow) - XCTAssertEqual(kSecUseAuthenticationUIAllow, item.parameters[kSecUseAuthenticationUI as String] as! CFString) - XCTAssertEqual(3, item.parameters.count) - - let biometricFlag: SecAccessControlCreateFlags - if #available(iOS 11.3, *) { - biometricFlag = .biometryAny - } else { - biometricFlag = .touchIDAny - } - let sacObject: SecAccessControl? = SecAccessControlCreateWithFlags( - kCFAllocatorDefault, - kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, - biometricFlag, - nil - ) - item.enableSafeAccessControl(sacObject!) - XCTAssertNotNil(item.parameters[kSecAttrAccessControl as String]) - XCTAssertEqual(4, item.parameters.count) - } - - // Keychain cannot be tested due to missing entitlements, when running tests 🤯 -} - diff --git a/Tests/TIMEncryptedStorageTests/TIMKeychainTests.swift b/Tests/TIMEncryptedStorageTests/TIMKeychainTests.swift new file mode 100644 index 0000000..7d7747f --- /dev/null +++ b/Tests/TIMEncryptedStorageTests/TIMKeychainTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import TIMEncryptedStorage + +final class KeychainStoreItemTests: XCTestCase { + func testKeychainStoreItem() { + let storeId: StoreID = "testStoreAndGet" + var item = TIMKeychainStoreItem(id: storeId) + XCTAssertEqual(storeId, item.parameters[kSecAttrAccount as String] as! String) + XCTAssertEqual(kSecClassGenericPassword, item.parameters[kSecClass as String] as! CFString) + XCTAssertEqual(2, item.parameters.count) + + item.enableUseAuthenticationUI(kSecUseAuthenticationUIAllow) + XCTAssertEqual(kSecUseAuthenticationUIAllow, item.parameters[kSecUseAuthenticationUI as String] as! CFString) + XCTAssertEqual(3, item.parameters.count) + + let biometricFlag: SecAccessControlCreateFlags + if #available(iOS 11.3, *) { + biometricFlag = .biometryAny + } else { + biometricFlag = .touchIDAny + } + let sacObject: SecAccessControl? = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + biometricFlag, + nil + ) + item.enableSafeAccessControl(sacObject!) + XCTAssertNotNil(item.parameters[kSecAttrAccessControl as String]) + XCTAssertEqual(4, item.parameters.count) + } + + func testStoreStatusMapping() { + let result = TIMKeychain.mapStoreStatusToResult(noErr) + assertResult(result, expectedDataType: Void.self, expectedError: nil) + + let result2 = TIMKeychain.mapStoreStatusToResult(errSecAuthFailed) + assertResult(result2, expectedDataType: nil, expectedError: .authenticationFailedForData) + + let result3 = TIMKeychain.mapStoreStatusToResult(errSecDeviceFailed) + assertResult(result3, expectedDataType: nil, expectedError: .failedToStoreData) + } + + func testLoadStatusMapping() { + let result = TIMKeychain.mapLoadStatusToResult(noErr, data: Data() as AnyObject) + assertResult(result, expectedDataType: Data.self, expectedError: nil) + + let result2 = TIMKeychain.mapLoadStatusToResult(errSecAuthFailed, data: nil) + assertResult(result2, expectedDataType: nil, expectedError: .authenticationFailedForData) + + let result3 = TIMKeychain.mapLoadStatusToResult(errSecDeviceFailed, data: nil) + assertResult(result3, expectedDataType: nil, expectedError: .failedToLoadData) + } + + private func assertResult(_ result: Result, expectedDataType: T.Type?, expectedError: TIMKeychainError?) { + switch result { + case .success(let dataType): + XCTAssertTrue(type(of: dataType) == expectedDataType) + XCTAssertNil(expectedError) + case .failure(let error): + XCTAssertEqual(expectedError, error) + XCTAssertNil(expectedDataType) + } + } + + // Keychain cannot be tested due to missing entitlements, when running tests 🤯 +} + +