diff --git a/AuthenticatorBridgeKit/SharedKeychain/Mocks/MockSharedKeychainStorage.swift b/AuthenticatorBridgeKit/SharedKeychain/Mocks/MockSharedKeychainStorage.swift deleted file mode 100644 index 3973be4ed6..0000000000 --- a/AuthenticatorBridgeKit/SharedKeychain/Mocks/MockSharedKeychainStorage.swift +++ /dev/null @@ -1,24 +0,0 @@ -import AuthenticatorBridgeKit -import BitwardenKit -import Foundation - -public class MockSharedKeychainStorage: SharedKeychainStorage { - public var storage = [SharedKeychainItem: any Codable]() - - public init() {} - - public func deleteValue(for item: SharedKeychainItem) async throws { - storage[item] = nil - } - - public func getValue(for item: SharedKeychainItem) async throws -> T where T: Codable { - guard let stored = storage[item] as? T else { - throw KeychainServiceError.keyNotFound(item) - } - return stored - } - - public func setValue(_ value: T, for item: SharedKeychainItem) async throws where T: Codable { - storage[item] = value - } -} diff --git a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepository.swift b/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepository.swift index a605a65d4c..7998921b71 100644 --- a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepository.swift +++ b/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepository.swift @@ -1,26 +1,41 @@ import BitwardenKit import Foundation +// MARK: - SharedKeychainItem + +/// Enumeration of support Keychain Items that can be placed in the `SharedKeychainRepository` +/// +public enum SharedKeychainItem: Equatable, KeychainItem { + /// A date at which a BWPM account automatically logs out. + case accountAutoLogout(userId: String) + + /// The keychain item for the authenticator encryption key. + case authenticatorKey + + /// The `SecAccessControlCreateFlags` level for this keychain item. + /// If `nil`, no extra protection is applied. + public var accessControlFlags: SecAccessControlCreateFlags? { nil } + + /// The protection level for this keychain item. + public var protection: CFTypeRef { kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly } + + /// The storage key for this keychain item. + public var unformattedKey: String { + switch self { + case let .accountAutoLogout(userId: userId): + "accountAutoLogout_\(userId)" + case .authenticatorKey: + "authenticatorKey" + } + } +} + // MARK: - SharedKeychainRepository /// A repository for managing keychain items to be shared between Password Manager and Authenticator. /// This should be the entry point in retrieving items from the shared keychain. public protocol SharedKeychainRepository { // sourcery: AutoMockable - /// Deletes the authenticator key. - /// - func deleteAuthenticatorKey() async throws - - /// Gets the authenticator key. - /// - /// - Returns: Data representing the authenticator key. - /// - func getAuthenticatorKey() async throws -> Data - - /// Stores the access token for a user in the keychain. - /// - /// - Parameter value: The authenticator key to store. - /// - func setAuthenticatorKey(_ value: Data) async throws + // MARK: AccountAutoLogoutTime /// Gets when a user account should automatically log out. /// @@ -42,40 +57,40 @@ public protocol SharedKeychainRepository { // sourcery: AutoMockable _ value: Date?, userId: String, ) async throws -} -public class DefaultSharedKeychainRepository: SharedKeychainRepository { - /// The shared keychain storage used by the repository. - let storage: SharedKeychainStorage + // MARK: AuthenticatorKey - /// Initialize a `DefaultSharedKeychainStorage`. + /// Deletes the authenticator key. /// - /// - Parameters: - /// - storage: The shared keychain storage used by the repository - public init(storage: SharedKeychainStorage) { - self.storage = storage - } - - public func deleteAuthenticatorKey() async throws { - try await storage.deleteValue(for: .authenticatorKey) - } + func deleteAuthenticatorKey() async throws /// Gets the authenticator key. /// /// - Returns: Data representing the authenticator key. /// - public func getAuthenticatorKey() async throws -> Data { - try await storage.getValue(for: .authenticatorKey) - } + func getAuthenticatorKey() async throws -> Data /// Stores the access token for a user in the keychain. /// /// - Parameter value: The authenticator key to store. /// - public func setAuthenticatorKey(_ value: Data) async throws { - try await storage.setValue(value, for: .authenticatorKey) + func setAuthenticatorKey(_ value: Data) async throws +} + +public class DefaultSharedKeychainRepository: SharedKeychainRepository { + /// The keychain service facade used by the repository. + let keychainServiceFacade: KeychainServiceFacade + + /// Initialize a `DefaultSharedKeychainRepository`. + /// + /// - Parameters: + /// - keychainServiceFacade: The keychain service facade used by the repository + public init(keychainServiceFacade: KeychainServiceFacade) { + self.keychainServiceFacade = keychainServiceFacade } + // MARK: AccountAutoLogoutTime + /// Gets when a user account should automatically log out. /// /// - Parameters: @@ -83,7 +98,11 @@ public class DefaultSharedKeychainRepository: SharedKeychainRepository { /// - Returns: The time the user should be automatically logged out. If `nil`, then the user should not be. /// public func getAccountAutoLogoutTime(userId: String) async throws -> Date? { - try await storage.getValue(for: .accountAutoLogout(userId: userId)) + do { + return try await keychainServiceFacade.getValue(for: SharedKeychainItem.accountAutoLogout(userId: userId)) + } catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound { + return nil + } } /// Sets when a user account should automatically log out. @@ -96,6 +115,28 @@ public class DefaultSharedKeychainRepository: SharedKeychainRepository { _ value: Date?, userId: String, ) async throws { - try await storage.setValue(value, for: .accountAutoLogout(userId: userId)) + try await keychainServiceFacade.setValue(value, for: SharedKeychainItem.accountAutoLogout(userId: userId)) + } + + // MARK: AuthenticatorKey + + public func deleteAuthenticatorKey() async throws { + try await keychainServiceFacade.deleteValue(for: SharedKeychainItem.authenticatorKey) + } + + /// Gets the authenticator key. + /// + /// - Returns: Data representing the authenticator key. + /// + public func getAuthenticatorKey() async throws -> Data { + try await keychainServiceFacade.getValue(for: SharedKeychainItem.authenticatorKey) + } + + /// Stores the access token for a user in the keychain. + /// + /// - Parameter value: The authenticator key to store. + /// + public func setAuthenticatorKey(_ value: Data) async throws { + try await keychainServiceFacade.setValue(value, for: SharedKeychainItem.authenticatorKey) } } diff --git a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepositoryTests.swift b/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepositoryTests.swift index 69a97f5c0e..31dac9e324 100644 --- a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepositoryTests.swift +++ b/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainRepositoryTests.swift @@ -1,76 +1,190 @@ import AuthenticatorBridgeKit -import AuthenticatorBridgeKitMocks import BitwardenKit import BitwardenKitMocks -import CryptoKit import Foundation import XCTest final class SharedKeychainRepositoryTests: BitwardenTestCase { // MARK: Properties - var storage: MockSharedKeychainStorage! + var keychainServiceFacade: MockKeychainServiceFacade! var subject: DefaultSharedKeychainRepository! // MARK: Setup & Teardown override func setUp() { - storage = MockSharedKeychainStorage() + super.setUp() + + keychainServiceFacade = MockKeychainServiceFacade() subject = DefaultSharedKeychainRepository( - storage: storage, + keychainServiceFacade: keychainServiceFacade, ) } override func tearDown() { - storage = nil + super.tearDown() + + keychainServiceFacade = nil subject = nil } - // MARK: Tests + // MARK: Tests - AccountAutoLogoutTime + + /// `getAccountAutoLogoutTime()` retrieves the account auto-logout time via the facade. + /// + func test_getAccountAutoLogoutTime_success() async throws { + let date = Date(timeIntervalSince1970: 12345) + keychainServiceFacade.getValueReturnValue = try String( + data: JSONEncoder.defaultEncoder.encode(date), + encoding: .utf8, + ) + + let result = try await subject.getAccountAutoLogoutTime(userId: "1") + + XCTAssertEqual(result, date) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + SharedKeychainItem.accountAutoLogout(userId: "1").unformattedKey, + ) + } + + /// `getAccountAutoLogoutTime()` returns nil when the key is not found. + /// + func test_getAccountAutoLogoutTime_keyNotFound() async throws { + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + SharedKeychainItem.accountAutoLogout(userId: "1"), + ) + + let result = try await subject.getAccountAutoLogoutTime(userId: "1") + + XCTAssertNil(result) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + SharedKeychainItem.accountAutoLogout(userId: "1").unformattedKey, + ) + } + + /// `getAccountAutoLogoutTime()` rethrows errors other than keyNotFound. + /// + func test_getAccountAutoLogoutTime_rethrowsError() async { + keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(-1) + + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + _ = try await subject.getAccountAutoLogoutTime(userId: "1") + } + } + + /// `setAccountAutoLogoutTime()` sets the account auto-logout time via the facade. + /// + func test_setAccountAutoLogoutTime_success() async throws { + let date = Date(timeIntervalSince1970: 12345) + + try await subject.setAccountAutoLogoutTime(date, userId: "1") + + XCTAssertTrue(keychainServiceFacade.setValueCalled) + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + SharedKeychainItem.accountAutoLogout(userId: "1").unformattedKey, + ) + } + + /// `setAccountAutoLogoutTime()` deletes the account auto-logout time when nil is passed. + /// + func test_setAccountAutoLogoutTime_nil() async throws { + try await subject.setAccountAutoLogoutTime(nil, userId: "1") + + XCTAssertEqual( + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + SharedKeychainItem.accountAutoLogout(userId: "1").unformattedKey, + ) + } + + /// `setAccountAutoLogoutTime()` rethrows errors from the facade. + /// + func test_setAccountAutoLogoutTime_rethrowsError() async { + keychainServiceFacade.setValueThrowableError = KeychainServiceError.osStatusError(-1) + let date = Date(timeIntervalSince1970: 12345) + + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + try await subject.setAccountAutoLogoutTime(date, userId: "1") + } + } + + // MARK: Tests - AuthenticatorKey - /// `deleteAuthenticatorKey()` deletes the authenticator key from storage. + /// `deleteAuthenticatorKey()` deletes the authenticator key via the facade. + /// func test_deleteAuthenticatorKey_success() async throws { - storage.storage[.authenticatorKey] = Data() try await subject.deleteAuthenticatorKey() - XCTAssertNil(storage.storage[.authenticatorKey]) + + XCTAssertEqual( + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + SharedKeychainItem.authenticatorKey.unformattedKey, + ) + } + + /// `deleteAuthenticatorKey()` rethrows errors from the facade. + /// + func test_deleteAuthenticatorKey_rethrowsError() async { + keychainServiceFacade.deleteValueThrowableError = KeychainServiceError.osStatusError(-1) + + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + try await subject.deleteAuthenticatorKey() + } } - /// `getAuthenticatorKey()` retrieves the authenticator key from storage. + /// `getAuthenticatorKey()` retrieves the authenticator key via the facade. + /// func test_getAuthenticatorKey_success() async throws { - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data(Array($0)) } - storage.storage[.authenticatorKey] = data - let authenticatorKey = try await subject.getAuthenticatorKey() - XCTAssertEqual(authenticatorKey, data) + let data = Data([1, 2, 3]) + keychainServiceFacade.getValueReturnValue = try String( + data: JSONEncoder.defaultEncoder.encode(data), + encoding: .utf8, + ) + + let result = try await subject.getAuthenticatorKey() + + XCTAssertEqual(result, data) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + SharedKeychainItem.authenticatorKey.unformattedKey, + ) } - /// `getAuthenticatorKey()` throws an error if the key is not in storage. - func test_getAuthenticatorKey_nil() async throws { + /// `getAuthenticatorKey()` throws `keyNotFound` when the key is not in storage. + /// + func test_getAuthenticatorKey_keyNotFound() async { + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + SharedKeychainItem.authenticatorKey, + ) + await assertAsyncThrows(error: KeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey)) { _ = try await subject.getAuthenticatorKey() } } - /// `setAuthenticatorKey()` sets the authenticator key in storage. + /// `setAuthenticatorKey()` sets the authenticator key via the facade. + /// func test_setAuthenticatorKey_success() async throws { - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data(Array($0)) } + let data = Data([1, 2, 3]) + try await subject.setAuthenticatorKey(data) - XCTAssertEqual(storage.storage[.authenticatorKey] as? Data, data) - } - /// `getAccountAutoLogoutTime()` retrieves the last active time from storage. - func test_getBWPMAccountAutoLogoutTime_success() async throws { - let date = Date(timeIntervalSince1970: 12345) - storage.storage[.accountAutoLogout(userId: "1")] = date - let lastActiveTime = try await subject.getAccountAutoLogoutTime(userId: "1") - XCTAssertEqual(lastActiveTime, date) + XCTAssertTrue(keychainServiceFacade.setValueCalled) + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + SharedKeychainItem.authenticatorKey.unformattedKey, + ) } - /// `setAccountAutoLogoutTime()` sets the last active time in storage. - func test_setBWPMAccountAutoLogoutTime_success() async throws { - let date = Date(timeIntervalSince1970: 12345) - try await subject.setAccountAutoLogoutTime(date, userId: "1") - XCTAssertEqual(storage.storage[.accountAutoLogout(userId: "1")] as? Date, date) + /// `setAuthenticatorKey()` rethrows errors from the facade. + /// + func test_setAuthenticatorKey_rethrowsError() async { + keychainServiceFacade.setValueThrowableError = KeychainServiceError.osStatusError(-1) + let data = Data([1, 2, 3]) + + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + try await subject.setAuthenticatorKey(data) + } } } diff --git a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorage.swift b/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorage.swift deleted file mode 100644 index 010c1f2702..0000000000 --- a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorage.swift +++ /dev/null @@ -1,164 +0,0 @@ -import BitwardenKit -import Foundation - -// MARK: - SharedKeychainItem - -/// Enumeration of support Keychain Items that can be placed in the `SharedKeychainRepository` -/// -public enum SharedKeychainItem: Equatable, Hashable, Sendable, KeychainItem { - /// The keychain item for the authenticator encryption key. - case authenticatorKey - - /// A date at which a BWPM account automatically logs out. - case accountAutoLogout(userId: String) - - public var accessControlFlags: SecAccessControlCreateFlags? { nil } - - public var protection: CFTypeRef { kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly } - - /// The storage key for this keychain item. - /// - public var unformattedKey: String { - switch self { - case .authenticatorKey: - "authenticatorKey" - case let .accountAutoLogout(userId: userId): - "accountAutoLogout_\(userId)" - } - } -} - -/// A storage layer for managing keychain items that are shared between Password Manager -/// and Authenticator. In particular, it is able to construct the appropriate queries to -/// talk with a `SharedKeychainService`. -/// -public protocol SharedKeychainStorage { - /// Deletes the value in the keychain for the given item. - /// - /// - Parameters: - /// - value: The value (Data) to be stored into the keychain - /// - item: The item for which to store the value in the keychain. - /// - func deleteValue(for item: SharedKeychainItem) async throws - - /// Retrieve the value for the specific item from the Keychain Service. - /// - /// - Parameter item: the keychain item for which to retrieve a value. - /// - Returns: The value (Data) stored in the keychain for the given item. - /// - func getValue(for item: SharedKeychainItem) async throws -> T - - /// Store a given value into the keychain for the given item. - /// - /// - Parameters: - /// - value: The value (Data) to be stored into the keychain - /// - item: The item for which to store the value in the keychain. - /// - func setValue(_ value: T, for item: SharedKeychainItem) async throws -} - -public class DefaultSharedKeychainStorage: SharedKeychainStorage { - // MARK: Properties - - /// The keychain service used by the repository - /// - private let keychainService: KeychainService - - /// An identifier for the shared access group used by the application. - /// - /// Example: "group.com.8bit.bitwarden" - /// - private let sharedAppGroupIdentifier: String - - // MARK: Initialization - - /// Initialize a `DefaultSharedKeychainStorage`. - /// - /// - Parameters: - /// - keychainService: The keychain service used by the repository - /// - sharedAppGroupIdentifier: An identifier for the shared access group used by the application. - public init( - keychainService: KeychainService, - sharedAppGroupIdentifier: String, - ) { - self.keychainService = keychainService - self.sharedAppGroupIdentifier = sharedAppGroupIdentifier - } - - // MARK: Methods - - public func deleteValue(for item: SharedKeychainItem) async throws { - try keychainService.delete( - query: item.baseQuery(sharedAppGroupIdentifier: sharedAppGroupIdentifier), - ) - } - - public func getValue(for item: SharedKeychainItem) async throws -> T { - var query = item.baseQueryAttributes(sharedAppGroupIdentifier: sharedAppGroupIdentifier) - query[kSecMatchLimit] = kSecMatchLimitOne - query[kSecReturnData] = true - query[kSecReturnAttributes] = true - - let foundItem = try keychainService.search(query: query as CFDictionary) - - guard let resultDictionary = foundItem as? [String: Any], - let data = resultDictionary[kSecValueData as String] as? Data else { - throw KeychainServiceError.keyNotFound(item) - } - - let object = try JSONDecoder.defaultDecoder.decode(T.self, from: data) - return object - } - - public func setValue(_ value: T, for item: SharedKeychainItem) async throws { - let valueData = try JSONEncoder.defaultEncoder.encode(value) - - do { - // Try to update first - if item exists, this avoids delete-then-add race condition - try keychainService.update( - query: item.baseQuery(sharedAppGroupIdentifier: sharedAppGroupIdentifier), - attributes: [kSecValueData: valueData] as CFDictionary, - ) - } catch KeychainServiceError.osStatusError(errSecItemNotFound) { - // Item doesn't exist, so add it - var attributes = item.baseQueryAttributes(sharedAppGroupIdentifier: sharedAppGroupIdentifier) - attributes[kSecValueData] = valueData - try keychainService.add(attributes: attributes as CFDictionary) - } - } -} - -// MARK: - SharedKeychainItem+Query - -private extension SharedKeychainItem { - /// Builds the base query attributes for this keychain item as a Swift dictionary. - /// - /// Use this method when you need to add additional attributes to the query - /// (e.g., `kSecReturnData`, `kSecMatchLimit`, or `kSecValueData`) before passing it to the - /// keychain service. You can modify the returned dictionary and then cast it to `CFDictionary` - /// when ready. - /// - /// - Parameter sharedAppGroupIdentifier: The shared app group identifier. - /// - Returns: A mutable dictionary with the base query attributes. - /// - func baseQueryAttributes(sharedAppGroupIdentifier: String) -> [CFString: Any] { - [ - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - kSecAttrAccessGroup: sharedAppGroupIdentifier, - kSecAttrAccount: unformattedKey, - kSecClass: kSecClassGenericPassword, - ] - } - - /// Builds the base query for this keychain item as a CFDictionary. - /// - /// Use this method when you need the query as-is without modifications. This is convenient - /// for operations like delete or update that don't require additional attributes. - /// - /// - Parameter sharedAppGroupIdentifier: The shared app group identifier. - /// - Returns: The base query as a CFDictionary. - /// - func baseQuery(sharedAppGroupIdentifier: String) -> CFDictionary { - baseQueryAttributes(sharedAppGroupIdentifier: sharedAppGroupIdentifier) as CFDictionary - } -} diff --git a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorageTests.swift b/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorageTests.swift deleted file mode 100644 index f3704b372b..0000000000 --- a/AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorageTests.swift +++ /dev/null @@ -1,199 +0,0 @@ -import AuthenticatorBridgeKit -import AuthenticatorBridgeKitMocks -import BitwardenKit -import BitwardenKitMocks -import CryptoKit -import Foundation -import XCTest - -final class SharedKeychainStorageTests: BitwardenTestCase { - // MARK: Properties - - let accessGroup = "group.com.example.bitwarden" - var keychainService: MockKeychainService! - var subject: SharedKeychainStorage! - - // MARK: Setup & Teardown - - override func setUp() { - keychainService = MockKeychainService() - subject = DefaultSharedKeychainStorage( - keychainService: keychainService, - sharedAppGroupIdentifier: accessGroup, - ) - } - - override func tearDown() { - keychainService = nil - subject = nil - } - - // MARK: Tests - - /// Verify that `deleteValue(for:)` issues a delete with the correct search attributes specified. - /// - func test_deleteValue_success() async throws { - try await subject.deleteValue(for: .authenticatorKey) - - let queries = try XCTUnwrap(keychainService.deleteQueries as? [[CFString: Any]]) - XCTAssertEqual(queries.count, 1) - - let query = try XCTUnwrap(queries.first) - try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessGroup] as? String), accessGroup) - try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessible] as? String), - String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) - try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccount] as? String), - SharedKeychainItem.authenticatorKey.unformattedKey) - try XCTAssertEqual(XCTUnwrap(query[kSecClass] as? String), - String(kSecClassGenericPassword)) - } - - /// Verify that `getValue(for:)` returns a value successfully when one is set. Additionally, verify the - /// search attributes are specified correctly. - /// - func test_getValue_success() async throws { - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data(Array($0)) } - let encodedData = try JSONEncoder.defaultEncoder.encode(data) - - keychainService.setSearchResultData(encodedData) - - let returnData: Data = try await subject.getValue(for: .authenticatorKey) - XCTAssertEqual(returnData, data) - - let query = try XCTUnwrap(keychainService.searchQuery as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessGroup] as? String), accessGroup) - try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccessible] as? String), - String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) - try XCTAssertEqual(XCTUnwrap(query[kSecAttrAccount] as? String), - SharedKeychainItem.authenticatorKey.unformattedKey) - try XCTAssertEqual(XCTUnwrap(query[kSecClass] as? String), String(kSecClassGenericPassword)) - try XCTAssertEqual(XCTUnwrap(query[kSecMatchLimit] as? String), String(kSecMatchLimitOne)) - try XCTAssertTrue(XCTUnwrap(query[kSecReturnAttributes] as? Bool)) - try XCTAssertTrue(XCTUnwrap(query[kSecReturnData] as? Bool)) - } - - /// Verify that `getAuthenticatorKey()` fails with a `keyNotFound` error when an unexpected - /// result is returned instead of the key data from the keychain - /// - func test_getValue_badResult() async throws { - let key = SharedKeychainItem.accountAutoLogout(userId: "1") - let error = KeychainServiceError.keyNotFound(key) - keychainService.searchResult = .success([kSecValueData as String: NSObject()] as AnyObject) - - await assertAsyncThrows(error: error) { - let _: Data = try await subject.getValue(for: key) - } - } - - /// Verify that `getValue(for:)` fails with a `keyNotFound` error when a nil - /// result is returned instead of the key data from the keychain - /// - func test_getValue_nilResult() async throws { - let key = SharedKeychainItem.accountAutoLogout(userId: "1") - let error = KeychainServiceError.keyNotFound(key) - keychainService.searchResult = .success(nil) - - await assertAsyncThrows(error: error) { - let _: Data = try await subject.getValue(for: key) - } - } - - /// Verify that `getValue(for:)` fails with an error when the Authenticator key is not - /// present in the keychain - /// - func test_getAuthenticatorKey_keyNotFound() async throws { - let error = KeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) - keychainService.searchResult = .failure(error) - - await assertAsyncThrows(error: error) { - let _: Data = try await subject.getValue(for: .authenticatorKey) - } - } - - /// Verify that `setValue(_:for:)` sets a value with the correct search attributes specified. - /// - func test_setAuthenticatorKey_success() async throws { - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data(Array($0)) } - let encodedData = try JSONEncoder.defaultEncoder.encode(data) - try await subject.setValue(data, for: .authenticatorKey) - - let attributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccessGroup] as? String), accessGroup) - try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccessible] as? String), - String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) - try XCTAssertEqual(XCTUnwrap(attributes[kSecAttrAccount] as? String), - SharedKeychainItem.authenticatorKey.unformattedKey) - try XCTAssertEqual(XCTUnwrap(attributes[kSecClass] as? String), - String(kSecClassGenericPassword)) - try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), encodedData) - } - - /// Verify that `setValue(_:for:)` attempts to update before adding when item doesn't exist. - /// - func test_setValue_addsNewItem_afterUpdateFails() async throws { - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data(Array($0)) } - let encodedData = try JSONEncoder.defaultEncoder.encode(data) - - keychainService.updateResult = .failure(KeychainServiceError.osStatusError(errSecItemNotFound)) - - try await subject.setValue(data, for: .authenticatorKey) - - // Verify update was attempted first - let updateQuery = try XCTUnwrap(keychainService.updateQuery as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccessGroup] as? String), accessGroup) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccessible] as? String), - String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccount] as? String), - SharedKeychainItem.authenticatorKey.unformattedKey) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecClass] as? String), - String(kSecClassGenericPassword)) - XCTAssertNil(updateQuery[kSecValueData]) - - let updateAttributes = try XCTUnwrap(keychainService.updateAttributes as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(updateAttributes[kSecValueData] as? Data), encodedData) - XCTAssertEqual(updateAttributes.count, 1) - - // Verify add was called after update failed - let addAttributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecAttrAccessGroup] as? String), accessGroup) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecAttrAccessible] as? String), - String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecAttrAccount] as? String), - SharedKeychainItem.authenticatorKey.unformattedKey) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecClass] as? String), - String(kSecClassGenericPassword)) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecValueData] as? Data), encodedData) - } - - /// Verify that `setValue(_:for:)` updates an existing item without calling add. - /// - func test_setValue_updatesExistingItem() async throws { - let key = SymmetricKey(size: .bits256) - let data = key.withUnsafeBytes { Data(Array($0)) } - let encodedData = try JSONEncoder.defaultEncoder.encode(data) - - keychainService.updateResult = .success(()) - - try await subject.setValue(data, for: .authenticatorKey) - - // Verify update was called with correct query and attributes - let updateQuery = try XCTUnwrap(keychainService.updateQuery as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccessGroup] as? String), accessGroup) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccessible] as? String), - String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccount] as? String), - SharedKeychainItem.authenticatorKey.unformattedKey) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecClass] as? String), - String(kSecClassGenericPassword)) - - let updateAttributes = try XCTUnwrap(keychainService.updateAttributes as? [CFString: Any]) - try XCTAssertEqual(XCTUnwrap(updateAttributes[kSecValueData] as? Data), encodedData) - XCTAssertEqual(updateAttributes.count, 1) - - // Verify add was NOT called - XCTAssertNil(keychainService.addAttributes) - } -} diff --git a/AuthenticatorShared/Core/Auth/Services/KeychainRepository.swift b/AuthenticatorShared/Core/Auth/Services/KeychainRepository.swift index 7dbcddf65d..d32774bf7b 100644 --- a/AuthenticatorShared/Core/Auth/Services/KeychainRepository.swift +++ b/AuthenticatorShared/Core/Auth/Services/KeychainRepository.swift @@ -1,7 +1,7 @@ import BitwardenKit import Foundation -// MARK: - KeychainItem +// MARK: - AuthenticatorKeychainItem enum AuthenticatorKeychainItem: Equatable, KeychainItem { /// The keychain item for biometrics protected user auth key. @@ -38,13 +38,7 @@ enum AuthenticatorKeychainItem: Equatable, KeychainItem { // MARK: - KeychainRepository -protocol KeychainRepository: AnyObject { - /// Attempts to delete the userAuthKey from the keychain. - /// - /// - Parameter item: The KeychainItem to be deleted. - /// - func deleteUserAuthKey(for item: AuthenticatorKeychainItem) async throws - +protocol KeychainRepository: AnyObject { // sourcery: AutoMockable /// Gets the stored secret key for a user from the keychain. /// /// - Parameters: @@ -53,13 +47,6 @@ protocol KeychainRepository: AnyObject { /// func getSecretKey(userId: String) async throws -> String - /// Gets a user auth key value. - /// - /// - Parameter item: The storage key of the user auth key. - /// - Returns: A string representing the user auth key. - /// - func getUserAuthKeyValue(for item: AuthenticatorKeychainItem) async throws -> String - /// Stores the secret key for a user in the keychain /// /// - Parameters: @@ -67,24 +54,6 @@ protocol KeychainRepository: AnyObject { /// - userId: The user's ID /// func setSecretKey(_ value: String, userId: String) async throws - - /// Sets a user auth key/value pair. - /// - /// - Parameters: - /// - item: The storage key for this auth key. - /// - value: A `String` representing the user auth key. - /// - func setUserAuthKey(for item: AuthenticatorKeychainItem, value: String) async throws -} - -extension KeychainRepository { - /// The format for storing the `unformattedKey` of a `KeychainItem`. - /// The first value should be a unique appID from the `appIDService`. - /// The second value is the `unformattedKey` - /// - /// Example: `bwKeyChainStorage:1234567890:biometric_key_98765` - /// - var storageKeyFormat: String { "bwaKeychainStorage:%@:%@" } } // MARK: - DefaultKeychainRepository @@ -92,161 +61,24 @@ extension KeychainRepository { class DefaultKeychainRepository: KeychainRepository { // MARK: Properties - /// A service used to provide unique app ids. + /// The keychain service facade used by the repository. /// - let appIDService: AppIDService - - /// An identifier for this application and extensions. - /// ie: "LTZ2PFU5D6.com.8bit.bitwarden" - /// - var appSecAttrService: String { - Bundle.main.appIdentifier - } - - /// An identifier for this application group and extensions - /// ie: "group.LTZ2PFU5D6.com.8bit.bitwarden" - /// - var appSecAttrAccessGroup: String { - Bundle.main.groupIdentifier - } - - /// The keychain service used by the repository - /// - let keychainService: KeychainService + let keychainServiceFacade: KeychainServiceFacade // MARK: Initialization - init( - appIDService: AppIDService, - keychainService: KeychainService, - ) { - self.appIDService = appIDService - self.keychainService = keychainService - } - - // MARK: Methods - - /// Generates a formatted storage key for a keychain item. - /// - /// - Parameter item: The keychain item that needs a formatted key. - /// - Returns: A formatted storage key. - /// - func formattedKey(for item: AuthenticatorKeychainItem) async -> String { - let appId = await appIDService.getOrCreateAppID() - return String(format: storageKeyFormat, appId, item.unformattedKey) - } - - /// Gets the value associated with the keychain item from the keychain. - /// - /// - Parameter item: The keychain item used to fetch the associated value. - /// - Returns: The fetched value associated with the keychain item. - /// - func getValue(for item: AuthenticatorKeychainItem) async throws -> String { - let foundItem = try await keychainService.search( - query: keychainQueryValues( - for: item, - adding: [ - kSecMatchLimit: kSecMatchLimitOne, - kSecReturnData: true, - kSecReturnAttributes: true, - ], - ), - ) - - if let resultDictionary = foundItem as? [String: Any], - let data = resultDictionary[kSecValueData as String] as? Data, - let string = String(data: data, encoding: .utf8) { - guard !string.isEmpty else { - throw KeychainServiceError.keyNotFound(item) - } - return string - } - - throw KeychainServiceError.keyNotFound(item) - } - - /// The core key/value pairs for Keychain operations - /// - /// - Parameter item: The `KeychainItem` to be queried. - /// - func keychainQueryValues( - for item: AuthenticatorKeychainItem, - adding additionalPairs: [CFString: Any] = [:], - ) async -> CFDictionary { - // Prepare a formatted `kSecAttrAccount` value. - let formattedSecAttrAccount = await formattedKey(for: item) - - // Configure the base dictionary - var result: [CFString: Any] = [ - kSecAttrAccount: formattedSecAttrAccount, - kSecAttrAccessGroup: appSecAttrAccessGroup, - kSecAttrService: appSecAttrService, - kSecClass: kSecClassGenericPassword, - ] - - // Add the additional key value pairs. - additionalPairs.forEach { key, value in - result[key] = value - } - - return result as CFDictionary - } - - /// Sets a value associated with a keychain item in the keychain. - /// - /// - Parameters: - /// - value: The value associated with the keychain item to set. - /// - item: The keychain item used to set the associated value. - /// - func setValue(_ value: String, for item: AuthenticatorKeychainItem) async throws { - let accessControl = try keychainService.accessControl( - protection: item.protection, - for: item.accessControlFlags ?? [], - ) - let baseQuery = await keychainQueryValues(for: item) - let updateAttributes: CFDictionary = [ - kSecAttrAccessControl: accessControl as Any, - kSecValueData: Data(value.utf8), - ] as CFDictionary - - do { - // Try to update first - if item exists, this avoids delete-then-add race condition - try keychainService.update(query: baseQuery, attributes: updateAttributes) - } catch KeychainServiceError.osStatusError(errSecItemNotFound) { - // Item doesn't exist, so add it - let addAttributes = await keychainQueryValues( - for: item, - adding: [ - kSecAttrAccessControl: accessControl as Any, - kSecValueData: Data(value.utf8), - ], - ) - try keychainService.add(attributes: addAttributes) - } + init(keychainServiceFacade: KeychainServiceFacade) { + self.keychainServiceFacade = keychainServiceFacade } } extension DefaultKeychainRepository { - func deleteUserAuthKey(for item: AuthenticatorKeychainItem) async throws { - try await keychainService.delete( - query: keychainQueryValues(for: item), - ) - } - func getSecretKey(userId: String) async throws -> String { - try await getValue(for: .secretKey(userId: userId)) - } - - func getUserAuthKeyValue(for item: AuthenticatorKeychainItem) async throws -> String { - try await getValue(for: item) + try await keychainServiceFacade.getValue(for: AuthenticatorKeychainItem.secretKey(userId: userId)) } func setSecretKey(_ value: String, userId: String) async throws { - try await setValue(value, for: .secretKey(userId: userId)) - } - - func setUserAuthKey(for item: AuthenticatorKeychainItem, value: String) async throws { - try await setValue(value, for: item) + try await keychainServiceFacade.setValue(value, for: AuthenticatorKeychainItem.secretKey(userId: userId)) } } @@ -254,17 +86,14 @@ extension DefaultKeychainRepository { extension DefaultKeychainRepository: BiometricsKeychainRepository { func deleteUserBiometricAuthKey(userId: String) async throws { - let key = AuthenticatorKeychainItem.biometrics(userId: userId) - try await deleteUserAuthKey(for: key) + try await keychainServiceFacade.deleteValue(for: AuthenticatorKeychainItem.biometrics(userId: userId)) } func getUserBiometricAuthKey(userId: String) async throws -> String { - let key = AuthenticatorKeychainItem.biometrics(userId: userId) - return try await getUserAuthKeyValue(for: key) + try await keychainServiceFacade.getValue(for: AuthenticatorKeychainItem.biometrics(userId: userId)) } func setUserBiometricAuthKey(userId: String, value: String) async throws { - let key = AuthenticatorKeychainItem.biometrics(userId: userId) - try await setUserAuthKey(for: key, value: value) + try await keychainServiceFacade.setValue(value, for: AuthenticatorKeychainItem.biometrics(userId: userId)) } } diff --git a/AuthenticatorShared/Core/Auth/Services/KeychainRepositoryTests.swift b/AuthenticatorShared/Core/Auth/Services/KeychainRepositoryTests.swift new file mode 100644 index 0000000000..e9c02dba96 --- /dev/null +++ b/AuthenticatorShared/Core/Auth/Services/KeychainRepositoryTests.swift @@ -0,0 +1,143 @@ +import BitwardenKit +import BitwardenKitMocks +import Foundation +import Testing + +@testable import AuthenticatorShared + +// MARK: - KeychainRepositoryTests + +struct KeychainRepositoryTests { + // MARK: Properties + + var keychainServiceFacade: MockKeychainServiceFacade! + var subject: DefaultKeychainRepository! + + // MARK: Setup & Teardown + + init() { + keychainServiceFacade = MockKeychainServiceFacade() + subject = DefaultKeychainRepository(keychainServiceFacade: keychainServiceFacade) + } + + // MARK: Tests - secretKey + + /// `getSecretKey(userId:)` returns the secret key from the façade. + @Test + func getSecretKey_success() async throws { + keychainServiceFacade.getValueReturnValue = "secret-value" + + let result = try await subject.getSecretKey(userId: "user-1") + + #expect(result == "secret-value") + + let actualReceivedItem = keychainServiceFacade.getValueReceivedItem as? AuthenticatorKeychainItem + let expectedReceivedItem = AuthenticatorKeychainItem.secretKey(userId: "user-1") + #expect(actualReceivedItem == expectedReceivedItem) + } + + /// `getSecretKey(userId:)` rethrows errors from the façade. + @Test + func getSecretKey_rethrows() async { + let item = AuthenticatorKeychainItem.secretKey(userId: "user-1") + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound(item) + + await #expect(throws: KeychainServiceError.keyNotFound(item)) { + _ = try await subject.getSecretKey(userId: "user-1") + } + } + + /// `setSecretKey(_:userId:)` stores the secret key via the façade. + @Test + func setSecretKey_success() async throws { + try await subject.setSecretKey("new-secret", userId: "user-1") + + #expect(keychainServiceFacade.setValueReceivedArguments?.value == "new-secret") + + let actualReceivedItem = keychainServiceFacade.setValueReceivedArguments?.item as? AuthenticatorKeychainItem + let expectedReceivedItem = AuthenticatorKeychainItem.secretKey(userId: "user-1") + #expect(actualReceivedItem == expectedReceivedItem) + } + + /// `setSecretKey(_:userId:)` rethrows errors from the façade. + @Test + func setSecretKey_rethrows() async { + let expectedError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + keychainServiceFacade.setValueThrowableError = expectedError + + await #expect(throws: expectedError) { + try await subject.setSecretKey("new-secret", userId: "user-1") + } + } + + // MARK: Tests - BiometricsKeychainRepository + + /// `deleteUserBiometricAuthKey(userId:)` deletes the user biometrics auth key via the façade. + @Test + func deleteUserBiometricAuthKey_success() async throws { + try await subject.deleteUserBiometricAuthKey(userId: "user-1") + + let actualReceivedItem = keychainServiceFacade.deleteValueReceivedItem as? AuthenticatorKeychainItem + let expectedReceivedItem = AuthenticatorKeychainItem.biometrics(userId: "user-1") + #expect(actualReceivedItem == expectedReceivedItem) + } + + /// `deleteUserBiometricAuthKey(userId:)` rethrows errors from the façade. + @Test + func deleteUserBiometricAuthKey_rethrows() async { + let expectedError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + keychainServiceFacade.deleteValueThrowableError = expectedError + + await #expect(throws: expectedError) { + try await subject.deleteUserBiometricAuthKey(userId: "user-1") + } + } + + /// `getUserBiometricAuthKey(userId:)` returns the user biometrics auth key from the façade. + @Test + func getUserBiometricAuthKey_success() async throws { + keychainServiceFacade.getValueReturnValue = "biometric-key" + + let result = try await subject.getUserBiometricAuthKey(userId: "user-1") + + #expect(result == "biometric-key") + + let actualReceivedItem = keychainServiceFacade.getValueReceivedItem as? AuthenticatorKeychainItem + let expectedReceivedItem = AuthenticatorKeychainItem.biometrics(userId: "user-1") + #expect(actualReceivedItem == expectedReceivedItem) + } + + /// `getUserBiometricAuthKey(userId:)` rethrows errors from the façade. + @Test + func getUserBiometricAuthKey_rethrows() async { + let expectedError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + keychainServiceFacade.getValueThrowableError = expectedError + + await #expect(throws: expectedError) { + _ = try await subject.getUserBiometricAuthKey(userId: "user-1") + } + } + + /// `setUserBiometricAuthKey(userId:value:)` stores the user biometrics auth key via the façade. + @Test + func setUserBiometricAuthKey_success() async throws { + try await subject.setUserBiometricAuthKey(userId: "user-1", value: "biometric-key") + + #expect(keychainServiceFacade.setValueReceivedArguments?.value == "biometric-key") + + let actualReceivedItem = keychainServiceFacade.setValueReceivedArguments?.item as? AuthenticatorKeychainItem + let expectedReceivedItem = AuthenticatorKeychainItem.biometrics(userId: "user-1") + #expect(actualReceivedItem == expectedReceivedItem) + } + + /// `setUserBiometricAuthKey(userId:value:)` rethrows errors from the façade. + @Test + func setUserBiometricAuthKey_rethrows() async { + let expectedError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + keychainServiceFacade.setValueThrowableError = expectedError + + await #expect(throws: expectedError) { + try await subject.setUserBiometricAuthKey(userId: "user-1", value: "biometric-key") + } + } +} diff --git a/AuthenticatorShared/Core/Auth/Services/TestHelpers/MockKeychainRepository.swift b/AuthenticatorShared/Core/Auth/Services/TestHelpers/MockKeychainRepository.swift deleted file mode 100644 index 2d49843e51..0000000000 --- a/AuthenticatorShared/Core/Auth/Services/TestHelpers/MockKeychainRepository.swift +++ /dev/null @@ -1,80 +0,0 @@ -import BitwardenKit -import Foundation - -@testable import AuthenticatorShared - -class MockKeychainRepository: KeychainRepository { - var appId: String = "mockAppId" - var mockStorage = [String: String]() - var securityType: SecAccessControlCreateFlags? - var deleteResult: Result = .success(()) - var getResult: Result? - var setResult: Result = .success(()) - - var getSecretKeyResult: Result = .success("qwerty") - - var setSecretKeyResult: Result = .success(()) - - var getAccessTokenResult: Result = .success("ACCESS_TOKEN") - - var getRefreshTokenResult: Result = .success("REFRESH_TOKEN") - - var setAccessTokenResult: Result = .success(()) - - var setRefreshTokenResult: Result = .success(()) - - func deleteUserAuthKey(for item: AuthenticatorKeychainItem) async throws { - try deleteResult.get() - let formattedKey = formattedKey(for: item) - mockStorage = mockStorage.filter { $0.key != formattedKey } - } - - func getAccessToken(userId: String) async throws -> String { - try getAccessTokenResult.get() - } - - func getRefreshToken(userId: String) async throws -> String { - try getRefreshTokenResult.get() - } - - func getUserAuthKeyValue(for item: AuthenticatorKeychainItem) async throws -> String { - let formattedKey = formattedKey(for: item) - if let result = getResult { - let value = try result.get() - mockStorage[formattedKey] = value - return value - } else if let value = mockStorage[formattedKey] { - return value - } else { - throw KeychainServiceError.keyNotFound(item) - } - } - - func getValue(for item: AuthenticatorKeychainItem) throws -> String { - let formattedKey = formattedKey(for: item) - guard let value = mockStorage[formattedKey] else { - throw KeychainServiceError.keyNotFound(item) - } - return value - } - - func formattedKey(for item: AuthenticatorKeychainItem) -> String { - String(format: storageKeyFormat, appId, item.unformattedKey) - } - - func getSecretKey(userId: String) async throws -> String { - try getSecretKeyResult.get() - } - - func setSecretKey(_ value: String, userId: String) async throws { - try setSecretKeyResult.get() - mockStorage[formattedKey(for: .secretKey(userId: userId))] = value - } - - func setUserAuthKey(for item: AuthenticatorKeychainItem, value: String) async throws { - let formattedKey = formattedKey(for: item) - securityType = item.accessControlFlags - try setResult.get() - mockStorage[formattedKey] = value - } -} diff --git a/AuthenticatorShared/Core/Platform/Services/ServiceContainer.swift b/AuthenticatorShared/Core/Platform/Services/ServiceContainer.swift index 6debf2936a..91014ee2de 100644 --- a/AuthenticatorShared/Core/Platform/Services/ServiceContainer.swift +++ b/AuthenticatorShared/Core/Platform/Services/ServiceContainer.swift @@ -215,9 +215,18 @@ public class ServiceContainer: Services { let keychainService = DefaultKeychainService() let timeProvider = CurrentTime() - let keychainRepository = DefaultKeychainRepository( - appIDService: appIDService, + let keychainServiceFacade = DefaultKeychainServiceFacade( + appSecAttrAccessGroup: Bundle.main.groupIdentifier, keychainService: keychainService, + namespacing: .appScoped( + appIDService: appIDService, + appSecAttrService: Bundle.main.appIdentifier, + storageKeyPrefix: "bwaKeychainStorage", + ), + ) + + let keychainRepository = DefaultKeychainRepository( + keychainServiceFacade: keychainServiceFacade, ) let stateService = DefaultStateService( @@ -301,13 +310,14 @@ public class ServiceContainer: Services { authenticatorItemDataStore: dataStore, ) - let sharedKeychainStorage = DefaultSharedKeychainStorage( + let sharedKeychainServiceFacade = DefaultKeychainServiceFacade( + appSecAttrAccessGroup: Bundle.main.sharedAppGroupIdentifier, keychainService: keychainService, - sharedAppGroupIdentifier: Bundle.main.sharedAppGroupIdentifier, + namespacing: .shared, ) let sharedKeychainRepository = DefaultSharedKeychainRepository( - storage: sharedKeychainStorage, + keychainServiceFacade: sharedKeychainServiceFacade, ) let sharedCryptographyService = DefaultAuthenticatorCryptographyService( diff --git a/BitwardenKit/Core/Platform/Models/Domain/Mocks/MockKeychainItem.swift b/BitwardenKit/Core/Platform/Models/Domain/Mocks/MockKeychainItem.swift index 497d37ebbc..2eb3b1ac20 100644 --- a/BitwardenKit/Core/Platform/Models/Domain/Mocks/MockKeychainItem.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/Mocks/MockKeychainItem.swift @@ -1,8 +1,15 @@ import BitwardenKit +import Foundation extension MockKeychainItem: Equatable { - public convenience init(unformattedKey: String) { + public convenience init( + unformattedKey: String, + accessControlFlags: SecAccessControlCreateFlags? = nil, + protection: CFTypeRef = kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ) { self.init() + self.accessControlFlags = accessControlFlags + self.protection = protection self.unformattedKey = unformattedKey } @@ -10,6 +17,8 @@ extension MockKeychainItem: Equatable { lhs: MockKeychainItem, rhs: MockKeychainItem, ) -> Bool { - lhs.unformattedKey == rhs.unformattedKey + lhs.accessControlFlags == rhs.accessControlFlags + && CFEqual(lhs.protection, rhs.protection) + && lhs.unformattedKey == rhs.unformattedKey } } diff --git a/BitwardenKit/Core/Platform/Models/Enum/KeychainServiceError.swift b/BitwardenKit/Core/Platform/Models/Enum/KeychainServiceError.swift index 149d268481..287c2eda0c 100644 --- a/BitwardenKit/Core/Platform/Models/Enum/KeychainServiceError.swift +++ b/BitwardenKit/Core/Platform/Models/Enum/KeychainServiceError.swift @@ -16,7 +16,7 @@ public enum KeychainServiceError: Error, Equatable, CustomNSError { /// When a `KeychainService` is unable to locate a value for a given storage key. /// /// - Parameters: - /// - KeychainStorageKeyPossessing: The storage key for the value. + /// - KeychainItem: The storage key for the value. /// case keyNotFound(any KeychainItem) diff --git a/BitwardenKit/Core/Platform/Services/KeychainService.swift b/BitwardenKit/Core/Platform/Services/KeychainService.swift index dd4f885d62..3b10df393b 100644 --- a/BitwardenKit/Core/Platform/Services/KeychainService.swift +++ b/BitwardenKit/Core/Platform/Services/KeychainService.swift @@ -4,7 +4,7 @@ import Foundation /// A Service to provide a wrapper around the device Keychain. /// -public protocol KeychainService: AnyObject { +public protocol KeychainService: AnyObject { // sourcery: AutoMockable /// Creates an access control for a given set of flags. /// /// - Parameters: @@ -30,10 +30,10 @@ public protocol KeychainService: AnyObject { /// func delete(query: CFDictionary) throws - /// Searches for a query. + /// Searches the keychain based on a query. /// - /// - Parameter query: Query for the delete. - /// - Returns: The search results. + /// - Parameter query: Query to search the keychain for. + /// - Returns: The keychain items searched for. `nil` if none were found. /// func search(query: CFDictionary) throws -> AnyObject? @@ -90,8 +90,16 @@ public class DefaultKeychainService: KeychainService { public func search(query: CFDictionary) throws -> AnyObject? { var foundItem: AnyObject? - try resolve(SecItemCopyMatching(query, &foundItem)) - return foundItem + let status = SecItemCopyMatching(query, &foundItem) + + switch status { + case errSecSuccess: + return foundItem + case errSecItemNotFound: + return nil + default: + throw KeychainServiceError.osStatusError(status) + } } public func update(query: CFDictionary, attributes: CFDictionary) throws { diff --git a/BitwardenKit/Core/Platform/Services/KeychainServiceFacade.swift b/BitwardenKit/Core/Platform/Services/KeychainServiceFacade.swift new file mode 100644 index 0000000000..3660f0ff89 --- /dev/null +++ b/BitwardenKit/Core/Platform/Services/KeychainServiceFacade.swift @@ -0,0 +1,250 @@ +import Foundation + +// MARK: - KeychainServiceFacade + +/// A façade layer for ``KeychainService`` that provides higher-level get, set, and delete options. +/// +public protocol KeychainServiceFacade { // sourcery: AutoMockable + /// Deletes the value in the keychain for the given item. + /// + /// - Parameters: + /// - item: The keychain item to delete the associated value + /// + func deleteValue(for item: any KeychainItem) async throws + + /// Gets the string value associated with the keychain item from the keychain. + /// Throws `KeychainServiceError.keyNotFound` if no value exists for the given item. + /// + /// - Parameter item: The keychain item used to fetch the associated value. + /// - Returns: The fetched value associated with the keychain item. + /// + func getValue(for item: any KeychainItem) async throws -> String + + /// Sets a value associated with a keychain item in the keychain. + /// + /// - Parameters: + /// - value: The value associated with the keychain item to set. + /// - item: The keychain item used to set the associated value. + /// + func setValue(_ value: String, for item: any KeychainItem) async throws +} + +public extension KeychainServiceFacade { + /// Gets the value associated with the keychain item from the keychain. + /// Throws `KeychainServiceError.keyNotFound` if no value exists for the given item. + /// + /// - Parameter item: The keychain item used to fetch the associated value. + /// - Returns: The fetched value associated with the keychain item. + /// + func getValue(for item: any KeychainItem) async throws -> T { + let string = try await getValue(for: item) + + guard let jsonData = string.data(using: .utf8) else { + throw BitwardenError.dataError("JSON string contains invalid UTF-8 encoding.") + } + + return try JSONDecoder.defaultDecoder.decode(T.self, from: jsonData) + } + + /// Sets a value associated with a keychain item in the keychain. + /// + /// - Parameters: + /// - value: The value associated with the keychain item to set. + /// - item: The keychain item used to set the associated value. + /// + func setValue(_ value: T, for item: any KeychainItem) async throws { + let jsonData = try JSONEncoder.defaultEncoder.encode(value) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw BitwardenError.dataError("JSON data is not valid.") + } + try await setValue(jsonString, for: item) + } + + /// Sets an optional value associated with a keychain item in the keychain. + /// If `value` is `nil`, the keychain item is deleted instead. + /// + /// - Parameters: + /// - value: The value associated with the keychain item to set, or `nil` to delete. + /// - item: The keychain item used to set the associated value. + /// + func setValue(_ value: T?, for item: any KeychainItem) async throws { + if let value { + try await setValue(value, for: item) + } else { + try await deleteValue(for: item) + } + } +} + +// MARK: - KeychainNamespacing + +/// Defines whether keychain items are namespaced per app install or shared between apps. +/// +public enum KeychainNamespacing { + /// Items are namespaced with a per-install app ID and scoped to a `kSecAttrService`, + /// preventing collisions with other apps sharing the same access group. + /// + /// - Parameters: + /// - appIDService: A service used to provide the per-install app ID. + /// - appSecAttrService: An identifier for the keychain service used by the application and extensions. + /// (e.g., "com.8bit.bitwarden"). + /// - storageKeyPrefix: A prefix used for storage keys to namespace keychain items. + /// + case appScoped(appIDService: AppIDService, appSecAttrService: String, storageKeyPrefix: String) + + /// Items are stored with a bare key and no `kSecAttrService`, allowing cross-app access. + /// + case shared +} + +// MARK: - DefaultKeychainServiceFacade + +/// Default implementation of ``KeychainServiceFacade``. +/// +public class DefaultKeychainServiceFacade: KeychainServiceFacade { + // MARK: Properties + + /// An identifier for the keychain access group used by the application group and extensions. + /// + /// Example: `LTZ2PFU5D6.com.8bit.bitwarden` + /// + var appSecAttrAccessGroup: String + + /// The keychain service used by the repository + /// + let keychainService: KeychainService + + /// Determines how keychain item keys are constructed from the unformatted key + /// and whether `kSecAttrService` is included in the keychain entry. + /// + let namespacing: KeychainNamespacing + + // MARK: Initialization + + /// Creates a new instance of the keychain service façade. + /// + /// - Parameters: + /// - appSecAttrAccessGroup: An identifier for the keychain access group used by the application + /// group and extensions (e.g., "LTZ2PFU5D6.com.8bit.bitwarden"). + /// - keychainService: The keychain service used by the repository. + /// - namespacing: Determines how keychain item keys are constructed. + /// + public init( + appSecAttrAccessGroup: String, + keychainService: KeychainService, + namespacing: KeychainNamespacing, + ) { + self.appSecAttrAccessGroup = appSecAttrAccessGroup + self.keychainService = keychainService + self.namespacing = namespacing + } + + // MARK: Methods + + public func deleteValue(for item: any KeychainItem) async throws { + try await keychainService.delete( + query: keychainQueryValues(for: item), + ) + } + + public func getValue(for item: any KeychainItem) async throws -> String { + let foundItem = try await keychainService.search( + query: keychainQueryValues( + for: item, + adding: [ + kSecMatchLimit: kSecMatchLimitOne, + kSecReturnData: true, + kSecReturnAttributes: true, + ], + ), + ) + + guard let resultDictionary = foundItem as? [String: Any], + let data = resultDictionary[kSecValueData as String] as? Data, + let string = String(data: data, encoding: .utf8), + !string.isEmpty else { + throw KeychainServiceError.keyNotFound(item) + } + + return string + } + + public func setValue(_ value: String, for item: any KeychainItem) async throws { + let accessControl = try keychainService.accessControl( + protection: item.protection, + for: item.accessControlFlags ?? [], + ) + let baseQuery = await keychainQueryValues(for: item) + let updateAttributes: CFDictionary = [ + kSecAttrAccessControl: accessControl as Any, + kSecValueData: Data(value.utf8), + ] as CFDictionary + + do { + // Try to update first - if item exists, this avoids delete-then-add race condition + try keychainService.update(query: baseQuery, attributes: updateAttributes) + } catch KeychainServiceError.osStatusError(errSecItemNotFound) { + // Item doesn't exist, so add it + let addAttributes = await keychainQueryValues( + for: item, + adding: [ + kSecAttrAccessControl: accessControl as Any, + kSecValueData: Data(value.utf8), + ], + ) + try keychainService.add(attributes: addAttributes) + } + } + + // MARK: Private Methods + + /// Generates a formatted storage key for a keychain item. + /// + /// - Parameter item: The keychain item that needs a formatted key. + /// - Returns: A formatted storage key. + /// + func formattedKey(for item: any KeychainItem) async -> String { + switch namespacing { + case let .appScoped(appIDService, _, storageKeyPrefix): + let appId = await appIDService.getOrCreateAppID() + // Generate a storage key for storing a keychain item in an app-scoped keychain + return String(format: "\(storageKeyPrefix):%@:%@", appId, item.unformattedKey) + case .shared: + // For historical reasons, shared-keychain items use the plain unformatted key. + return item.unformattedKey + } + } + + /// The core key/value pairs for keychain operations. + /// + /// cf. https://developer.apple.com/documentation/security/searching-for-keychain-items + /// + /// - Parameter item: The keychain item to be queried. + /// + func keychainQueryValues( + for item: any KeychainItem, + adding additionalPairs: [CFString: Any] = [:], + ) async -> CFDictionary { + // Prepare a formatted `kSecAttrAccount` value. + let formattedSecAttrAccount = await formattedKey(for: item) + + // Configure the base dictionary + var result: [CFString: Any] = [ + kSecAttrAccount: formattedSecAttrAccount, + kSecAttrAccessGroup: appSecAttrAccessGroup, + kSecClass: kSecClassGenericPassword, + ] + + // For historical reasons, shared-keychain items don't have a kSecAttrService. + if case let .appScoped(_, appSecAttrService, _) = namespacing { + result[kSecAttrService] = appSecAttrService + } + + // Add the additional key value pairs. + additionalPairs.forEach { key, value in + result[key] = value + } + + return result as CFDictionary + } +} diff --git a/BitwardenKit/Core/Platform/Services/KeychainServiceFacadeTests.swift b/BitwardenKit/Core/Platform/Services/KeychainServiceFacadeTests.swift new file mode 100644 index 0000000000..6a006ac6a9 --- /dev/null +++ b/BitwardenKit/Core/Platform/Services/KeychainServiceFacadeTests.swift @@ -0,0 +1,404 @@ +import BitwardenKitMocks +import Foundation +import Security +import Testing + +@testable import BitwardenKit + +// MARK: - KeychainServiceFacadeTests + +struct KeychainServiceFacadeTests { // swiftlint:disable:this type_body_length + // MARK: Properties + + let keychainService: MockKeychainService + let subject: DefaultKeychainServiceFacade + + // MARK: Setup + + init() { + let appIDSettingsStore = MockAppIDSettingsStore() + appIDSettingsStore.appID = "test-app-ID" + keychainService = MockKeychainService() + subject = DefaultKeychainServiceFacade( + appSecAttrAccessGroup: "test-access-group", + keychainService: keychainService, + namespacing: .appScoped( + appIDService: AppIDService(appIDSettingsStore: appIDSettingsStore), + appSecAttrService: "test-service", + storageKeyPrefix: "test-prefix", + ), + ) + } + + // MARK: Tests - deleteValue(for:) + + /// `deleteValue(for:)` deletes the item using the correct base query. + @Test + func deleteValue_success() async throws { + let item = MockKeychainItem(unformattedKey: "delete_key") + + try await subject.deleteValue(for: item) + + let query = try #require(keychainService.deleteReceivedQuery as? [String: Any]) + #expect(query[kSecAttrAccount as String] as? String == "test-prefix:test-app-ID:delete_key") + #expect(query[kSecAttrAccessGroup as String] as? String == "test-access-group") + #expect(query[kSecAttrService as String] as? String == "test-service") + #expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String) + } + + /// `deleteValue(for:)` rethrows errors from the keychain service. + @Test + func deleteValue_rethrows() async { + let item = MockKeychainItem(unformattedKey: "delete_key") + keychainService.deleteThrowableError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + + await #expect(throws: KeychainServiceError.osStatusError(errSecInteractionNotAllowed)) { + try await subject.deleteValue(for: item) + } + } + + // MARK: Tests - getValue(for:) -> String + + /// `getValue(for:)` returns the stored string when the keychain search succeeds. + @Test + func getValue_string_success() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.searchReturnValue = [kSecValueData as String: Data("stored-value".utf8)] as AnyObject + + let result = try await subject.getValue(for: item) + + #expect(result == "stored-value") + let searchQuery = try #require(keychainService.searchReceivedQuery as? [String: Any]) + #expect(searchQuery[kSecAttrAccount as String] as? String == "test-prefix:test-app-ID:test_key") + #expect(searchQuery[kSecAttrAccessGroup as String] as? String == "test-access-group") + #expect(searchQuery[kSecAttrService as String] as? String == "test-service") + #expect(searchQuery[kSecClass as String] as? String == kSecClassGenericPassword as String) + #expect(searchQuery[kSecMatchLimit as String] as? String == kSecMatchLimitOne as String) + #expect(searchQuery[kSecReturnData as String] as? Bool == true) + #expect(searchQuery[kSecReturnAttributes as String] as? Bool == true) + } + + /// `getValue(for:)` throws `keyNotFound` when the search returns `nil`. + @Test + func getValue_string_nilResult_throwsKeyNotFound() async { + let item = MockKeychainItem(unformattedKey: "missing_key") + keychainService.searchReturnValue = nil + + await #expect(throws: KeychainServiceError.keyNotFound(item)) { + _ = try await subject.getValue(for: item) + } + } + + /// `getValue(for:)` throws `keyNotFound` when the search returns an empty string. + @Test + func getValue_string_emptyString_throwsKeyNotFound() async { + let item = MockKeychainItem(unformattedKey: "empty_key") + keychainService.searchReturnValue = [kSecValueData as String: Data("".utf8)] as AnyObject + + await #expect(throws: KeychainServiceError.keyNotFound(item)) { + _ = try await subject.getValue(for: item) + } + } + + /// `getValue(for:)` throws `keyNotFound` when the search result is not a dictionary. + @Test + func getValue_string_nonDictionaryResult_throwsKeyNotFound() async { + let item = MockKeychainItem(unformattedKey: "bad_result_key") + keychainService.searchReturnValue = "unexpected-string" as AnyObject + + await #expect(throws: KeychainServiceError.keyNotFound(item)) { + _ = try await subject.getValue(for: item) + } + } + + /// `getValue(for:)` rethrows other keychain service errors as-is. + @Test + func getValue_string_otherKeychainError_rethrows() async { + let item = MockKeychainItem(unformattedKey: "error_key") + keychainService.searchThrowableError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + + await #expect(throws: KeychainServiceError.osStatusError(errSecInteractionNotAllowed)) { + _ = try await subject.getValue(for: item) + } + } + + // MARK: Tests - getValue(for:) -> T: Codable + + /// `getValue(for:)` decodes and returns a `Codable` value when the keychain search succeeds. + @Test + func getValue_codable_success() async throws { + let item = MockKeychainItem(unformattedKey: "codable_key") + keychainService.searchReturnValue = [kSecValueData as String: Data("42".utf8)] as AnyObject + + let result: Int = try await subject.getValue(for: item) + + #expect(result == 42) + } + + /// `getValue(for:)` throws a decoding error when the stored string is not valid JSON for the target type. + @Test + func getValue_codable_invalidJSON_throwsDecodingError() async { + let item = MockKeychainItem(unformattedKey: "bad_json_key") + keychainService.searchReturnValue = [kSecValueData as String: Data("not-a-number".utf8)] as AnyObject + + // DecodingError is complex to construct for comparison, so we only assert that an error is thrown. + await #expect(throws: (any Error).self) { + let _: Int = try await subject.getValue(for: item) + } + } + + /// `getValue(for:)` propagates `keyNotFound` when the underlying string getter throws. + @Test + func getValue_codable_keyNotFound_rethrows() async { + let item = MockKeychainItem(unformattedKey: "missing_codable_key") + keychainService.searchReturnValue = nil + + await #expect(throws: KeychainServiceError.keyNotFound(item)) { + let _: Int = try await subject.getValue(for: item) + } + } + + /// `getValue(for:)` rethrows other keychain service errors as-is. + @Test + func getValue_codable_otherKeychainError_rethrows() async { + let item = MockKeychainItem(unformattedKey: "error_codable_key") + keychainService.searchThrowableError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + + await #expect(throws: KeychainServiceError.osStatusError(errSecInteractionNotAllowed)) { + let _: Int = try await subject.getValue(for: item) + } + } + + // MARK: Tests - setValue(_: String, for:) + + /// `setValue(_:for:)` updates the existing item when the update succeeds without calling `add`. + @Test + func setValue_updatesExistingItem() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + + try await subject.setValue("new-value", for: item) + + #expect(!keychainService.addCalled) + let updateReceivedArguments = try #require(keychainService.updateReceivedArguments) + + let updateQuery = try #require(updateReceivedArguments.query as? [String: Any]) + #expect(updateQuery[kSecAttrAccount as String] as? String == "test-prefix:test-app-ID:test_key") + #expect(updateQuery[kSecAttrAccessGroup as String] as? String == "test-access-group") + #expect(updateQuery[kSecAttrService as String] as? String == "test-service") + + let receivedAttributes = try #require(updateReceivedArguments.attributes as? [String: Any]) + let storedData = try #require(receivedAttributes[kSecValueData as String] as? Data) + #expect(storedData == Data("new-value".utf8)) + } + + /// `setValue(_:for:)` falls through to `add` when the update returns `errSecItemNotFound`. + @Test + func setValue_addsNewItem_whenNotFound() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + keychainService.updateThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) + + try await subject.setValue("new-value", for: item) + + #expect(keychainService.addCallsCount == 1) + let addAttributes = try #require(keychainService.addReceivedAttributes as? [String: Any]) + #expect(addAttributes[kSecAttrAccount as String] as? String == "test-prefix:test-app-ID:test_key") + #expect(addAttributes[kSecAttrAccessGroup as String] as? String == "test-access-group") + #expect(addAttributes[kSecAttrService as String] as? String == "test-service") + #expect(addAttributes[kSecClass as String] as? String == kSecClassGenericPassword as String) + + let storedData = try #require(addAttributes[kSecValueData as String] as? Data) + #expect(storedData == Data("new-value".utf8)) + } + + /// `setValue(_:for:)` passes the item's `protection` and `accessControlFlags` to the access control call. + @Test + func setValue_usesItemProtectionAndFlags() async throws { + let item = MockKeychainItem( + unformattedKey: "test_key", + accessControlFlags: .biometryCurrentSet, + ) + keychainService.accessControlReturnValue = try makeAccessControl() + + try await subject.setValue("value", for: item) + + let accessControlArgs = try #require(keychainService.accessControlReceivedArguments) + #expect(accessControlArgs.flags == .biometryCurrentSet) + #expect(CFEqual(accessControlArgs.protection, kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) + } + + /// `setValue(_:for:)` rethrows when the update fails with an error other than `errSecItemNotFound`. + @Test + func setValue_rethrows_whenUpdateFailsWithOtherError() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + keychainService.updateThrowableError = KeychainServiceError.osStatusError(errSecInteractionNotAllowed) + + await #expect(throws: KeychainServiceError.osStatusError(errSecInteractionNotAllowed)) { + try await subject.setValue("value", for: item) + } + #expect(!keychainService.addCalled) + } + + /// `setValue(_:for:)` rethrows when the add fails after a `errSecItemNotFound` update error. + @Test + func setValue_rethrows_whenAddFails() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + keychainService.updateThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) + keychainService.addThrowableError = KeychainServiceError.osStatusError(errSecDuplicateItem) + + await #expect(throws: KeychainServiceError.osStatusError(errSecDuplicateItem)) { + try await subject.setValue("value", for: item) + } + } + + /// `setValue(_:for:)` rethrows when creating the access control fails, without calling update or add. + @Test + func setValue_rethrows_whenAccessControlFails() async { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlThrowableError = KeychainServiceError.accessControlFailed(nil) + + await #expect(throws: KeychainServiceError.accessControlFailed(nil)) { + try await subject.setValue("value", for: item) + } + #expect(!keychainService.updateCalled) + #expect(!keychainService.addCalled) + } + + // MARK: Tests - setValue(_: T: Codable, for:) + + /// `setValue(_:for:)` JSON-encodes a `Codable` value and stores the resulting string. + /// + @Test + func setValue_codable_success() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + + try await subject.setValue(42, for: item) + + let expectedData = try JSONEncoder.defaultEncoder.encode(42) + let actualAttributes = try #require(keychainService.updateReceivedArguments?.attributes as? [String: Any]) + let storedData = try #require(actualAttributes[kSecValueData as String] as? Data) + #expect(storedData == expectedData) + } + + /// `setValue(_:for:)` rethrows when JSON encoding fails. + @Test + func setValue_codable_encodingError_throws() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + + // EncodingError is complex to construct for comparison, so we only assert that an error is thrown. + await #expect(throws: (any Error).self) { + try await subject.setValue(Double.nan, for: item) + } + #expect(!keychainService.updateCalled) + #expect(!keychainService.addCalled) + } + + // MARK: Tests - setValue(_: T?: Codable, for:) + + /// `setValue(_:for:)` JSON-encodes and stores a non-nil optional `Codable` value. + @Test + func setValue_optionalCodable_nonNil_storesValue() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + keychainService.accessControlReturnValue = try makeAccessControl() + + try await subject.setValue(Optional(42), for: item) + + let expectedData = try JSONEncoder.defaultEncoder.encode(42) + let actualAttributes = try #require(keychainService.updateReceivedArguments?.attributes as? [String: Any]) + let storedData = try #require(actualAttributes[kSecValueData as String] as? Data) + #expect(storedData == expectedData) + #expect(!keychainService.deleteCalled) + } + + /// `setValue(_:for:)` deletes the keychain item when the optional value is nil. + @Test + func setValue_optionalCodable_nil_deletesValue() async throws { + let item = MockKeychainItem(unformattedKey: "optional_key") + + try await subject.setValue(Int?.none, for: item) + + #expect(!keychainService.updateCalled) + #expect(!keychainService.addCalled) + let query = try #require(keychainService.deleteReceivedQuery as? [String: Any]) + #expect(query[kSecAttrAccount as String] as? String == "test-prefix:test-app-ID:optional_key") + } + + // MARK: Tests - shared namespacing configuration + + /// With `.appScoped` namespacing, `keychainQueryValues` includes `kSecAttrService` in the query. + /// + @Test + func keychainQueryValues_appScopedNamespacing_includesService() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + + let query = await subject.keychainQueryValues(for: item) + + let dict = try #require(query as? [String: Any]) + #expect(dict[kSecAttrService as String] as? String == "test-service") + #expect(dict[kSecClass as String] as? String == kSecClassGenericPassword as String) + } + + /// With `.appScoped` namespacing, `keychainQueryValues` formats `kSecAttrAccount` as `prefix:appID:unformattedKey`. + /// + @Test + func keychainQueryValues_appScopedNamespacing_usesFormattedKey() async throws { + let item = MockKeychainItem(unformattedKey: "scoped_key") + + let query = await subject.keychainQueryValues(for: item) + + let dict = try #require(query as? [String: Any]) + #expect(dict[kSecAttrAccount as String] as? String == "test-prefix:test-app-ID:scoped_key") + #expect(dict[kSecClass as String] as? String == kSecClassGenericPassword as String) + } + + /// With `.shared` namespacing, `keychainQueryValues` uses the bare `unformattedKey` for `kSecAttrAccount`. + /// + @Test + func keychainQueryValues_sharedNamespacing_usesBareKey() async throws { + let item = MockKeychainItem(unformattedKey: "shared_key") + let sharedSubject = makeSharedSubject() + + let query = await sharedSubject.keychainQueryValues(for: item) + + let dict = try #require(query as? [String: Any]) + #expect(dict[kSecAttrAccount as String] as? String == "shared_key") + #expect(dict[kSecClass as String] as? String == kSecClassGenericPassword as String) + } + + /// With `.shared` namespacing, `keychainQueryValues` omits `kSecAttrService` from the query. + /// + @Test + func keychainQueryValues_sharedNamespacing_omitsService() async throws { + let item = MockKeychainItem(unformattedKey: "test_key") + let sharedSubject = makeSharedSubject() + + let query = await sharedSubject.keychainQueryValues(for: item) + + let dict = try #require(query as? [String: Any]) + #expect(dict[kSecAttrService as String] == nil) + #expect(dict[kSecClass as String] as? String == kSecClassGenericPassword as String) + } + + // MARK: Private Helpers + + private func makeSharedSubject() -> DefaultKeychainServiceFacade { + DefaultKeychainServiceFacade( + appSecAttrAccessGroup: "test-access-group", + keychainService: keychainService, + namespacing: .shared, + ) + } + + private func makeAccessControl() throws -> SecAccessControl { + var error: Unmanaged? + return try #require( + SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [], &error), + "Failed to create access control: \(String(describing: error?.takeRetainedValue()))", + ) + } +} // swiftlint:disable:this file_length diff --git a/BitwardenKit/Core/Platform/Services/Mocks/MockKeychainService.swift b/BitwardenKit/Core/Platform/Services/Mocks/MockKeychainService.swift deleted file mode 100644 index 9fcfe9bf61..0000000000 --- a/BitwardenKit/Core/Platform/Services/Mocks/MockKeychainService.swift +++ /dev/null @@ -1,154 +0,0 @@ -import BitwardenKit -import Foundation - -/// A mock implementation of `KeychainService` for testing purposes. -/// -/// This mock allows you to control the behavior and results of keychain operations, -/// and inspect the parameters passed to each method call. -public class MockKeychainService { - // MARK: Properties - - /// The flags captured from the most recent `accessControl(protection:for:)` call. - public var accessControlFlags: SecAccessControlCreateFlags? - - /// The protection level captured from the most recent `accessControl(protection:for:)` call. - public var accessControlProtection: CFTypeRef? - - /// The result to return from `accessControl(protection:for:)` calls. - /// Defaults to a failure with `.accessControlFailed(nil)`. - public var accessControlResult: Result = .failure(.accessControlFailed(nil)) - - /// The attributes dictionary captured from the most recent `add(attributes:)` call. - public var addAttributes: CFDictionary? - - /// An array of all attributes dictionaries passed to `add(attributes:)` calls. - public var addCalls = [CFDictionary]() - - /// The result to return from `add(attributes:)` calls. - /// Defaults to success. - public var addResult: Result = .success(()) - - /// An array of all query dictionaries passed to `delete(query:)` calls. - public var deleteQueries = [CFDictionary]() - - /// The result to return from `delete(query:)` calls. - /// Defaults to success. - public var deleteResult: Result = .success(()) - - /// The query dictionary captured from the most recent `search(query:)` call. - public var searchQuery: CFDictionary? - - /// The result to return from `search(query:)` calls. - /// Defaults to `nil` (no results found). - public var searchResult: Result = .success(nil) - - /// The attributes dictionary captured from the most recent `update(query:attributes:)` call. - public var updateAttributes: CFDictionary? - - /// The query dictionary captured from the most recent `update(query:attributes:)` call. - public var updateQuery: CFDictionary? - - /// The result to return from `update(query:attributes:)` calls. - /// Defaults to failure with `errSecItemNotFound`. - public var updateResult: Result = .failure(KeychainServiceError.osStatusError(errSecItemNotFound)) - - /// Initializes a new mock keychain service with default values. - public init() {} -} - -// MARK: KeychainService - -extension MockKeychainService: KeychainService { - /// Creates a `SecAccessControl` object with the specified protection level and flags. - /// - /// This mock implementation captures the parameters and returns the value from `accessControlResult`. - /// - /// - Parameters: - /// - protection: The protection level for the access control. - /// - flags: The flags defining when the keychain item can be accessed. - /// - Returns: A `SecAccessControl` object as configured in `accessControlResult`. - /// - Throws: A `KeychainServiceError` if `accessControlResult` is set to a failure. - public func accessControl( - protection: CFTypeRef, - for flags: SecAccessControlCreateFlags, - ) throws -> SecAccessControl { - accessControlFlags = flags - accessControlProtection = protection - return try accessControlResult.get() - } - - /// Adds a new item to the keychain with the specified attributes. - /// - /// This mock implementation captures the attributes and appends them to `addCalls`. - /// - /// - Parameter attributes: A dictionary containing the attributes for the keychain item. - /// - Throws: A `KeychainServiceError` if `addResult` is set to a failure. - public func add(attributes: CFDictionary) throws { - addAttributes = attributes - addCalls.append(attributes) - try addResult.get() - } - - /// Deletes keychain items matching the specified query. - /// - /// This mock implementation captures the query and appends it to `deleteQueries`. - /// - /// - Parameter query: A dictionary specifying the items to delete. - /// - Throws: A `KeychainServiceError` if `deleteResult` is set to a failure. - public func delete(query: CFDictionary) throws { - deleteQueries.append(query) - try deleteResult.get() - } - - /// Searches for keychain items matching the specified query. - /// - /// This mock implementation captures the query and returns the value from `searchResult`. - /// - /// - Parameter query: A dictionary specifying the search criteria. - /// - Returns: The keychain item data as configured in `searchResult`, or `nil` if not found. - /// - Throws: A `KeychainServiceError` if `searchResult` is set to a failure. - public func search(query: CFDictionary) throws -> AnyObject? { - searchQuery = query - return try searchResult.get() - } - - /// Updates keychain items matching the specified query with new attributes. - /// - /// This mock implementation captures both the query and attributes. - /// - /// - Parameters: - /// - query: A dictionary specifying which items to update. - /// - attributes: A dictionary containing the new attributes. - /// - Throws: An error if `updateResult` is set to a failure. - public func update(query: CFDictionary, attributes: CFDictionary) throws { - updateQuery = query - updateAttributes = attributes - try updateResult.get() - } -} - -public extension MockKeychainService { - /// Configures `searchResult` to return a dictionary containing string data. - /// - /// This is a convenience method for setting up common test scenarios where you want - /// the keychain search to return string data. The data is stored in a dictionary - /// with the `kSecValueData` key. - /// - /// - Parameter string: The string to return from search operations. - func setSearchResultData(_ data: Data) { - let dictionary = [kSecValueData as String: data] - searchResult = .success(dictionary as AnyObject) - } - - /// Configures `searchResult` to return a dictionary containing string data. - /// - /// This is a convenience method for setting up common test scenarios where you want - /// the keychain search to return string data. The string is converted to `Data` and - /// stored in a dictionary with the `kSecValueData` key. - /// - /// - Parameter string: The string to return from search operations. - func setSearchResultData(string: String) { - let dictionary = [kSecValueData as String: Data(string.utf8)] - searchResult = .success(dictionary as AnyObject) - } -} diff --git a/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift b/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift index 3db99067c8..51456c4952 100644 --- a/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift +++ b/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift @@ -1667,7 +1667,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo vaultTimeoutService.vaultTimeout = [ active.profile.userId: .never, ] - keychainService.deleteResult = .failure(BitwardenTestError.example) + keychainService.deleteUserAuthKeyThrowableError = BitwardenTestError.example try await subject.setVaultTimeout(value: .fiveMinutes) XCTAssertEqual(vaultTimeoutService.vaultTimeout["1"], .fiveMinutes) } @@ -1679,17 +1679,8 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo vaultTimeoutService.vaultTimeout = [ active.profile.userId: .never, ] - keychainService.mockStorage = [ - keychainService.formattedKey( - for: BitwardenKeychainItem.neverLock( - userId: active.profile.userId, - ), - ): - "pasta", - ] - keychainService.deleteResult = .success(()) try await subject.setVaultTimeout(value: .fiveMinutes) - XCTAssertTrue(keychainService.mockStorage.isEmpty) + XCTAssertTrue(keychainService.deleteUserAuthKeyCalled) } /// `setVaultTimeout` correctly configures the user's timeout value. @@ -1699,28 +1690,17 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo clientService.mockCrypto.getUserEncryptionKeyResult = .success("pasta") try await subject.setVaultTimeout(value: .never) XCTAssertEqual(vaultTimeoutService.vaultTimeout[active.profile.userId], .never) + XCTAssertEqual(keychainService.setUserAuthKeyReceivedArguments?.value, "pasta") XCTAssertEqual( - keychainService.mockStorage, - [ - keychainService.formattedKey( - for: BitwardenKeychainItem.neverLock(userId: active.profile.userId), - ): - "pasta", - ], + keychainService.setUserAuthKeyReceivedArguments?.item, + BitwardenKeychainItem.neverLock(userId: active.profile.userId), ) } /// `unlockVaultWithNeverlockKey` attempts to unlock the vault using an auth key from the keychain. func test_unlockVaultWithNeverlockKey_error() async throws { let active = Account.fixture() - keychainService.mockStorage = [ - keychainService.formattedKey( - for: BitwardenKeychainItem.neverLock( - userId: active.profile.userId, - ), - ): - "pasta", - ] + keychainService.getUserAuthKeyValueReturnValue = "pasta" stateService.accountEncryptionKeys = [ active.profile.userId: .init( accountKeys: .fixture(), @@ -1739,14 +1719,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo func test_unlockVaultWithNeverlockKey_success() async throws { let active = Account.fixture() stateService.activeAccount = active - keychainService.mockStorage = [ - keychainService.formattedKey( - for: BitwardenKeychainItem.neverLock( - userId: active.profile.userId, - ), - ): - "pasta", - ] + keychainService.getUserAuthKeyValueReturnValue = "pasta" stateService.accountEncryptionKeys = [ active.profile.userId: .init( accountKeys: .fixture(), @@ -1767,14 +1740,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo func test_unlockVaultWithDeviceKey_success() async throws { let active = Account.fixtureWithTDE() stateService.activeAccount = active - keychainService.mockStorage = [ - keychainService.formattedKey( - for: BitwardenKeychainItem.deviceKey( - userId: active.profile.userId, - ), - ): - "pasta", - ] + keychainService.getDeviceKeyReturnValue = "pasta" stateService.accountEncryptionKeys = [ active.profile.userId: .init( accountKeys: .fixture(), @@ -1795,14 +1761,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo func test_unlockVaultWithDeviceKey_error() async throws { let active = Account.fixture() stateService.activeAccount = active - keychainService.mockStorage = [ - keychainService.formattedKey( - for: BitwardenKeychainItem.deviceKey( - userId: active.profile.userId, - ), - ): - "pasta", - ] + keychainService.getDeviceKeyReturnValue = "pasta" stateService.accountEncryptionKeys = [ active.profile.userId: .init( accountKeys: .fixture(), @@ -2673,7 +2632,8 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo let setArguments = biometricsRepository.setBiometricUnlockKeyReceivedArguments XCTAssertEqual(setArguments?.userId, "1") XCTAssertNil(setArguments?.authKey) - XCTAssertEqual(keychainService.deleteItemsForUserIds, ["1"]) + XCTAssertEqual(keychainService.deleteItemsCallsCount, 1) + XCTAssertEqual(keychainService.deleteItemsReceivedUserId, "1") XCTAssertTrue(stateService.logoutAccountUserInitiated) XCTAssertEqual(vaultTimeoutService.removedIds, [anneAccount.profile.userId]) XCTAssertEqual(stateService.pinProtectedUserKeyValue["1"], "1") @@ -2698,7 +2658,8 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo let setArguments = biometricsRepository.setBiometricUnlockKeyReceivedArguments XCTAssertEqual(setArguments?.userId, "1") XCTAssertNil(setArguments?.authKey) - XCTAssertEqual(keychainService.deleteItemsForUserIds, ["1"]) + XCTAssertEqual(keychainService.deleteItemsCallsCount, 1) + XCTAssertEqual(keychainService.deleteItemsReceivedUserId, "1") XCTAssertTrue(stateService.logoutAccountUserInitiated) XCTAssertEqual(vaultTimeoutService.removedIds, [anneAccount.profile.userId]) XCTAssertNil(stateService.pinProtectedUserKeyValue["1"]) @@ -2722,7 +2683,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo XCTAssertEqual([], stateService.accountsLoggedOut) XCTAssertFalse(biometricsRepository.setBiometricUnlockKeyCalled) - XCTAssertEqual(keychainService.deleteItemsForUserIds, []) + XCTAssertEqual(keychainService.deleteItemsCallsCount, 0) XCTAssertFalse(stateService.logoutAccountUserInitiated) XCTAssertEqual(vaultTimeoutService.removedIds, []) XCTAssertEqual(stateService.pinProtectedUserKeyValue["1"], "1") diff --git a/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift b/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift index 1e1cd78dd0..51770b57b4 100644 --- a/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift +++ b/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift @@ -199,7 +199,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ func test_getPendingAdminLoginRequest() async throws { stateService.activeAccount = .fixture() let keychainRequest = try JSONEncoder().encode(PendingAdminLoginRequest.fixture()) - keychainRepository.getPendingAdminLoginRequestResult = .success(String(data: keychainRequest, encoding: .utf8)!) + keychainRepository.getPendingAdminLoginRequestReturnValue = String(data: keychainRequest, encoding: .utf8)! let result = try await subject.getPendingAdminLoginRequest(userId: "1") XCTAssertEqual(result, .fixture()) @@ -208,13 +208,12 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ /// setPendingAdminLoginRequest()` sets the specific pending login request. func test_setPendingAdminLoginRequest_value() async throws { stateService.activeAccount = .fixture() - keychainRepository.setPendingAdminLoginRequestResult = .success(()) try await subject.setPendingAdminLoginRequest(PendingAdminLoginRequest.fixture(), userId: "1") - let jsonData = keychainRepository.mockStorage[ - keychainRepository.formattedKey(for: .pendingAdminLoginRequest(userId: "1")), - ]!.data(using: .utf8)! + let jsonData = try XCTUnwrap( + keychainRepository.setPendingAdminLoginRequestReceivedArguments?.value.data(using: .utf8), + ) let request = try JSONDecoder().decode(PendingAdminLoginRequest.self, from: jsonData) XCTAssertEqual( request, @@ -225,20 +224,10 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ /// setPendingAdminLoginRequest()` deletes the specific pending login request. func test_setPendingAdminLoginRequest_nil() async throws { stateService.activeAccount = .fixture() - let keychainRequest = try JSONEncoder().encode(PendingAdminLoginRequest.fixture()) - keychainRepository.setPendingAdminLoginRequestResult = .success(()) - keychainRepository.mockStorage[ - keychainRepository.formattedKey(for: .pendingAdminLoginRequest(userId: "1")), - ] = String(data: keychainRequest, encoding: .utf8)! try await subject.setPendingAdminLoginRequest(nil, userId: "1") - XCTAssertEqual( - keychainRepository.mockStorage[ - keychainRepository.formattedKey(for: .pendingAdminLoginRequest(userId: "1")), - ], - nil, - ) + XCTAssertEqual(keychainRepository.deletePendingAdminLoginRequestReceivedUserId, "1") } /// `getPendingLoginRequests(withId:)` returns the specific pending login request. @@ -387,12 +376,12 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ stateService.masterPasswordHashes, ["13512467-9cfe-43b0-969f-07534084764b": "hashed password"], ) - try XCTAssertEqual( - keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setAccessTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().accessToken, ) - try XCTAssertEqual( - keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setRefreshTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().refreshToken, ) assertGetConfig() @@ -457,12 +446,12 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ stateService.masterPasswordHashes, ["13512467-9cfe-43b0-969f-07534084764b": "hashed password"], ) - try XCTAssertEqual( - keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setAccessTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().accessToken, ) - try XCTAssertEqual( - keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setRefreshTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().refreshToken, ) assertGetConfig() @@ -708,12 +697,12 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ ), ], ) - try XCTAssertEqual( - keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setAccessTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().accessToken, ) - try XCTAssertEqual( - keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setRefreshTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().refreshToken, ) @@ -783,12 +772,12 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ stateService.masterPasswordHashes, ["13512467-9cfe-43b0-969f-07534084764b": "hashed password"], ) - try XCTAssertEqual( - keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setAccessTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().accessToken, ) - try XCTAssertEqual( - keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setRefreshTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().refreshToken, ) @@ -850,12 +839,12 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ stateService.masterPasswordHashes, ["13512467-9cfe-43b0-969f-07534084764b": "hashed password"], ) - try XCTAssertEqual( - keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setAccessTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().accessToken, ) - try XCTAssertEqual( - keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")), + XCTAssertEqual( + keychainRepository.setRefreshTokenReceivedArguments?.value, IdentityTokenResponseModel.fixture().refreshToken, ) diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository+DeviceAuthTests.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository+DeviceAuthTests.swift index 166bc135ff..34e3f085e5 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository+DeviceAuthTests.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository+DeviceAuthTests.swift @@ -11,8 +11,7 @@ import XCTest final class KeychainRepositoryDeviceAuthTests: BitwardenTestCase { // MARK: Properties - var appIDSettingsStore: MockAppIDSettingsStore! - var keychainService: MockKeychainService! + var keychainServiceFacade: MockKeychainServiceFacade! var subject: DefaultKeychainRepository! // MARK: Setup & Teardown @@ -20,49 +19,43 @@ final class KeychainRepositoryDeviceAuthTests: BitwardenTestCase { override func setUp() { super.setUp() - appIDSettingsStore = MockAppIDSettingsStore() - keychainService = MockKeychainService() + keychainServiceFacade = MockKeychainServiceFacade() subject = DefaultKeychainRepository( - appIDService: AppIDService( - appIDSettingsStore: appIDSettingsStore, - ), - keychainService: keychainService, + keychainService: MockKeychainService(), + keychainServiceFacade: keychainServiceFacade, ) } override func tearDown() { super.tearDown() - appIDSettingsStore = nil - keychainService = nil + keychainServiceFacade = nil subject = nil } // MARK: Tests - Device Auth Key - /// `deleteDeviceAuthKey(userId:)` deletes the device auth key and metadata from the keychain. + /// `deleteDeviceAuthKey(userId:)` deletes metadata first, then the auth key, via the facade. /// func test_deleteDeviceAuthKey() async throws { - try await subject.deleteDeviceAuthKey(userId: "1") - - XCTAssertEqual(keychainService.deleteQueries.count, 2) + var deletedKeys: [String] = [] + keychainServiceFacade.deleteValueClosure = { item in + deletedKeys.append(item.unformattedKey) + } - let metadataQuery = keychainService.deleteQueries[0] as NSDictionary - let metadataAccount = metadataQuery[kSecAttrAccount] as? String - let metadataKey = await subject.formattedKey(for: .deviceAuthKeyMetadata(userId: "1")) - XCTAssertEqual(metadataAccount, metadataKey) + try await subject.deleteDeviceAuthKey(userId: "1") - let recordQuery = keychainService.deleteQueries[1] as NSDictionary - let recordAccount = recordQuery[kSecAttrAccount] as? String - let recordKey = await subject.formattedKey(for: .deviceAuthKey(userId: "1")) - XCTAssertEqual(recordAccount, recordKey) + XCTAssertEqual(deletedKeys.count, 2) + XCTAssertEqual(deletedKeys[0], BitwardenKeychainItem.deviceAuthKeyMetadata(userId: "1").unformattedKey) + XCTAssertEqual(deletedKeys[1], BitwardenKeychainItem.deviceAuthKey(userId: "1").unformattedKey) } - /// `deleteDeviceAuthKey(userId:)` throws an error if one occurs. + /// `deleteDeviceAuthKey(userId:)` rethrows errors from the facade. /// func test_deleteDeviceAuthKey_error() async { let error = KeychainServiceError.osStatusError(-1) - keychainService.deleteResult = .failure(error) + keychainServiceFacade.deleteValueThrowableError = error + await assertAsyncThrows(error: error) { try await subject.deleteDeviceAuthKey(userId: "1") } @@ -72,42 +65,44 @@ final class KeychainRepositoryDeviceAuthTests: BitwardenTestCase { /// func test_getDeviceAuthKey() async throws { let record = DeviceAuthKeyRecord.fixture() - let recordData = try JSONEncoder.defaultEncoder.encode(record) - let recordString = String(data: recordData, encoding: .utf8)! + keychainServiceFacade.getValueReturnValue = String(data: recordData, encoding: .utf8)! - keychainService.setSearchResultData(string: recordString) let result = try await subject.getDeviceAuthKey(userId: "1") XCTAssertEqual(result, record) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.deviceAuthKey(userId: "1").unformattedKey, + ) } - /// `getDeviceAuthKey(userId:)` returns nil if the key is not found. + /// `getDeviceAuthKey(userId:)` returns nil when the key is not found. /// func test_getDeviceAuthKey_notFound() async throws { - let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.deviceAuthKey(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + BitwardenKeychainItem.deviceAuthKey(userId: "1"), + ) let result = try await subject.getDeviceAuthKey(userId: "1") XCTAssertNil(result) } - /// `getDeviceAuthKey(userId:)` returns nil if the key is not found. + /// `getDeviceAuthKey(userId:)` returns nil when the OS status indicates not found. /// func test_getDeviceAuthKey_notFound_osStatus() async throws { - let error = KeychainServiceError.osStatusError(errSecItemNotFound) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) let result = try await subject.getDeviceAuthKey(userId: "1") XCTAssertNil(result) } - /// `getDeviceAuthKey(userId:)` throws an error if the data is invalid. + /// `getDeviceAuthKey(userId:)` rethrows when the stored data is invalid JSON. /// - func test_getDeviceAuthKey_invalidData() async throws { - keychainService.setSearchResultData(string: "invalid-json") + func test_getDeviceAuthKey_invalidData() async { + keychainServiceFacade.getValueReturnValue = "invalid-json" await assertAsyncThrows { _ = try await subject.getDeviceAuthKey(userId: "1") @@ -119,94 +114,90 @@ final class KeychainRepositoryDeviceAuthTests: BitwardenTestCase { func test_getDeviceAuthKeyMetadata() async throws { let metadata = DeviceAuthKeyMetadata.fixture() let metadataData = try JSONEncoder.defaultEncoder.encode(metadata) - let metadataString = String(data: metadataData, encoding: .utf8)! + keychainServiceFacade.getValueReturnValue = String(data: metadataData, encoding: .utf8)! - keychainService.setSearchResultData(string: metadataString) let result = try await subject.getDeviceAuthKeyMetadata(userId: "1") XCTAssertEqual(result, metadata) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.deviceAuthKeyMetadata(userId: "1").unformattedKey, + ) } - /// `getDeviceAuthKeyMetadata(userId:)` returns nil if the metadata is not found. + /// `getDeviceAuthKeyMetadata(userId:)` returns nil when the metadata is not found. /// func test_getDeviceAuthKeyMetadata_notFound() async throws { - let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.deviceAuthKeyMetadata(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + BitwardenKeychainItem.deviceAuthKeyMetadata(userId: "1"), + ) let result = try await subject.getDeviceAuthKeyMetadata(userId: "1") XCTAssertNil(result) } - /// `getDeviceAuthKeyMetadata(userId:)` returns nil if the metadata is not found. + /// `getDeviceAuthKeyMetadata(userId:)` returns nil when the OS status indicates not found. /// func test_getDeviceAuthKeyMetadata_notFound_osStatus() async throws { - let error = KeychainServiceError.osStatusError(errSecItemNotFound) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) let result = try await subject.getDeviceAuthKeyMetadata(userId: "1") XCTAssertNil(result) } - /// `getDeviceAuthKeyMetadata(userId:)` throws an error if the data is invalid. + /// `getDeviceAuthKeyMetadata(userId:)` rethrows when the stored data is invalid JSON. /// - func test_getDeviceAuthKeyMetadata_invalidData() async throws { - keychainService.setSearchResultData(string: "invalid-json") + func test_getDeviceAuthKeyMetadata_invalidData() async { + keychainServiceFacade.getValueReturnValue = "invalid-json" await assertAsyncThrows { _ = try await subject.getDeviceAuthKeyMetadata(userId: "1") } } - /// `setDeviceAuthKey(record:metadata:userId:)` stores the device auth key and metadata in the keychain. + /// `setDeviceAuthKey(record:metadata:userId:)` stores the record first, then metadata, via the facade. /// func test_setDeviceAuthKey() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [], - nil, - )!, - ) + var setArgs: [(value: String, key: String)] = [] + keychainServiceFacade.setValueClosure = { value, item in + setArgs.append((value: value, key: item.unformattedKey)) + } let record = DeviceAuthKeyRecord.fixture() let metadata = DeviceAuthKeyMetadata.fixture() - try await subject.setDeviceAuthKey(record: record, metadata: metadata, userId: "1") - // Verify the record was stored - XCTAssertEqual(keychainService.addCalls.count, 2) - let recordAttributes = try XCTUnwrap(keychainService.addCalls[0]) as Dictionary - let recordData = try XCTUnwrap(recordAttributes[kSecValueData] as? Data) + XCTAssertEqual(setArgs.count, 2) + XCTAssertEqual(setArgs[0].key, BitwardenKeychainItem.deviceAuthKey(userId: "1").unformattedKey) + XCTAssertEqual(setArgs[1].key, BitwardenKeychainItem.deviceAuthKeyMetadata(userId: "1").unformattedKey) + let decodedRecord = try JSONDecoder.defaultDecoder.decode( DeviceAuthKeyRecord.self, - from: recordData, + from: XCTUnwrap(setArgs[0].value.data(using: .utf8)), ) XCTAssertEqual(decodedRecord, record) - // Verify the metadata was stored - let metadataAttributes = try XCTUnwrap(keychainService.addCalls[1]) as Dictionary - let metadataData = try XCTUnwrap(metadataAttributes[kSecValueData] as? Data) let decodedMetadata = try JSONDecoder.defaultDecoder.decode( DeviceAuthKeyMetadata.self, - from: metadataData, + from: XCTUnwrap(setArgs[1].value.data(using: .utf8)), ) XCTAssertEqual(decodedMetadata, metadata) } - /// `setDeviceAuthKey(record:metadata:userId:)` throws an error if one occurs. + /// `setDeviceAuthKey(record:metadata:userId:)` rethrows errors from the facade. /// - func test_setDeviceAuthKey_accessControlError() async { + func test_setDeviceAuthKey_error() async { let error = KeychainServiceError.accessControlFailed(nil) - keychainService.accessControlResult = .failure(error) - - let record = DeviceAuthKeyRecord.fixture() - let metadata = DeviceAuthKeyMetadata.fixture() + keychainServiceFacade.setValueThrowableError = error await assertAsyncThrows(error: error) { - try await subject.setDeviceAuthKey(record: record, metadata: metadata, userId: "1") + try await subject.setDeviceAuthKey( + record: DeviceAuthKeyRecord.fixture(), + metadata: DeviceAuthKeyMetadata.fixture(), + userId: "1", + ) } } } diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository+ServerCommunicationConfigTests.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository+ServerCommunicationConfigTests.swift index ec8274f06d..00e298b2cc 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository+ServerCommunicationConfigTests.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository+ServerCommunicationConfigTests.swift @@ -12,8 +12,7 @@ import XCTest final class KeychainRepositoryServerCommunicationConfigTests: BitwardenTestCase { // MARK: Properties - var appIDSettingsStore: MockAppIDSettingsStore! - var keychainService: MockKeychainService! + var keychainServiceFacade: MockKeychainServiceFacade! var subject: DefaultKeychainRepository! // MARK: Setup & Teardown @@ -21,54 +20,54 @@ final class KeychainRepositoryServerCommunicationConfigTests: BitwardenTestCase override func setUp() { super.setUp() - appIDSettingsStore = MockAppIDSettingsStore() - keychainService = MockKeychainService() + keychainServiceFacade = MockKeychainServiceFacade() subject = DefaultKeychainRepository( - appIDService: AppIDService( - appIDSettingsStore: appIDSettingsStore, - ), - keychainService: keychainService, + keychainService: MockKeychainService(), + keychainServiceFacade: keychainServiceFacade, ) } override func tearDown() { super.tearDown() - appIDSettingsStore = nil - keychainService = nil + keychainServiceFacade = nil subject = nil } // MARK: Tests - Server Communication Config - /// `deleteServerCommunicationConfig(hostname:)` deletes the keychain item with the correct query. + /// `deleteServerCommunicationConfig(hostname:)` deletes the correct item via the facade. + /// func test_deleteServerCommunicationConfig() async throws { - let hostname = "example.com" - let item = BitwardenKeychainItem.serverCommunicationConfig(hostname: hostname) - keychainService.deleteResult = .success(()) - let expectedQuery = await subject.keychainQueryValues(for: item) + try await subject.deleteServerCommunicationConfig(hostname: "example.com") - try await subject.deleteServerCommunicationConfig(hostname: hostname) - - XCTAssertEqual(keychainService.deleteQueries, [expectedQuery]) + XCTAssertEqual( + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com").unformattedKey, + ) } /// `getServerCommunicationConfig(hostname:)` returns the stored config. + /// func test_getServerCommunicationConfig_success() async throws { let config = ServerCommunicationConfig(bootstrap: .direct) let configData = try JSONEncoder.defaultEncoder.encode(config) - let configString = String(data: configData, encoding: .utf8)! - keychainService.setSearchResultData(string: configString) + keychainServiceFacade.getValueReturnValue = String(data: configData, encoding: .utf8)! let result = try await subject.getServerCommunicationConfig(hostname: "example.com") XCTAssertEqual(result, ServerCommunicationConfig(bootstrap: .direct)) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com").unformattedKey, + ) } - /// `getServerCommunicationConfig(hostname:)` returns `nil` when the key is not found. + /// `getServerCommunicationConfig(hostname:)` returns nil on keyNotFound. + /// func test_getServerCommunicationConfig_notFound_keyNotFound() async throws { - keychainService.searchResult = .failure( - KeychainServiceError.keyNotFound(BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com")), + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com"), ) let result = try await subject.getServerCommunicationConfig(hostname: "example.com") @@ -76,50 +75,47 @@ final class KeychainRepositoryServerCommunicationConfigTests: BitwardenTestCase XCTAssertNil(result) } - /// `getServerCommunicationConfig(hostname:)` returns `nil` when the OS status indicates not found. + /// `getServerCommunicationConfig(hostname:)` returns nil when the OS status indicates not found. + /// func test_getServerCommunicationConfig_notFound_osStatus() async throws { - keychainService.searchResult = .failure(KeychainServiceError.osStatusError(errSecItemNotFound)) + keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) let result = try await subject.getServerCommunicationConfig(hostname: "example.com") XCTAssertNil(result) } - /// `setServerCommunicationConfig(_:hostname:)` stores the config as JSON in the keychain. + /// `setServerCommunicationConfig(_:hostname:)` stores the config as JSON via the facade. + /// func test_setServerCommunicationConfig_withConfig() async throws { - let item = BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com") - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - item.protection, - item.accessControlFlags ?? [], - nil, - )!, - ) - keychainService.updateResult = .success(()) - let config = ServerCommunicationConfig(bootstrap: .direct) try await subject.setServerCommunicationConfig(config, hostname: "example.com") - let updateAttributes = try XCTUnwrap(keychainService.updateAttributes as? [CFString: Any]) - let valueData = try XCTUnwrap(updateAttributes[kSecValueData] as? Data) - let decoded = try JSONDecoder.defaultDecoder.decode(ServerCommunicationConfig.self, from: valueData) + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com").unformattedKey, + ) + let storedValue = try XCTUnwrap(keychainServiceFacade.setValueReceivedArguments?.value) + let decoded = try JSONDecoder.defaultDecoder.decode( + ServerCommunicationConfig.self, + from: XCTUnwrap(storedValue.data(using: .utf8)), + ) XCTAssertEqual(decoded, ServerCommunicationConfig(bootstrap: .direct)) } - /// `setServerCommunicationConfig(_:hostname:)` deletes the keychain item when config is `nil`. + /// `setServerCommunicationConfig(_:hostname:)` deletes the item when config is nil. + /// func test_setServerCommunicationConfig_nilConfig() async throws { - let hostname = "example.com" - let item = BitwardenKeychainItem.serverCommunicationConfig(hostname: hostname) - keychainService.deleteResult = .success(()) - let expectedQuery = await subject.keychainQueryValues(for: item) + try await subject.setServerCommunicationConfig(nil, hostname: "example.com") - try await subject.setServerCommunicationConfig(nil, hostname: hostname) - - XCTAssertEqual(keychainService.deleteQueries, [expectedQuery]) + XCTAssertEqual( + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com").unformattedKey, + ) } /// `unformattedKey` generates the expected unformatted key for server communication config. + /// func test_unformattedKey_serverCommunicationConfig() { let item = BitwardenKeychainItem.serverCommunicationConfig(hostname: "example.com") XCTAssertEqual(item.unformattedKey, "serverCommunicationConfig_example.com") diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository+UserSessionTests.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository+UserSessionTests.swift index 4f4198bb5c..2e32c4df98 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository+UserSessionTests.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository+UserSessionTests.swift @@ -11,8 +11,7 @@ import XCTest final class KeychainRepositoryUserSessionTests: BitwardenTestCase { // MARK: Properties - var appIDSettingsStore: MockAppIDSettingsStore! - var keychainService: MockKeychainService! + var keychainServiceFacade: MockKeychainServiceFacade! var subject: DefaultKeychainRepository! // MARK: Setup & Teardown @@ -20,21 +19,17 @@ final class KeychainRepositoryUserSessionTests: BitwardenTestCase { override func setUp() { super.setUp() - appIDSettingsStore = MockAppIDSettingsStore() - keychainService = MockKeychainService() + keychainServiceFacade = MockKeychainServiceFacade() subject = DefaultKeychainRepository( - appIDService: AppIDService( - appIDSettingsStore: appIDSettingsStore, - ), - keychainService: keychainService, + keychainService: MockKeychainService(), + keychainServiceFacade: keychainServiceFacade, ) } override func tearDown() { super.tearDown() - appIDSettingsStore = nil - keychainService = nil + keychainServiceFacade = nil subject = nil } @@ -43,57 +38,56 @@ final class KeychainRepositoryUserSessionTests: BitwardenTestCase { /// `getLastActiveTime(userId:)` returns the stored last active time. /// func test_getLastActiveTime() async throws { - keychainService.setSearchResultData(string: "1234567890") - let lastActiveTime = try await subject.getLastActiveTime(userId: "1") - XCTAssertEqual(lastActiveTime, Date(timeIntervalSince1970: 1_234_567_890)) + keychainServiceFacade.getValueReturnValue = "1234567890" + + let result = try await subject.getLastActiveTime(userId: "1") + + XCTAssertEqual(result, Date(timeIntervalSince1970: 1_234_567_890)) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.lastActiveTime(userId: "1").unformattedKey, + ) } - /// `getLastActiveTime(userId:)` throws an error if one occurs. + /// `getLastActiveTime(userId:)` rethrows non-notFound errors. /// func test_getLastActiveTime_error() async { let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.lastActiveTime(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getLastActiveTime(userId: "1") } } - /// `getLastActiveTime(userId:)` returns nil when the time has never been set. + /// `getLastActiveTime(userId:)` returns nil when the item is not found. + /// func test_getLastActiveTime_itemNotFound() async throws { - let error = KeychainServiceError.osStatusError(errSecItemNotFound) - keychainService.searchResult = .failure(error) - let lastActiveTime = try await subject.getLastActiveTime(userId: "1") - XCTAssertNil(lastActiveTime) + keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) + + let result = try await subject.getLastActiveTime(userId: "1") + + XCTAssertNil(result) } - /// `setLastActiveTime(_:userId:)` stores the last active time with correct attributes. + /// `setLastActiveTime(_:userId:)` stores the timestamp string via the facade. /// func test_setLastActiveTime() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [], - nil, - )!, - ) - keychainService.setSearchResultData(string: "1234567890") try await subject.setLastActiveTime(Date(timeIntervalSince1970: 1_234_567_890), userId: "1") - let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary - try XCTAssertEqual( - String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8), - "1234567890.0", + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "1234567890.0") + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.lastActiveTime(userId: "1").unformattedKey, ) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) } - /// `setLastActiveTime(_:userId:)` throws an error if one occurs. + /// `setLastActiveTime(_:userId:)` rethrows errors from the facade. /// func test_setLastActiveTime_error() async { let error = KeychainServiceError.accessControlFailed(nil) - keychainService.accessControlResult = .failure(error) + keychainServiceFacade.setValueThrowableError = error + await assertAsyncThrows(error: error) { try await subject.setLastActiveTime(Date(timeIntervalSince1970: 1_234_567_890), userId: "1") } @@ -101,44 +95,41 @@ final class KeychainRepositoryUserSessionTests: BitwardenTestCase { // MARK: Tests - Unsuccessful Unlock Attempts - /// `getUnsuccessfulUnlockAttempts(userId:)` returns the stored value of unsuccessful unlock attempts. + /// `getUnsuccessfulUnlockAttempts(userId:)` returns the stored unlock attempt count. + /// func test_getUnsuccessfulUnlockAttempts() async throws { - keychainService.setSearchResultData(string: "4") - let attempts = try await subject.getUnsuccessfulUnlockAttempts(userId: "1") - XCTAssertEqual(attempts, 4) + keychainServiceFacade.getValueReturnValue = "4" + + let result = try await subject.getUnsuccessfulUnlockAttempts(userId: "1") + + XCTAssertEqual(result, 4) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.unsuccessfulUnlockAttempts(userId: "1").unformattedKey, + ) } - /// `getUnsuccessfulUnlockAttempts(userId:)` throws an error if one occurs. + /// `getUnsuccessfulUnlockAttempts(userId:)` rethrows errors from the facade. /// func test_getUnsuccessfulUnlockAttempts_error() async { let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.unsuccessfulUnlockAttempts(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getUnsuccessfulUnlockAttempts(userId: "1") } } - /// `setUnsuccessfulUnlockAttempts(_:userId:)` stores the number of unsuccessful unlock attempts. + /// `setUnsuccessfulUnlockAttempts(_:userId:)` stores the count as a string via the facade. /// func test_setUnsuccessfulUnlockAttempts() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [], - nil, - )!, - ) - keychainService.setSearchResultData(string: "2") try await subject.setUnsuccessfulUnlockAttempts(3, userId: "1") - let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary - try XCTAssertEqual( - String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8), - "3", + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "3") + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.unsuccessfulUnlockAttempts(userId: "1").unformattedKey, ) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) } // MARK: Tests - Vault Timeout @@ -146,49 +137,46 @@ final class KeychainRepositoryUserSessionTests: BitwardenTestCase { /// `getVaultTimeout(userId:)` returns the stored vault timeout. /// func test_getVaultTimeout() async throws { - keychainService.setSearchResultData(string: "15") - let vaultTimeout = try await subject.getVaultTimeout(userId: "1") - XCTAssertEqual(vaultTimeout, 15) + keychainServiceFacade.getValueReturnValue = "15" + + let result = try await subject.getVaultTimeout(userId: "1") + + XCTAssertEqual(result, 15) + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.vaultTimeout(userId: "1").unformattedKey, + ) } - /// `getVaultTimeout(userId:)` throws an error if one occurs. + /// `getVaultTimeout(userId:)` rethrows errors from the facade. /// func test_getVaultTimeout_error() async { let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.vaultTimeout(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getVaultTimeout(userId: "1") } } - /// `setVaultTimeout(_:userId:)` stores the vault timeout with correct attributes. + /// `setVaultTimeout(minutes:userId:)` stores the timeout as a string via the facade. /// func test_setVaultTimeout() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [], - nil, - )!, - ) - keychainService.setSearchResultData(string: "30") try await subject.setVaultTimeout(minutes: 30, userId: "1") - let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary - try XCTAssertEqual( - String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8), - "30", + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "30") + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.vaultTimeout(userId: "1").unformattedKey, ) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) } - /// `setVaultTimeout(_:userId:)` throws an error if one occurs. + /// `setVaultTimeout(minutes:userId:)` rethrows errors from the facade. /// - func test_setVaultTimeout_accessControlError() async { + func test_setVaultTimeout_error() async { let error = KeychainServiceError.accessControlFailed(nil) - keychainService.accessControlResult = .failure(error) + keychainServiceFacade.setValueThrowableError = error + await assertAsyncThrows(error: error) { try await subject.setVaultTimeout(minutes: 30, userId: "1") } diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift index 5c51cb6f0c..7c3ac6639c 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift @@ -125,7 +125,7 @@ enum BitwardenKeychainItem: Equatable, KeychainItem { // MARK: - KeychainRepository -protocol KeychainRepository: AnyObject, ServerCommunicationConfigKeychainRepository { +protocol KeychainRepository: AnyObject, ServerCommunicationConfigKeychainRepository { // sourcery: AutoMockable /// Deletes all items stored in the keychain. /// func deleteAllItems() async throws @@ -251,183 +251,27 @@ protocol KeychainRepository: AnyObject, ServerCommunicationConfigKeychainReposit func setUserAuthKey(for item: BitwardenKeychainItem, value: String) async throws } -extension KeychainRepository { - /// The format for storing a `KeychainItem`'s `unformattedKey`. - /// The first value should be a unique appID from the `appIDService`. - /// The second value is the `unformattedKey` - /// - /// example: `bwKeyChainStorage:1234567890:biometric_key_98765` - /// - var storageKeyFormat: String { "bwKeyChainStorage:%@:%@" } -} - // MARK: - DefaultKeychainRepository class DefaultKeychainRepository: KeychainRepository { // MARK: Properties - /// A service used to provide unique app ids. - /// - let appIDService: AppIDService - - /// An identifier for the keychain service used by the application and extensions. - /// - /// Example: "com.8bit.bitwarden". - /// - var appSecAttrService: String { - Bundle.main.appIdentifier - } - - /// An identifier for the keychain access group used by the application group and extensions. - /// - /// Example: "LTZ2PFU5D6.com.8bit.bitwarden" + /// The keychain service used for bulk deletion operations not covered by the facade. /// - var appSecAttrAccessGroup: String { - Bundle.main.keychainAccessGroup - } + let keychainService: KeychainService - /// The keychain service used by the repository + /// The keychain service facade used by the repository. /// - let keychainService: KeychainService + let keychainServiceFacade: KeychainServiceFacade // MARK: Initialization init( - appIDService: AppIDService, keychainService: KeychainService, + keychainServiceFacade: KeychainServiceFacade, ) { - self.appIDService = appIDService self.keychainService = keychainService - } - - // MARK: Methods - - /// Generates a formatted storage key for a keychain item. - /// - /// - Parameter item: The keychain item that needs a formatted key. - /// - Returns: A formatted storage key. - /// - func formattedKey(for item: BitwardenKeychainItem) async -> String { - let appId = await appIDService.getOrCreateAppID() - return String(format: storageKeyFormat, appId, item.unformattedKey) - } - - /// Gets the value associated with the keychain item from the keychain. - /// - /// - Parameter item: The keychain item used to fetch the associated value. - /// - Returns: The fetched value associated with the keychain item. - /// - func getValue(for item: BitwardenKeychainItem) async throws -> String { - let foundItem = try await keychainService.search( - query: keychainQueryValues( - for: item, - adding: [ - kSecMatchLimit: kSecMatchLimitOne, - kSecReturnData: true, - kSecReturnAttributes: true, - ], - ), - ) - - if let resultDictionary = foundItem as? [String: Any], - let data = resultDictionary[kSecValueData as String] as? Data, - let string = String(data: data, encoding: .utf8) { - guard !string.isEmpty else { - throw KeychainServiceError.keyNotFound(item) - } - return string - } - - throw KeychainServiceError.keyNotFound(item) - } - - /// Gets the value associated with the keychain item from the keychain. - /// - /// - Parameter item: The keychain item used to fetch the associated value. - /// - Returns: The fetched value associated with the keychain item. - /// - func getValue(for item: BitwardenKeychainItem) async throws -> T { - let string = try await getValue(for: item) - - guard let jsonData = string.data(using: .utf8) else { - throw BitwardenError.dataError("JSON string contains invalid UTF-8 encoding.") - } - - return try JSONDecoder.defaultDecoder.decode(T.self, from: jsonData) - } - - /// The core key/value pairs for Keychain operations. - /// - /// - Parameter item: The `KeychainItem` to be queried. - /// - func keychainQueryValues( - for item: BitwardenKeychainItem, - adding additionalPairs: [CFString: Any] = [:], - ) async -> CFDictionary { - // Prepare a formatted `kSecAttrAccount` value. - let formattedSecAttrAccount = await formattedKey(for: item) - - // Configure the base dictionary - var result: [CFString: Any] = [ - kSecAttrAccount: formattedSecAttrAccount, - kSecAttrAccessGroup: appSecAttrAccessGroup, - kSecAttrService: appSecAttrService, - kSecClass: kSecClassGenericPassword, - ] - - // Add the additional key value pairs. - additionalPairs.forEach { key, value in - result[key] = value - } - - return result as CFDictionary - } - - /// Sets a value associated with a keychain item in the keychain. - /// - /// - Parameters: - /// - value: The value associated with the keychain item to set. - /// - item: The keychain item used to set the associated value. - /// - func setValue(_ value: String, for item: BitwardenKeychainItem) async throws { - let accessControl = try keychainService.accessControl( - protection: item.protection, - for: item.accessControlFlags ?? [], - ) - let baseQuery = await keychainQueryValues(for: item) - let updateAttributes: CFDictionary = [ - kSecAttrAccessControl: accessControl as Any, - kSecValueData: Data(value.utf8), - ] as CFDictionary - - do { - // Try to update first - if item exists, this avoids delete-then-add race condition - try keychainService.update(query: baseQuery, attributes: updateAttributes) - } catch KeychainServiceError.osStatusError(errSecItemNotFound) { - // Item doesn't exist, so add it - let addAttributes = await keychainQueryValues( - for: item, - adding: [ - kSecAttrAccessControl: accessControl as Any, - kSecValueData: Data(value.utf8), - ], - ) - try keychainService.add(attributes: addAttributes) - } - } - - /// Sets a value associated with a keychain item in the keychain. - /// - /// - Parameters: - /// - value: The value associated with the keychain item to set. - /// - item: The keychain item used to set the associated value. - /// - func setValue(_ value: T, for item: BitwardenKeychainItem) async throws { - let jsonData = try JSONEncoder.defaultEncoder.encode(value) - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw BitwardenError.dataError("JSON data is not valid.") - } - try await setValue(jsonString, for: item) + self.keychainServiceFacade = keychainServiceFacade } } @@ -446,9 +290,7 @@ extension DefaultKeychainRepository { } func deleteAuthenticatorVaultKey(userId: String) async throws { - try await keychainService.delete( - query: keychainQueryValues(for: .authenticatorVaultKey(userId: userId)), - ) + try await keychainServiceFacade.deleteValue(for: BitwardenKeychainItem.authenticatorVaultKey(userId: userId)) } func deleteItems(for userId: String) async throws { @@ -467,39 +309,37 @@ extension DefaultKeychainRepository { // Exclude `vaultTimeout` since it should be maintained for users who log out and back in regularly. ] for keychainItem in keychainItems { - try await keychainService.delete(query: keychainQueryValues(for: keychainItem)) + try await keychainServiceFacade.deleteValue(for: keychainItem) } } - func deleteUserAuthKey(for item: BitwardenKeychainItem) async throws { - try await keychainService.delete( - query: keychainQueryValues(for: item), - ) - } - func deleteDeviceKey(userId: String) async throws { - try await keychainService.delete( - query: keychainQueryValues(for: .deviceKey(userId: userId)), - ) + try await keychainServiceFacade.deleteValue(for: BitwardenKeychainItem.deviceKey(userId: userId)) } func deletePendingAdminLoginRequest(userId: String) async throws { - try await keychainService.delete( - query: keychainQueryValues(for: .pendingAdminLoginRequest(userId: userId)), + try await keychainServiceFacade.deleteValue( + for: BitwardenKeychainItem.pendingAdminLoginRequest(userId: userId), ) } + func deleteUserAuthKey(for item: BitwardenKeychainItem) async throws { + try await keychainServiceFacade.deleteValue(for: item) + } + func getAccessToken(userId: String) async throws -> String { - try await getValue(for: .accessToken(userId: userId)) + try await keychainServiceFacade.getValue(for: BitwardenKeychainItem.accessToken(userId: userId)) } func getAuthenticatorVaultKey(userId: String) async throws -> String { - try await getValue(for: .authenticatorVaultKey(userId: userId)) + try await keychainServiceFacade.getValue(for: BitwardenKeychainItem.authenticatorVaultKey(userId: userId)) } func getDeviceKey(userId: String) async throws -> String? { do { - let value: String = try await getValue(for: .deviceKey(userId: userId)) + let value: String = try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.deviceKey(userId: userId), + ) return value } catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound { return nil @@ -507,12 +347,14 @@ extension DefaultKeychainRepository { } func getRefreshToken(userId: String) async throws -> String { - try await getValue(for: .refreshToken(userId: userId)) + try await keychainServiceFacade.getValue(for: BitwardenKeychainItem.refreshToken(userId: userId)) } func getPendingAdminLoginRequest(userId: String) async throws -> String? { do { - let value: String = try await getValue(for: .pendingAdminLoginRequest(userId: userId)) + let value: String = try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.pendingAdminLoginRequest(userId: userId), + ) return value } catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound { return nil @@ -520,31 +362,37 @@ extension DefaultKeychainRepository { } func getUserAuthKeyValue(for item: BitwardenKeychainItem) async throws -> String { - try await getValue(for: item) + try await keychainServiceFacade.getValue(for: item) } func setAccessToken(_ value: String, userId: String) async throws { - try await setValue(value, for: .accessToken(userId: userId)) + try await keychainServiceFacade.setValue(value, for: BitwardenKeychainItem.accessToken(userId: userId)) } func setAuthenticatorVaultKey(_ value: String, userId: String) async throws { - try await setValue(value, for: .authenticatorVaultKey(userId: userId)) + try await keychainServiceFacade.setValue( + value, + for: BitwardenKeychainItem.authenticatorVaultKey(userId: userId), + ) } func setDeviceKey(_ value: String, userId: String) async throws { - try await setValue(value, for: .deviceKey(userId: userId)) + try await keychainServiceFacade.setValue(value, for: BitwardenKeychainItem.deviceKey(userId: userId)) } func setRefreshToken(_ value: String, userId: String) async throws { - try await setValue(value, for: .refreshToken(userId: userId)) + try await keychainServiceFacade.setValue(value, for: BitwardenKeychainItem.refreshToken(userId: userId)) } func setPendingAdminLoginRequest(_ value: String, userId: String) async throws { - try await setValue(value, for: .pendingAdminLoginRequest(userId: userId)) + try await keychainServiceFacade.setValue( + value, + for: BitwardenKeychainItem.pendingAdminLoginRequest(userId: userId), + ) } func setUserAuthKey(for item: BitwardenKeychainItem, value: String) async throws { - try await setValue(value, for: item) + try await keychainServiceFacade.setValue(value, for: item) } } @@ -552,18 +400,15 @@ extension DefaultKeychainRepository { extension DefaultKeychainRepository: BiometricsKeychainRepository { func deleteUserBiometricAuthKey(userId: String) async throws { - let key = BitwardenKeychainItem.biometrics(userId: userId) - try await deleteUserAuthKey(for: key) + try await keychainServiceFacade.deleteValue(for: BitwardenKeychainItem.biometrics(userId: userId)) } func getUserBiometricAuthKey(userId: String) async throws -> String { - let key = BitwardenKeychainItem.biometrics(userId: userId) - return try await getUserAuthKeyValue(for: key) + try await keychainServiceFacade.getValue(for: BitwardenKeychainItem.biometrics(userId: userId)) } func setUserBiometricAuthKey(userId: String, value: String) async throws { - let key = BitwardenKeychainItem.biometrics(userId: userId) - try await setUserAuthKey(for: key, value: value) + try await keychainServiceFacade.setValue(value, for: BitwardenKeychainItem.biometrics(userId: userId)) } } @@ -573,17 +418,17 @@ extension DefaultKeychainRepository: DeviceAuthKeychainRepository { func deleteDeviceAuthKey(userId: String) async throws { // We want to delete metadata first because that's what's used to determine if we're in a // consistent state. - try await keychainService.delete( - query: keychainQueryValues(for: .deviceAuthKeyMetadata(userId: userId)), - ) - try await keychainService.delete( - query: keychainQueryValues(for: .deviceAuthKey(userId: userId)), + try await keychainServiceFacade.deleteValue( + for: BitwardenKeychainItem.deviceAuthKeyMetadata(userId: userId), ) + try await keychainServiceFacade.deleteValue(for: BitwardenKeychainItem.deviceAuthKey(userId: userId)) } func getDeviceAuthKey(userId: String) async throws -> DeviceAuthKeyRecord? { do { - return try await getValue(for: .deviceAuthKey(userId: userId)) + return try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.deviceAuthKey(userId: userId), + ) } catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound { return nil } @@ -591,7 +436,9 @@ extension DefaultKeychainRepository: DeviceAuthKeychainRepository { func getDeviceAuthKeyMetadata(userId: String) async throws -> DeviceAuthKeyMetadata? { do { - return try await getValue(for: .deviceAuthKeyMetadata(userId: userId)) + return try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.deviceAuthKeyMetadata(userId: userId), + ) } catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound { return nil } @@ -604,8 +451,11 @@ extension DefaultKeychainRepository: DeviceAuthKeychainRepository { ) async throws { // We want to set metadata last because that's what's used to determine if we're in a // consistent state. - try await setValue(record, for: .deviceAuthKey(userId: userId)) - try await setValue(metadata, for: .deviceAuthKeyMetadata(userId: userId)) + try await keychainServiceFacade.setValue(record, for: BitwardenKeychainItem.deviceAuthKey(userId: userId)) + try await keychainServiceFacade.setValue( + metadata, + for: BitwardenKeychainItem.deviceAuthKeyMetadata(userId: userId), + ) } } @@ -616,7 +466,9 @@ extension DefaultKeychainRepository: UserSessionKeychainRepository { func getLastActiveTime(userId: String) async throws -> Date? { do { - let stored = try await getValue(for: .lastActiveTime(userId: userId)) + let stored = try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.lastActiveTime(userId: userId), + ) guard let timeInterval = TimeInterval(stored) else { return nil } @@ -628,30 +480,37 @@ extension DefaultKeychainRepository: UserSessionKeychainRepository { func setLastActiveTime(_ date: Date?, userId: String) async throws { let value = date.map { String($0.timeIntervalSince1970) } ?? "" - try await setValue(value, for: .lastActiveTime(userId: userId)) + try await keychainServiceFacade.setValue(value, for: BitwardenKeychainItem.lastActiveTime(userId: userId)) } // MARK: Unsuccessful Unlock Attempts func getUnsuccessfulUnlockAttempts(userId: String) async throws -> Int? { - let stored = try await getValue(for: .unsuccessfulUnlockAttempts(userId: userId)) + let stored = try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.unsuccessfulUnlockAttempts(userId: userId), + ) return Int(stored) } func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String) async throws { let value = String(attempts) - try await setValue(value, for: .unsuccessfulUnlockAttempts(userId: userId)) + try await keychainServiceFacade.setValue( + value, + for: BitwardenKeychainItem.unsuccessfulUnlockAttempts(userId: userId), + ) } // MARK: Vault Timeout func getVaultTimeout(userId: String) async throws -> Int? { - let stored = try await getValue(for: .vaultTimeout(userId: userId)) + let stored = try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.vaultTimeout(userId: userId), + ) return Int(stored) } func setVaultTimeout(minutes: Int, userId: String) async throws { let value = String(minutes) - try await setValue(value, for: .vaultTimeout(userId: userId)) + try await keychainServiceFacade.setValue(value, for: BitwardenKeychainItem.vaultTimeout(userId: userId)) } } diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepositoryTests.swift b/BitwardenShared/Core/Auth/Services/KeychainRepositoryTests.swift index 19554fa5eb..7f58c2d651 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepositoryTests.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepositoryTests.swift @@ -12,8 +12,8 @@ import XCTest final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Properties - var appIDSettingsStore: MockAppIDSettingsStore! var keychainService: MockKeychainService! + var keychainServiceFacade: MockKeychainServiceFacade! var subject: DefaultKeychainRepository! // MARK: Setup & Teardown @@ -21,73 +21,36 @@ final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:th override func setUp() { super.setUp() - appIDSettingsStore = MockAppIDSettingsStore() keychainService = MockKeychainService() + keychainServiceFacade = MockKeychainServiceFacade() subject = DefaultKeychainRepository( - appIDService: AppIDService( - appIDSettingsStore: appIDSettingsStore, - ), keychainService: keychainService, + keychainServiceFacade: keychainServiceFacade, ) } override func tearDown() { super.tearDown() - appIDSettingsStore = nil keychainService = nil + keychainServiceFacade = nil subject = nil } - // MARK: Tests + // MARK: Tests - deleteAllItems - /// The service provides a kSecAttrService value. + /// `deleteAllItems` deletes items for all classes via the raw keychain service. /// - func test_appSecAttrService() { - XCTAssertEqual( - Bundle.main.appIdentifier, - subject.appSecAttrService, - ) - } - - /// The service provides a kSecAttrAccessGroup value. - /// - func test_appSecAttrAccessGroup() { - XCTAssertEqual( - Bundle.main.keychainAccessGroup, - subject.appSecAttrAccessGroup, - ) - } - - /// `deleteUserAuthKey` failures rethrow. - /// - func test_delete_error_onDelete() async { - keychainService.deleteResult = .failure(.osStatusError(-1)) - await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { - try await subject.deleteUserAuthKey(for: .biometrics(userId: "123")) + func test_deleteAllItems() async throws { + var deletedQueries: [CFDictionary] = [] + keychainService.deleteClosure = { query in + deletedQueries.append(query) } - } - - /// `deleteUserAuthKey` succeeds quietly. - /// - func test_delete_success() async throws { - let item = BitwardenKeychainItem.biometrics(userId: "123") - keychainService.deleteResult = .success(()) - let expectedQuery = await subject.keychainQueryValues(for: item) - try await subject.deleteUserAuthKey(for: item) - XCTAssertEqual( - keychainService.deleteQueries, - [expectedQuery], - ) - } - - /// `deleteAllItems` deletes items for all classes. - func test_deleteAllItems() async throws { try await subject.deleteAllItems() XCTAssertEqual( - keychainService.deleteQueries, + deletedQueries, [ [kSecClass: kSecClassGenericPassword] as CFDictionary, [kSecClass: kSecClassInternetPassword] as CFDictionary, @@ -98,542 +61,362 @@ final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:th ) } - /// `deleteAuthenticatorVaultKey` deletes the stored Authenticator Vault Key with the correct query values. + // MARK: Tests - deleteAuthenticatorVaultKey(userId:) + + /// `deleteAuthenticatorVaultKey(userId:)` deletes the correct item via the facade. /// func test_deleteAuthenticatorVaultKey_success() async throws { - let item = BitwardenKeychainItem.authenticatorVaultKey(userId: "1") - keychainService.deleteResult = .success(()) - let expectedQuery = await subject.keychainQueryValues(for: item) - try await subject.deleteAuthenticatorVaultKey(userId: "1") + XCTAssertEqual( - keychainService.deleteQueries, - [expectedQuery], + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.authenticatorVaultKey(userId: "1").unformattedKey, ) } - /// `deleteItems(for:)` deletes items for a specific user. + // MARK: Tests - deleteItems(for:) + + /// `deleteItems(for:)` deletes the expected items via the facade in order. + /// func test_deleteItems_forUserId() async throws { - try await subject.deleteItems(for: "1") + var deletedKeys: [String] = [] + keychainServiceFacade.deleteValueClosure = { item in + deletedKeys.append(item.unformattedKey) + } - let expectedQueries = await [ - subject.keychainQueryValues(for: .accessToken(userId: "1")), - subject.keychainQueryValues(for: .authenticatorVaultKey(userId: "1")), - subject.keychainQueryValues(for: .biometrics(userId: "1")), - subject.keychainQueryValues(for: .lastActiveTime(userId: "1")), - subject.keychainQueryValues(for: .neverLock(userId: "1")), - subject.keychainQueryValues(for: .refreshToken(userId: "1")), - subject.keychainQueryValues(for: .unsuccessfulUnlockAttempts(userId: "1")), - ] + try await subject.deleteItems(for: "1") XCTAssertEqual( - keychainService.deleteQueries, - expectedQueries, + deletedKeys, + [ + BitwardenKeychainItem.accessToken(userId: "1").unformattedKey, + BitwardenKeychainItem.authenticatorVaultKey(userId: "1").unformattedKey, + BitwardenKeychainItem.biometrics(userId: "1").unformattedKey, + BitwardenKeychainItem.lastActiveTime(userId: "1").unformattedKey, + BitwardenKeychainItem.neverLock(userId: "1").unformattedKey, + BitwardenKeychainItem.refreshToken(userId: "1").unformattedKey, + BitwardenKeychainItem.unsuccessfulUnlockAttempts(userId: "1").unformattedKey, + ], ) } - /// `deleteDeviceKey` succeeds quietly. + // MARK: Tests - deleteDeviceKey(userId:) + + /// `deleteDeviceKey(userId:)` deletes the correct item via the facade. /// func test_deleteDeviceKey_success() async throws { - let item = BitwardenKeychainItem.deviceKey(userId: "1") - keychainService.deleteResult = .success(()) - let expectedQuery = await subject.keychainQueryValues(for: item) - try await subject.deleteDeviceKey(userId: "1") + XCTAssertEqual( - keychainService.deleteQueries, - [expectedQuery], + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.deviceKey(userId: "1").unformattedKey, ) } - /// `deletePendingAdminLoginRequest` succeeds quietly. + // MARK: Tests - deletePendingAdminLoginRequest(userId:) + + /// `deletePendingAdminLoginRequest(userId:)` deletes the correct item via the facade. /// func test_deletePendingAdminLoginRequest_success() async throws { - let item = BitwardenKeychainItem.pendingAdminLoginRequest(userId: "1") - keychainService.deleteResult = .success(()) - let expectedQuery = await subject.keychainQueryValues(for: item) - try await subject.deletePendingAdminLoginRequest(userId: "1") + XCTAssertEqual( - keychainService.deleteQueries, - [expectedQuery], + keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.pendingAdminLoginRequest(userId: "1").unformattedKey, ) } - /// The service should generate a storage key for a` KeychainItem`. + // MARK: Tests - deleteUserAuthKey(for:) + + /// `deleteUserAuthKey(for:)` deletes the item via the facade. /// - func test_formattedKey_biometrics() async { + func test_deleteUserAuthKey_success() async throws { let item = BitwardenKeychainItem.biometrics(userId: "123") - appIDSettingsStore.appID = "testAppID" - let formattedKey = await subject.formattedKey(for: item) - let expectedKey = String(format: subject.storageKeyFormat, "testAppID", item.unformattedKey) + try await subject.deleteUserAuthKey(for: item) - XCTAssertEqual( - formattedKey, - expectedKey, - ) + XCTAssertEqual(keychainServiceFacade.deleteValueReceivedItem?.unformattedKey, item.unformattedKey) } - /// The service should generate a storage key for a` KeychainItem`. + /// `deleteUserAuthKey(for:)` rethrows errors from the facade. /// - func test_formattedKey_neverLock() async { - let item = BitwardenKeychainItem.neverLock(userId: "123") - appIDSettingsStore.appID = "testAppID" - let formattedKey = await subject.formattedKey(for: item) - let expectedKey = String(format: subject.storageKeyFormat, "testAppID", item.unformattedKey) + func test_deleteUserAuthKey_rethrows() async { + keychainServiceFacade.deleteValueThrowableError = KeychainServiceError.osStatusError(-1) - XCTAssertEqual( - formattedKey, - expectedKey, - ) + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + try await subject.deleteUserAuthKey(for: .biometrics(userId: "123")) + } } + // MARK: Tests - getAccessToken(userId:) + /// `getAccessToken(userId:)` returns the stored access token. + /// func test_getAccessToken() async throws { - keychainService.setSearchResultData(string: "ACCESS_TOKEN") - let accessToken = try await subject.getAccessToken(userId: "1") - XCTAssertEqual(accessToken, "ACCESS_TOKEN") + keychainServiceFacade.getValueReturnValue = "ACCESS_TOKEN" + + let result = try await subject.getAccessToken(userId: "1") + + XCTAssertEqual(result, "ACCESS_TOKEN") + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.accessToken(userId: "1").unformattedKey, + ) } - /// `getAccessToken(userId:)` throws an error if one occurs. + /// `getAccessToken(userId:)` rethrows errors from the facade. + /// func test_getAccessToken_error() async { let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.accessToken(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getAccessToken(userId: "1") } } + // MARK: Tests - getAuthenticatorVaultKey(userId:) + /// `getAuthenticatorVaultKey(userId:)` returns the stored authenticator vault key. + /// func test_getAuthenticatorVaultKey() async throws { - keychainService.setSearchResultData(string: "AUTHENTICATOR_VAULT_KEY") - let authVaultKey = try await subject.getAuthenticatorVaultKey(userId: "1") - XCTAssertEqual(authVaultKey, "AUTHENTICATOR_VAULT_KEY") + keychainServiceFacade.getValueReturnValue = "AUTHENTICATOR_VAULT_KEY" + + let result = try await subject.getAuthenticatorVaultKey(userId: "1") + + XCTAssertEqual(result, "AUTHENTICATOR_VAULT_KEY") + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.authenticatorVaultKey(userId: "1").unformattedKey, + ) } - /// `getAuthenticatorVaultKey(userId:)` throws an error if one occurs. + /// `getAuthenticatorVaultKey(userId:)` rethrows errors from the facade. + /// func test_getAuthenticatorVaultKey_error() async { let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.authenticatorVaultKey(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getAuthenticatorVaultKey(userId: "1") } } + // MARK: Tests - getDeviceKey(userId:) + /// `getDeviceKey(userId:)` returns the stored device key. + /// func test_getDeviceKey() async throws { - keychainService.setSearchResultData(string: "DEVICE_KEY") - let deviceKey = try await subject.getDeviceKey(userId: "1") - XCTAssertEqual(deviceKey, "DEVICE_KEY") + keychainServiceFacade.getValueReturnValue = "DEVICE_KEY" + + let result = try await subject.getDeviceKey(userId: "1") + + XCTAssertEqual(result, "DEVICE_KEY") } - /// `getDeviceKey(userId:)` throws an error if a non-keyNotFound error occurs. + /// `getDeviceKey(userId:)` rethrows non-notFound errors. + /// func test_getDeviceKey_error() async { let error = KeychainServiceError.osStatusError(-1) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getDeviceKey(userId: "1") } } - /// `getDeviceKey(userId:)` returns `nil` when the key is not found. + /// `getDeviceKey(userId:)` returns nil on keyNotFound. + /// func test_getDeviceKey_notFound() async throws { - let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.deviceKey(userId: "1")) - keychainService.searchResult = .failure(error) - let deviceKey = try await subject.getDeviceKey(userId: "1") - XCTAssertNil(deviceKey) - } + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + BitwardenKeychainItem.deviceKey(userId: "1") + ) - /// `getPendingAdminLoginRequest(userId:)` returns the stored pending admin login request. - func test_getPendingAdminLoginRequest() async throws { - keychainService.setSearchResultData(string: "PENDING_ADMIN_LOGIN_REQUEST") - let request = try await subject.getPendingAdminLoginRequest(userId: "1") - XCTAssertEqual(request, "PENDING_ADMIN_LOGIN_REQUEST") - } + let result = try await subject.getDeviceKey(userId: "1") - /// `getPendingAdminLoginRequest(userId:)` throws an error if a non-keyNotFound error occurs. - func test_getPendingAdminLoginRequest_error() async { - let error = KeychainServiceError.osStatusError(-1) - keychainService.searchResult = .failure(error) - await assertAsyncThrows(error: error) { - _ = try await subject.getPendingAdminLoginRequest(userId: "1") - } + XCTAssertNil(result) } - /// `getPendingAdminLoginRequest(userId:)` returns `nil` when the key is not found. - func test_getPendingAdminLoginRequest_notFound() async throws { - let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.pendingAdminLoginRequest(userId: "1")) - keychainService.searchResult = .failure(error) - let request = try await subject.getPendingAdminLoginRequest(userId: "1") - XCTAssertNil(request) - } + // MARK: Tests - getRefreshToken(userId:) /// `getRefreshToken(userId:)` returns the stored refresh token. + /// func test_getRefreshToken() async throws { - keychainService.setSearchResultData(string: "REFRESH_TOKEN") - let accessToken = try await subject.getRefreshToken(userId: "1") - XCTAssertEqual(accessToken, "REFRESH_TOKEN") + keychainServiceFacade.getValueReturnValue = "REFRESH_TOKEN" + + let result = try await subject.getRefreshToken(userId: "1") + + XCTAssertEqual(result, "REFRESH_TOKEN") + XCTAssertEqual( + keychainServiceFacade.getValueReceivedItem?.unformattedKey, + BitwardenKeychainItem.refreshToken(userId: "1").unformattedKey, + ) } - /// `getRefreshToken(userId:)` throws an error if one occurs. + /// `getRefreshToken(userId:)` rethrows errors from the facade. + /// func test_getRefreshToken_error() async { let error = KeychainServiceError.keyNotFound(BitwardenKeychainItem.refreshToken(userId: "1")) - keychainService.searchResult = .failure(error) + keychainServiceFacade.getValueThrowableError = error + await assertAsyncThrows(error: error) { _ = try await subject.getRefreshToken(userId: "1") } } - /// `getUserAuthKeyValue(_:)` failures rethrow. - /// - func test_getUserAuthKeyValue_error_searchError() async { - let item = BitwardenKeychainItem.biometrics(userId: "123") - let searchError = KeychainServiceError.osStatusError(-1) - keychainService.searchResult = .failure(searchError) - await assertAsyncThrows(error: searchError) { - _ = try await subject.getUserAuthKeyValue(for: item) - } - } + // MARK: Tests - getPendingAdminLoginRequest(userId:) - /// `getUserAuthKeyValue(_:)` errors if the search results are not in the correct format. + /// `getPendingAdminLoginRequest(userId:)` returns the stored pending admin login request. /// - func test_getUserAuthKeyValue_error_malformedData() async { - let item = BitwardenKeychainItem.biometrics(userId: "123") - let notFoundError = KeychainServiceError.keyNotFound(item) - let results = [ - kSecValueData: Data(), - ] as CFDictionary - keychainService.searchResult = .success(results) - await assertAsyncThrows(error: notFoundError) { - _ = try await subject.getUserAuthKeyValue(for: .biometrics(userId: "123")) - } - } + func test_getPendingAdminLoginRequest() async throws { + keychainServiceFacade.getValueReturnValue = "PENDING_ADMIN_LOGIN_REQUEST" - /// `getUserAuthKeyValue(_:)` errors if the search results are not in the correct format. - /// - func test_getUserAuthKeyValue_error_unexpectedResult() async { - let item = BitwardenKeychainItem.biometrics(userId: "123") - let notFoundError = KeychainServiceError.keyNotFound(item) - let results = [ - kSecValueData: 1, - ] as CFDictionary - keychainService.searchResult = .success(results) - await assertAsyncThrows(error: notFoundError) { - _ = try await subject.getUserAuthKeyValue(for: .biometrics(userId: "123")) - } + let result = try await subject.getPendingAdminLoginRequest(userId: "1") + + XCTAssertEqual(result, "PENDING_ADMIN_LOGIN_REQUEST") } - /// `getUserAuthKeyValue(_:)` errors if the search results are empty. + /// `getPendingAdminLoginRequest(userId:)` rethrows non-notFound errors. /// - func test_getUserAuthKeyValue_error_nilResult() async { - let item = BitwardenKeychainItem.biometrics(userId: "123") - let notFoundError = KeychainServiceError.keyNotFound(item) - keychainService.searchResult = .success(nil) - await assertAsyncThrows(error: notFoundError) { - _ = try await subject.getUserAuthKeyValue(for: .biometrics(userId: "123")) + func test_getPendingAdminLoginRequest_error() async { + let error = KeychainServiceError.osStatusError(-1) + keychainServiceFacade.getValueThrowableError = error + + await assertAsyncThrows(error: error) { + _ = try await subject.getPendingAdminLoginRequest(userId: "1") } } - /// `getUserAuthKeyValue(_:)` returns a string on success. + /// `getPendingAdminLoginRequest(userId:)` returns nil on keyNotFound. /// - func test_getUserAuthKeyValue_error_success() async throws { - let item = BitwardenKeychainItem.biometrics(userId: "123") - let expectedKey = "1234" - let results = [ - kSecValueData: Data("1234".utf8), - ] as CFDictionary - keychainService.searchResult = .success(results) - let key = try await subject.getUserAuthKeyValue(for: item) - XCTAssertEqual(key, expectedKey) + func test_getPendingAdminLoginRequest_notFound() async throws { + keychainServiceFacade.getValueThrowableError = KeychainServiceError.keyNotFound( + BitwardenKeychainItem.pendingAdminLoginRequest(userId: "1") + ) + + let result = try await subject.getPendingAdminLoginRequest(userId: "1") + + XCTAssertNil(result) } - /// The service should generate keychain Query Key/Values` KeychainItem`. + // MARK: Tests - getUserAuthKeyValue(for:) + + /// `getUserAuthKeyValue(for:)` returns the value from the facade. /// - func test_keychainQueryValues_biometrics() async { + func test_getUserAuthKeyValue_success() async throws { let item = BitwardenKeychainItem.biometrics(userId: "123") - appIDSettingsStore.appID = "testAppID" - let formattedKey = await subject.formattedKey(for: item) - let queryValues = await subject.keychainQueryValues(for: item) - let expectedResult = [ - kSecAttrAccount: formattedKey, - kSecAttrAccessGroup: subject.appSecAttrAccessGroup, - kSecAttrService: subject.appSecAttrService, - kSecClass: kSecClassGenericPassword, - ] as CFDictionary + keychainServiceFacade.getValueReturnValue = "1234" - XCTAssertEqual( - queryValues, - expectedResult, - ) + let result = try await subject.getUserAuthKeyValue(for: item) + + XCTAssertEqual(result, "1234") + XCTAssertEqual(keychainServiceFacade.getValueReceivedItem?.unformattedKey, item.unformattedKey) } - /// The service should generate keychain Query Key/Values` KeychainItem`. + /// `getUserAuthKeyValue(for:)` rethrows errors from the facade. /// - func test_keychainQueryValues_neverLock() async { - let item = BitwardenKeychainItem.neverLock(userId: "123") - appIDSettingsStore.appID = "testAppID" - let formattedKey = await subject.formattedKey(for: item) - let queryValues = await subject.keychainQueryValues(for: item) - let expectedResult = [ - kSecAttrAccount: formattedKey, - kSecAttrAccessGroup: subject.appSecAttrAccessGroup, - kSecAttrService: subject.appSecAttrService, - kSecClass: kSecClassGenericPassword, - ] as CFDictionary + func test_getUserAuthKeyValue_error() async { + let item = BitwardenKeychainItem.biometrics(userId: "123") + keychainServiceFacade.getValueThrowableError = KeychainServiceError.osStatusError(-1) - XCTAssertEqual( - queryValues, - expectedResult, - ) + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + _ = try await subject.getUserAuthKeyValue(for: item) + } } - /// `setAccessToken(userId:)` stored the access token. + // MARK: Tests - setAccessToken(_:userId:) + + /// `setAccessToken(_:userId:)` stores the value via the facade for the correct item. + /// func test_setAccessToken() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [], - nil, - )!, - ) - keychainService.setSearchResultData(string: "ACCESS_TOKEN") try await subject.setAccessToken("ACCESS_TOKEN", userId: "1") - let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary - try XCTAssertEqual( - String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8), - "ACCESS_TOKEN", + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "ACCESS_TOKEN") + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.accessToken(userId: "1").unformattedKey, ) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) } - /// `setAccessToken(userId:)` throws an error if one occurs. + /// `setAccessToken(_:userId:)` rethrows errors from the facade. + /// func test_setAccessToken_error() async { let error = KeychainServiceError.accessControlFailed(nil) - keychainService.addResult = .failure(error) + keychainServiceFacade.setValueThrowableError = error + await assertAsyncThrows(error: error) { - _ = try await subject.setAccessToken("ACCESS_TOKEN", userId: "1") + try await subject.setAccessToken("ACCESS_TOKEN", userId: "1") } } - /// `setAuthenticatorVaultKey(userId:)` stores the authenticator vault key. + // MARK: Tests - setAuthenticatorVaultKey(_:userId:) + + /// `setAuthenticatorVaultKey(_:userId:)` stores the value via the facade for the correct item. + /// func test_setAuthenticatorVaultKey() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - [], - nil, - )!, - ) - keychainService.setSearchResultData(string: "AUTHENTICATOR_VAULT_KEY") try await subject.setAuthenticatorVaultKey("AUTHENTICATOR_VAULT_KEY", userId: "1") - let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary - try XCTAssertEqual( - String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8), - "AUTHENTICATOR_VAULT_KEY", + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "AUTHENTICATOR_VAULT_KEY") + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.authenticatorVaultKey(userId: "1").unformattedKey, ) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) } - /// `setAuthenticatorVaultKey(userId:)` throws an error if one occurs. + /// `setAuthenticatorVaultKey(_:userId:)` rethrows errors from the facade. + /// func test_setAuthenticatorVaultKey_error() async { let error = KeychainServiceError.accessControlFailed(nil) - keychainService.addResult = .failure(error) + keychainServiceFacade.setValueThrowableError = error + await assertAsyncThrows(error: error) { - _ = try await subject.setAuthenticatorVaultKey("AUTHENTICATOR_VAULT_KEY", userId: "1") + try await subject.setAuthenticatorVaultKey("AUTHENTICATOR_VAULT_KEY", userId: "1") } } - /// `setRefreshToken(userId:)` stored the refresh token. + // MARK: Tests - setRefreshToken(_:userId:) + + /// `setRefreshToken(_:userId:)` stores the value via the facade for the correct item. + /// func test_setRefreshToken() async throws { - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - [], - nil, - )!, - ) - keychainService.setSearchResultData(string: "REFRESH_TOKEN") try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1") - let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary - try XCTAssertEqual( - String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8), - "REFRESH_TOKEN", + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "REFRESH_TOKEN") + XCTAssertEqual( + keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, + BitwardenKeychainItem.refreshToken(userId: "1").unformattedKey, ) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)) } - /// `setRefreshToken(userId:)` throws an error if one occurs. + /// `setRefreshToken(_:userId:)` rethrows errors from the facade. + /// func test_setRefreshToken_error() async { let error = KeychainServiceError.accessControlFailed(nil) - keychainService.addResult = .failure(error) - await assertAsyncThrows(error: error) { - _ = try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1") - } - } + keychainServiceFacade.setValueThrowableError = error - /// `setUserAuthKey(_:)` failures rethrow. - /// - func test_setUserAuthKey_error_accessControl() async { - let newKey = "123" - let item = BitwardenKeychainItem.biometrics(userId: "123") - let accessError = KeychainServiceError.accessControlFailed(nil) - keychainService.accessControlResult = .failure(accessError) - keychainService.addResult = .success(()) - await assertAsyncThrows(error: accessError) { - _ = try await subject.setUserAuthKey(for: item, value: newKey) + await assertAsyncThrows(error: error) { + try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1") } } - /// `setUserAuthKey(_:)` failures rethrow. - /// - func test_setUserAuthKey_error_onSet() async { - let newKey = "123" - let item = BitwardenKeychainItem.biometrics(userId: "123") - let addError = KeychainServiceError.osStatusError(-1) - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - item.accessControlFlags ?? [], - nil, - )!, - ) - keychainService.addResult = .failure(addError) - await assertAsyncThrows(error: addError) { - _ = try await subject.setUserAuthKey(for: .biometrics(userId: "123"), value: newKey) - } - } + // MARK: Tests - setUserAuthKey(for:value:) - /// `setUserAuthKey(_:)` succeeds quietly. + /// `setUserAuthKey(for:value:)` stores the value via the facade for the given item. /// - func test_setUserAuthKey_success_biometrics() async throws { - let newKey = "123" + func test_setUserAuthKey_success() async throws { let item = BitwardenKeychainItem.biometrics(userId: "123") - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - item.accessControlFlags ?? [], - nil, - )!, - ) - keychainService.addResult = .success(()) - try await subject.setUserAuthKey(for: item, value: newKey) - XCTAssertEqual(keychainService.accessControlFlags, .biometryCurrentSet) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) - } + try await subject.setUserAuthKey(for: item, value: "123") - /// `setUserAuthKey(_:)` succeeds quietly. - /// - func test_setUserAuthKey_success_neverlock() async throws { - let newKey = "123" - let item = BitwardenKeychainItem.neverLock(userId: "123") - keychainService.accessControlResult = .success( - SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - item.accessControlFlags ?? [], - nil, - )!, - ) - keychainService.addResult = .success(()) - try await subject.setUserAuthKey(for: item, value: newKey) - XCTAssertEqual(keychainService.accessControlFlags, []) - let protection = try XCTUnwrap(keychainService.accessControlProtection as? String) - XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.value, "123") + XCTAssertEqual(keychainServiceFacade.setValueReceivedArguments?.item.unformattedKey, item.unformattedKey) } - /// `setValue(_:for:)` attempts to update before adding when item doesn't exist. + /// `setUserAuthKey(for:value:)` rethrows errors from the facade. /// - func test_setValue_addsNewItem_afterUpdateFails() async throws { - let newKey = "test-value" - let item = BitwardenKeychainItem.biometrics(userId: "123") - let accessControl = SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - item.accessControlFlags ?? [], - nil, - )! - - keychainService.accessControlResult = .success(accessControl) - keychainService.updateResult = .failure(KeychainServiceError.osStatusError(errSecItemNotFound)) - keychainService.addResult = .success(()) - - try await subject.setValue(newKey, for: item) - - // Verify update was attempted first - let updateQuery = try XCTUnwrap(keychainService.updateQuery as? [CFString: Any]) - let expectedFormattedKey = await subject.formattedKey(for: item) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccount] as? String), expectedFormattedKey) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccessGroup] as? String), subject.appSecAttrAccessGroup) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrService] as? String), subject.appSecAttrService) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecClass] as? String), String(kSecClassGenericPassword)) - XCTAssertNil(updateQuery[kSecValueData]) - XCTAssertNil(updateQuery[kSecAttrAccessControl]) - - let updateAttributes = try XCTUnwrap(keychainService.updateAttributes as? [CFString: Any]) - XCTAssertNotNil(updateAttributes[kSecValueData]) - XCTAssertNotNil(updateAttributes[kSecAttrAccessControl]) - let updateValueData = try XCTUnwrap(updateAttributes[kSecValueData] as? Data) - XCTAssertEqual(String(data: updateValueData, encoding: .utf8), newKey) - XCTAssertEqual(updateAttributes.count, 2) - - // Verify add was called after update failed - let addAttributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any]) - let addValueData = try XCTUnwrap(addAttributes[kSecValueData] as? Data) - XCTAssertEqual(String(data: addValueData, encoding: .utf8), newKey) - XCTAssertNotNil(addAttributes[kSecAttrAccessControl]) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecAttrAccount] as? String), expectedFormattedKey) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecAttrAccessGroup] as? String), subject.appSecAttrAccessGroup) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecAttrService] as? String), subject.appSecAttrService) - try XCTAssertEqual(XCTUnwrap(addAttributes[kSecClass] as? String), String(kSecClassGenericPassword)) - } + func test_setUserAuthKey_error() async { + keychainServiceFacade.setValueThrowableError = KeychainServiceError.osStatusError(-1) - /// `setValue(_:for:)` updates an existing item without calling add. - /// - func test_setValue_updatesExistingItem() async throws { - let newKey = "test-value" - let item = BitwardenKeychainItem.biometrics(userId: "123") - let accessControl = SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - item.accessControlFlags ?? [], - nil, - )! - - keychainService.accessControlResult = .success(accessControl) - keychainService.updateResult = .success(()) - - try await subject.setValue(newKey, for: item) - - // Verify update was called with correct query and attributes - let updateQuery = try XCTUnwrap(keychainService.updateQuery as? [CFString: Any]) - let expectedFormattedKey = await subject.formattedKey(for: item) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccount] as? String), expectedFormattedKey) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrAccessGroup] as? String), subject.appSecAttrAccessGroup) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecAttrService] as? String), subject.appSecAttrService) - try XCTAssertEqual(XCTUnwrap(updateQuery[kSecClass] as? String), String(kSecClassGenericPassword)) - XCTAssertNil(updateQuery[kSecValueData]) - XCTAssertNil(updateQuery[kSecAttrAccessControl]) - - let updateAttributes = try XCTUnwrap(keychainService.updateAttributes as? [CFString: Any]) - XCTAssertNotNil(updateAttributes[kSecValueData]) - XCTAssertNotNil(updateAttributes[kSecAttrAccessControl]) - let valueData = try XCTUnwrap(updateAttributes[kSecValueData] as? Data) - XCTAssertEqual(String(data: valueData, encoding: .utf8), newKey) - XCTAssertEqual(updateAttributes.count, 2) - - // Verify add was NOT called - XCTAssertNil(keychainService.addAttributes) + await assertAsyncThrows(error: KeychainServiceError.osStatusError(-1)) { + try await subject.setUserAuthKey(for: .biometrics(userId: "123"), value: "123") + } } } diff --git a/BitwardenShared/Core/Auth/Services/ServerCommunicationConfigKeychainRepository.swift b/BitwardenShared/Core/Auth/Services/ServerCommunicationConfigKeychainRepository.swift index 4a70bc7820..b8e9eeff96 100644 --- a/BitwardenShared/Core/Auth/Services/ServerCommunicationConfigKeychainRepository.swift +++ b/BitwardenShared/Core/Auth/Services/ServerCommunicationConfigKeychainRepository.swift @@ -33,14 +33,16 @@ protocol ServerCommunicationConfigKeychainRepository { // sourcery: AutoMockable extension DefaultKeychainRepository: ServerCommunicationConfigKeychainRepository { func deleteServerCommunicationConfig(hostname: String) async throws { - try await keychainService.delete( - query: keychainQueryValues(for: .serverCommunicationConfig(hostname: hostname)), + try await keychainServiceFacade.deleteValue( + for: BitwardenKeychainItem.serverCommunicationConfig(hostname: hostname), ) } func getServerCommunicationConfig(hostname: String) async throws -> BitwardenSdk.ServerCommunicationConfig? { do { - return try await getValue(for: .serverCommunicationConfig(hostname: hostname)) + return try await keychainServiceFacade.getValue( + for: BitwardenKeychainItem.serverCommunicationConfig(hostname: hostname), + ) } catch KeychainServiceError.osStatusError(errSecItemNotFound), KeychainServiceError.keyNotFound { return nil } @@ -54,6 +56,9 @@ extension DefaultKeychainRepository: ServerCommunicationConfigKeychainRepository try await deleteServerCommunicationConfig(hostname: hostname) return } - try await setValue(config, for: .serverCommunicationConfig(hostname: hostname)) + try await keychainServiceFacade.setValue( + config, + for: BitwardenKeychainItem.serverCommunicationConfig(hostname: hostname), + ) } } diff --git a/BitwardenShared/Core/Auth/Services/TestHelpers/MockKeychainRepository.swift b/BitwardenShared/Core/Auth/Services/TestHelpers/MockKeychainRepository.swift deleted file mode 100644 index cd7c62da14..0000000000 --- a/BitwardenShared/Core/Auth/Services/TestHelpers/MockKeychainRepository.swift +++ /dev/null @@ -1,168 +0,0 @@ -import BitwardenKit -import BitwardenSdk -import Foundation - -@testable import BitwardenShared - -class MockKeychainRepository: KeychainRepository { - var appId: String = "mockAppId" - var mockStorage = [String: String]() - var securityType: SecAccessControlCreateFlags? - - var deleteResult: Result = .success(()) - var getResult: Result? - var setResult: Result = .success(()) - - var deleteAllItemsCalled = false - var deleteAllItemsResult: Result = .success(()) - var deleteItemsForUserIds = [String]() - var deleteItemsForUserResult: Result = .success(()) - - var getAccessTokenResult: Result = .success("ACCESS_TOKEN") - var getAuthenticatorVaultKeyResult: Result = .success("AUTHENTICATOR_VAULT_KEY") - var getDeviceKeyResult: Result = .success("DEVICE_KEY") - var getPendingAdminLoginRequestResult: Result = .success("PENDING_REQUEST") - var getRefreshTokenResult: Result = .success("REFRESH_TOKEN") - var getServerCommunicationConfigResult: Result = .success(nil) - var getServerCommunicationConfigCalledHostname: String? // swiftlint:disable:this identifier_name - - var setAuthenticatorVaultKeyResult: Result = .success(()) - var setAccessTokenResult: Result = .success(()) - var setDeviceKeyResult: Result = .success(()) - var setPendingAdminLoginRequestResult: Result = .success(()) - var setRefreshTokenResult: Result = .success(()) - var setServerCommunicationConfigResult: Result = .success(()) - var setServerCommunicationConfigCalledConfig: BitwardenSdk.ServerCommunicationConfig? - var setServerCommunicationConfigCalledHostname: String? // swiftlint:disable:this identifier_name - - func deleteAllItems() async throws { - deleteAllItemsCalled = true - mockStorage.removeAll() - try deleteAllItemsResult.get() - } - - func deleteAuthenticatorVaultKey(userId: String) async throws { - try deleteResult.get() - let formattedKey = formattedKey(for: .authenticatorVaultKey(userId: userId)) - mockStorage = mockStorage.filter { $0.key != formattedKey } - } - - func deleteItems(for userId: String) async throws { - deleteItemsForUserIds.append(userId) - mockStorage = mockStorage.filter { !$0.key.contains(userId) } - try deleteItemsForUserResult.get() - } - - func deleteDeviceKey(userId: String) async throws { - let formattedKey = formattedKey(for: .deviceKey(userId: userId)) - mockStorage = mockStorage.filter { $0.key != formattedKey } - } - - func deletePendingAdminLoginRequest(userId: String) async throws { - try deleteResult.get() - let formattedKey = formattedKey(for: .pendingAdminLoginRequest(userId: userId)) - mockStorage = mockStorage.filter { $0.key != formattedKey } - } - - func deleteServerCommunicationConfig(hostname: String) async throws { - try deleteResult.get() - let formattedKey = formattedKey(for: .serverCommunicationConfig(hostname: hostname)) - mockStorage = mockStorage.filter { $0.key != formattedKey } - } - - func deleteUserAuthKey(for item: BitwardenKeychainItem) async throws { - try deleteResult.get() - let formattedKey = formattedKey(for: item) - mockStorage = mockStorage.filter { $0.key != formattedKey } - } - - func getAccessToken(userId: String) async throws -> String { - try getAccessTokenResult.get() - } - - func getAuthenticatorVaultKey(userId: String) async throws -> String { - try getValue(for: .authenticatorVaultKey(userId: userId)) - } - - func getDeviceKey(userId: String) async throws -> String? { - try getValue(for: .deviceKey(userId: userId)) - } - - func getRefreshToken(userId: String) async throws -> String { - try getRefreshTokenResult.get() - } - - func getPendingAdminLoginRequest(userId: String) async throws -> String? { - try getPendingAdminLoginRequestResult.get() - } - - func getServerCommunicationConfig(hostname: String) async throws -> BitwardenSdk.ServerCommunicationConfig? { - getServerCommunicationConfigCalledHostname = hostname - return try getServerCommunicationConfigResult.get() - } - - func getUserAuthKeyValue(for item: BitwardenKeychainItem) async throws -> String { - let formattedKey = formattedKey(for: item) - if let result = getResult { - let value = try result.get() - mockStorage[formattedKey] = value - return value - } else if let value = mockStorage[formattedKey] { - return value - } else { - throw KeychainServiceError.keyNotFound(item) - } - } - - func getValue(for item: BitwardenKeychainItem) throws -> String { - let formattedKey = formattedKey(for: item) - guard let value = mockStorage[formattedKey] else { - throw KeychainServiceError.keyNotFound(item) - } - return value - } - - func formattedKey(for item: BitwardenKeychainItem) -> String { - String(format: storageKeyFormat, appId, item.unformattedKey) - } - - func setAccessToken(_ value: String, userId: String) async throws { - try setAccessTokenResult.get() - mockStorage[formattedKey(for: .accessToken(userId: userId))] = value - } - - func setAuthenticatorVaultKey(_ value: String, userId: String) async throws { - try setAuthenticatorVaultKeyResult.get() - mockStorage[formattedKey(for: .authenticatorVaultKey(userId: userId))] = value - } - - func setDeviceKey(_ value: String, userId: String) async throws { - mockStorage[formattedKey(for: .deviceKey(userId: userId))] = value - } - - func setRefreshToken(_ value: String, userId: String) async throws { - try setRefreshTokenResult.get() - mockStorage[formattedKey(for: .refreshToken(userId: userId))] = value - } - - func setPendingAdminLoginRequest(_ value: String, userId: String) async throws { - try setPendingAdminLoginRequestResult.get() - mockStorage[formattedKey(for: .pendingAdminLoginRequest(userId: userId))] = value - } - - func setServerCommunicationConfig( - _ config: BitwardenSdk.ServerCommunicationConfig?, - hostname: String, - ) async throws { - setServerCommunicationConfigCalledConfig = config - setServerCommunicationConfigCalledHostname = hostname - try setServerCommunicationConfigResult.get() - } - - func setUserAuthKey(for item: BitwardenKeychainItem, value: String) async throws { - let formattedKey = formattedKey(for: item) - securityType = item.accessControlFlags - try setResult.get() - mockStorage[formattedKey] = value - } -} diff --git a/BitwardenShared/Core/Auth/Services/TrustDeviceServiceTests.swift b/BitwardenShared/Core/Auth/Services/TrustDeviceServiceTests.swift index 9dc8d01a33..e92f57512e 100644 --- a/BitwardenShared/Core/Auth/Services/TrustDeviceServiceTests.swift +++ b/BitwardenShared/Core/Auth/Services/TrustDeviceServiceTests.swift @@ -62,7 +62,7 @@ class TrustDeviceServiceTests: BitwardenTestCase { /// `getDeviceKey()` get the deviceKey of the trusted device. func test_getDeviceKey() async throws { // Set up the mock data. - try await keychainRepository.setDeviceKey("DEVICE_KEY", userId: stateService.getActiveAccountId()) + keychainRepository.getDeviceKeyReturnValue = "DEVICE_KEY" // Test. let deviceKey = try await subject.getDeviceKey() @@ -88,9 +88,8 @@ class TrustDeviceServiceTests: BitwardenTestCase { let result = try await subject.trustDevice() // Confirm the results. - let storedDeviceKey = try await keychainRepository.getDeviceKey(userId: stateService.getActiveAccountId()) XCTAssertEqual(appIDSettingsStore.appID, "App ID") - XCTAssertEqual(storedDeviceKey, "DEVICE_KEY") + XCTAssertEqual(keychainRepository.setDeviceKeyReceivedArguments?.value, "DEVICE_KEY") XCTAssertEqual(trustDeviceResponse, result) } @@ -114,9 +113,8 @@ class TrustDeviceServiceTests: BitwardenTestCase { let result = try await subject.trustDeviceIfNeeded() // Confirm the results. - let storedDeviceKey = try await keychainRepository.getDeviceKey(userId: userId) XCTAssertEqual(appIDSettingsStore.appID, "App ID") - XCTAssertEqual(storedDeviceKey, "DEVICE_KEY") + XCTAssertEqual(keychainRepository.setDeviceKeyReceivedArguments?.value, "DEVICE_KEY") XCTAssertEqual(trustDeviceResponse, result) } @@ -138,22 +136,20 @@ class TrustDeviceServiceTests: BitwardenTestCase { try await subject.trustDeviceWithExistingKeys(keys: trustDeviceResponse) // Confirm the results. - let storedDeviceKey = try await keychainRepository.getDeviceKey(userId: userId) XCTAssertEqual(appIDSettingsStore.appID, "App ID") - XCTAssertEqual(storedDeviceKey, "DEVICE_KEY") + XCTAssertEqual(keychainRepository.setDeviceKeyReceivedArguments?.value, "DEVICE_KEY") } /// `removeTrustedDevice()` set current device locally as not trusted. func test_removeTrustedDevice() async throws { // Set up the mock data. - try await keychainRepository.setDeviceKey("DEVICE_KEY", userId: stateService.getActiveAccountId()) + keychainRepository.getDeviceKeyReturnValue = "DEVICE_KEY" // Test. try await subject.removeTrustedDevice() // Confirm the results. - let isDeviceTrusted = try await subject.isDeviceTrusted() - XCTAssertFalse(isDeviceTrusted) + XCTAssertTrue(keychainRepository.deleteDeviceKeyCalled) } /// `getShouldTrustDevice(:true)` get if device should be trusted. @@ -209,7 +205,7 @@ class TrustDeviceServiceTests: BitwardenTestCase { /// `isDeviceTrusted(:true)` check if device is trusted. func test_isDeviceTrusted_true() async throws { // Set up the mock data. - try await keychainRepository.setDeviceKey("DEVICE_KEY", userId: stateService.getActiveAccountId()) + keychainRepository.getDeviceKeyReturnValue = "DEVICE_KEY" // Test. let result = try await subject.isDeviceTrusted() diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 5661f2dfc4..9384b17a5c 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -13,6 +13,7 @@ import XCTest final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length var authBridgeItemService: MockAuthenticatorBridgeItemService! var authenticatorClientService: MockClientService! + var authenticatorVaultKeyStorage = [String: String]() var cipherDataStore: MockCipherDataStore! var clientService: MockClientService! var configService: MockConfigService! @@ -36,6 +37,18 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa clientService = MockClientService() errorReporter = MockErrorReporter() keychainRepository = MockKeychainRepository() + keychainRepository.getAuthenticatorVaultKeyClosure = { [weak self] userId in + guard let value = self?.authenticatorVaultKeyStorage[userId] else { + throw KeychainServiceError.keyNotFound(BitwardenKeychainItem.authenticatorVaultKey(userId: userId)) + } + return value + } + keychainRepository.setAuthenticatorVaultKeyClosure = { [weak self] value, userId in + self?.authenticatorVaultKeyStorage[userId] = value + } + keychainRepository.deleteAuthenticatorVaultKeyClosure = { [weak self] userId in + self?.authenticatorVaultKeyStorage.removeValue(forKey: userId) + } organizationService = MockOrganizationService() sharedKeychainRepository = MockSharedKeychainRepository() let throwableError = KeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) @@ -64,6 +77,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa subject = nil authBridgeItemService = nil authenticatorClientService = nil + authenticatorVaultKeyStorage = [:] cipherDataStore = nil configService = nil clientService = nil @@ -120,11 +134,10 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa func test_createAuthenticatorVaultKeyIfNeeded_createsKeyWhenNeeded() async throws { setupInitialState() await subject.start() - try await keychainRepository.deleteAuthenticatorVaultKey(userId: "1") stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } XCTAssertEqual(authenticatorClientService.mockCrypto.getUserEncryptionKeyCalled, false) XCTAssertEqual(clientService.mockCrypto.getUserEncryptionKeyCalled, true) @@ -154,14 +167,14 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa func test_createAuthenticatorVaultKeyIfNeeded_keyAlreadyExists() async throws { setupInitialState() await subject.start() - keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] = - "AUTHENTICATOR_VAULT_KEY" + authenticatorVaultKeyStorage["1"] = "AUTHENTICATOR_VAULT_KEY" stateService.syncToAuthenticatorSubject.send(("1", true)) - waitFor(keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil) - XCTAssertEqual(keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"], - "AUTHENTICATOR_VAULT_KEY") + try await waitForAsync { + self.keychainRepository.getAuthenticatorVaultKeyCalled + } + XCTAssertFalse(keychainRepository.setAuthenticatorVaultKeyCalled) } /// When the user has subscribed to sync and has an unlocked vault, the @@ -172,7 +185,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa func test_createAuthenticatorVaultKeyIfNeeded_keychainError() async throws { setupInitialState() await subject.start() - keychainRepository.setAuthenticatorVaultKeyResult = .failure(BitwardenTestError.example) + keychainRepository.setAuthenticatorVaultKeyThrowableError = BitwardenTestError.example stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { @@ -191,7 +204,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await stateService.addAccount(.fixtureAccountLogin()) stateService.syncToAuthenticatorSubject.send(("1", true)) - XCTAssertNil(keychainRepository.mockStorage["authenticatorVaultKey_1"]) + XCTAssertFalse(keychainRepository.setAuthenticatorVaultKeyCalled) } /// When Ciphers are published. the service filters out ones that have a deletedDate in the past. @@ -412,13 +425,13 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } stateService.syncToAuthenticatorByUserId["1"] = false stateService.syncToAuthenticatorSubject.send(("1", false)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] == nil + self.keychainRepository.deleteAuthenticatorVaultKeyCalled } } @@ -460,7 +473,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa func test_determineSyncForUserId_errorFromKeychain() async throws { setupInitialState() await subject.start() - keychainRepository.setAuthenticatorVaultKeyResult = .failure(BitwardenTestError.example) + keychainRepository.setAuthenticatorVaultKeyThrowableError = BitwardenTestError.example stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { @@ -714,7 +727,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } vaultTimeoutService.isClientLocked["1"] = true @@ -756,7 +769,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } await stateService.addAccount(.fixtureAccountLogin()) @@ -910,7 +923,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } vaultTimeoutService.isClientLocked["1"] = true @@ -943,7 +956,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } vaultTimeoutService.isClientLocked["1"] = true @@ -980,7 +993,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } authenticatorClientService.mockCrypto.initializeUserCryptoResult = .failure(BitwardenTestError.example) @@ -1008,7 +1021,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } authenticatorClientService.mockVault.clientCiphers.decryptResult = { _ in @@ -1040,7 +1053,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } authBridgeItemService.errorToThrow = BitwardenTestError.example @@ -1070,7 +1083,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) try await waitForAsync { - self.keychainRepository.mockStorage["bwKeyChainStorage:mockAppId:authenticatorVaultKey_1"] != nil + self.keychainRepository.setAuthenticatorVaultKeyCalled } cipherDataStore.cipherSubjectByUserId["1"]?.send([ diff --git a/BitwardenShared/Core/Platform/Services/MigrationServiceTests.swift b/BitwardenShared/Core/Platform/Services/MigrationServiceTests.swift index 87fd20fd7e..388703172b 100644 --- a/BitwardenShared/Core/Platform/Services/MigrationServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/MigrationServiceTests.swift @@ -94,7 +94,7 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_ ], activeUserId: "1", ) - keychainRepository.setAccessTokenResult = .failure(KeychainServiceError.osStatusError(-1)) + keychainRepository.setAccessTokenThrowableError = KeychainServiceError.osStatusError(-1) await subject.performMigrations() @@ -129,6 +129,11 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_ appSettingsStore.notificationsLastRegistrationDates[userId] = Date() } + var accessTokensByUserId = [String: String]() + keychainRepository.setAccessTokenClosure = { value, userId in accessTokensByUserId[userId] = value } + var refreshTokensByUserId = [String: String]() + keychainRepository.setRefreshTokenClosure = { value, userId in refreshTokensByUserId[userId] = value } + try await subject.performMigration(version: 1) XCTAssertEqual(appSettingsStore.migrationVersion, 1) @@ -138,10 +143,10 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_ let account2 = try XCTUnwrap(appSettingsStore.state?.accounts["2"]) XCTAssertNil(account2._tokens) - try XCTAssertEqual(keychainRepository.getValue(for: .accessToken(userId: "1")), "ACCESS_TOKEN_1") - try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "1")), "REFRESH_TOKEN_1") - try XCTAssertEqual(keychainRepository.getValue(for: .accessToken(userId: "2")), "ACCESS_TOKEN_2") - try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "2")), "REFRESH_TOKEN_2") + XCTAssertEqual(accessTokensByUserId["1"], "ACCESS_TOKEN_1") + XCTAssertEqual(refreshTokensByUserId["1"], "REFRESH_TOKEN_1") + XCTAssertEqual(accessTokensByUserId["2"], "ACCESS_TOKEN_2") + XCTAssertEqual(refreshTokensByUserId["2"], "REFRESH_TOKEN_2") for userId in ["1", "2"] { XCTAssertNil(appSettingsStore.lastSyncTime(userId: userId)) diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 6f17b2fac8..a0af91913b 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -468,9 +468,18 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let keychainService = DefaultKeychainService() + let keychainRepositoryFacade = DefaultKeychainServiceFacade( + appSecAttrAccessGroup: Bundle.main.keychainAccessGroup, + keychainService: keychainService, + namespacing: .appScoped( + appIDService: appIDService, + appSecAttrService: Bundle.main.appIdentifier, + storageKeyPrefix: "bwKeyChainStorage", + ), + ) let keychainRepository = DefaultKeychainRepository( - appIDService: appIDService, keychainService: keychainService, + keychainServiceFacade: keychainRepositoryFacade, ) let timeProvider = CurrentTime() @@ -660,13 +669,14 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le tokenService: tokenService, ) - let sharedKeychainStorage = DefaultSharedKeychainStorage( + let sharedKeychainServiceFacade = DefaultKeychainServiceFacade( + appSecAttrAccessGroup: Bundle.main.sharedAppGroupIdentifier, keychainService: keychainService, - sharedAppGroupIdentifier: Bundle.main.sharedAppGroupIdentifier, + namespacing: .shared, ) let sharedKeychainRepository = DefaultSharedKeychainRepository( - storage: sharedKeychainStorage, + keychainServiceFacade: sharedKeychainServiceFacade, ) let sharedTimeoutService = DefaultSharedTimeoutService( diff --git a/BitwardenShared/Core/Platform/Services/StateService+ServerCommunicationConfigTests.swift b/BitwardenShared/Core/Platform/Services/StateService+ServerCommunicationConfigTests.swift index 81ec6cd489..f59136fe71 100644 --- a/BitwardenShared/Core/Platform/Services/StateService+ServerCommunicationConfigTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateService+ServerCommunicationConfigTests.swift @@ -67,11 +67,11 @@ class StateServiceServerCommunicationConfigTests: BitwardenTestCase { ), ), ) - keychainRepository.getServerCommunicationConfigResult = .success(config) + keychainRepository.getServerCommunicationConfigReturnValue = config try await subject.clearServerCommunicationCookieValue(hostname: hostname) - let savedConfig = keychainRepository.setServerCommunicationConfigCalledConfig + let savedConfig = keychainRepository.setServerCommunicationConfigReceivedArguments?.config guard case let .ssoCookieVendor(savedSsoConfig) = savedConfig?.bootstrap else { XCTFail("Expected .ssoCookieVendor bootstrap after clearing") return @@ -80,34 +80,32 @@ class StateServiceServerCommunicationConfigTests: BitwardenTestCase { XCTAssertEqual(savedSsoConfig.cookieName, "bwauth") XCTAssertEqual(savedSsoConfig.cookieDomain, "example.com") XCTAssertNil(savedSsoConfig.cookieValue) - XCTAssertEqual(keychainRepository.setServerCommunicationConfigCalledHostname, hostname) + XCTAssertEqual(keychainRepository.setServerCommunicationConfigReceivedArguments?.hostname, hostname) } /// `clearServerCommunicationCookieValue(hostname:)` does nothing when no config exists. func test_clearServerCommunicationCookieValue_noConfig() async throws { - keychainRepository.getServerCommunicationConfigResult = .success(nil) + keychainRepository.getServerCommunicationConfigReturnValue = nil try await subject.clearServerCommunicationCookieValue(hostname: "example.com") - XCTAssertNil(keychainRepository.setServerCommunicationConfigCalledConfig) + XCTAssertNil(keychainRepository.setServerCommunicationConfigReceivedArguments?.config) } /// `clearServerCommunicationCookieValue(hostname:)` does nothing when the stored config /// uses the `.direct` bootstrap (no SSO cookie to clear). func test_clearServerCommunicationCookieValue_directBootstrap() async throws { - keychainRepository.getServerCommunicationConfigResult = .success( - ServerCommunicationConfig(bootstrap: .direct), - ) + keychainRepository.getServerCommunicationConfigReturnValue = ServerCommunicationConfig(bootstrap: .direct) try await subject.clearServerCommunicationCookieValue(hostname: "example.com") - XCTAssertNil(keychainRepository.setServerCommunicationConfigCalledConfig) + XCTAssertNil(keychainRepository.setServerCommunicationConfigReceivedArguments?.config) } /// `getServerCommunicationConfig(hostname:)` returns the stored config from the keychain. func test_getServerCommunicationConfig_success() async throws { let config = ServerCommunicationConfig(bootstrap: .direct) - keychainRepository.getServerCommunicationConfigResult = .success(config) + keychainRepository.getServerCommunicationConfigReturnValue = config let result = try await subject.getServerCommunicationConfig(hostname: "example.com") @@ -116,7 +114,7 @@ class StateServiceServerCommunicationConfigTests: BitwardenTestCase { /// `getServerCommunicationConfig(hostname:)` returns `nil` when the keychain has no entry. func test_getServerCommunicationConfig_notFound() async throws { - keychainRepository.getServerCommunicationConfigResult = .success(nil) + keychainRepository.getServerCommunicationConfigReturnValue = nil let result = try await subject.getServerCommunicationConfig(hostname: "example.com") @@ -130,8 +128,8 @@ class StateServiceServerCommunicationConfigTests: BitwardenTestCase { try await subject.setServerCommunicationConfig(config, hostname: hostname) - XCTAssertEqual(keychainRepository.setServerCommunicationConfigCalledHostname, hostname) - let saved = keychainRepository.setServerCommunicationConfigCalledConfig + XCTAssertEqual(keychainRepository.setServerCommunicationConfigReceivedArguments?.hostname, hostname) + let saved = keychainRepository.setServerCommunicationConfigReceivedArguments?.config guard case .direct = saved?.bootstrap else { XCTFail("Expected .direct bootstrap in saved config") return diff --git a/BitwardenShared/Core/Platform/Services/StateService+UserSessionTests.swift b/BitwardenShared/Core/Platform/Services/StateService+UserSessionTests.swift index 9cacfd89eb..1d3bf01d6c 100644 --- a/BitwardenShared/Core/Platform/Services/StateService+UserSessionTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateService+UserSessionTests.swift @@ -28,6 +28,9 @@ class StateServiceUserSessionTests: BitwardenTestCase { dataStore = DataStore(errorReporter: MockErrorReporter(), storeType: .memory) errorReporter = MockErrorReporter() keychainRepository = MockKeychainRepository() + keychainRepository.getUserAuthKeyValueThrowableError = KeychainServiceError.keyNotFound( + BitwardenKeychainItem.neverLock(userId: "1"), + ) userSessionKeychainRepository = MockUserSessionKeychainRepository() subject = DefaultStateService( @@ -140,7 +143,8 @@ class StateServiceUserSessionTests: BitwardenTestCase { func test_getVaultTimeout_neverLock() async throws { let item = BitwardenKeychainItem.vaultTimeout(userId: "1") userSessionKeychainRepository.getVaultTimeoutThrowableError = KeychainServiceError.keyNotFound(item) - keychainRepository.mockStorage[keychainRepository.formattedKey(for: .neverLock(userId: "1"))] = "NEVER_LOCK_KEY" + keychainRepository.getUserAuthKeyValueThrowableError = nil + keychainRepository.getUserAuthKeyValueReturnValue = "NEVER_LOCK_KEY" await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 255821d84f..2b1751dd8a 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -1381,13 +1381,12 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body func test_isAuthenticated() async throws { await subject.addAccount(.fixture()) - keychainRepository.getAccessTokenResult = .failure( - KeychainServiceError.osStatusError(errSecItemNotFound), - ) + keychainRepository.getAccessTokenThrowableError = KeychainServiceError.osStatusError(errSecItemNotFound) var authenticationState = try await subject.isAuthenticated() XCTAssertFalse(authenticationState) - keychainRepository.getAccessTokenResult = .success("ACCESS_TOKEN") + keychainRepository.getAccessTokenThrowableError = nil + keychainRepository.getAccessTokenReturnValue = "ACCESS_TOKEN" authenticationState = try await subject.isAuthenticated() XCTAssertTrue(authenticationState) } @@ -1396,7 +1395,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body func test_isAuthenticated_keychainError() async throws { await subject.addAccount(.fixture()) let error = KeychainServiceError.osStatusError(errSecParam) - keychainRepository.getAccessTokenResult = .failure(error) + keychainRepository.getAccessTokenThrowableError = error await assertAsyncThrows(error: error) { _ = try await subject.isAuthenticated() diff --git a/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift b/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift index 8fcd0034bf..d6870cfdaf 100644 --- a/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift @@ -20,6 +20,8 @@ class TokenServiceTests: BitwardenTestCase { errorReporter = MockErrorReporter() keychainRepository = MockKeychainRepository() + keychainRepository.getAccessTokenReturnValue = "ACCESS_TOKEN" + keychainRepository.getRefreshTokenReturnValue = "REFRESH_TOKEN" stateService = MockStateService() subject = DefaultTokenService( @@ -47,7 +49,7 @@ class TokenServiceTests: BitwardenTestCase { let accessToken: String = try await subject.getAccessToken() XCTAssertEqual(accessToken, "ACCESS_TOKEN") - keychainRepository.getAccessTokenResult = .success("🔑") + keychainRepository.getAccessTokenReturnValue = "🔑" let updatedAccessToken: String = try await subject.getAccessToken() XCTAssertEqual(updatedAccessToken, "🔑") @@ -90,7 +92,7 @@ class TokenServiceTests: BitwardenTestCase { func test_getIsExternal_false() async throws { // swiftlint:disable:next line_length let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTY5MDg4NzksInN1YiI6IjEzNTEyNDY3LTljZmUtNDNiMC05NjlmLTA3NTM0MDg0NzY0YiIsIm5hbWUiOiJCaXR3YXJkZW4gVXNlciIsImVtYWlsIjoidXNlckBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicHJlbWl1bSI6ZmFsc2UsImFtciI6WyJBcHBsaWNhdGlvbiJdfQ.KDqC8kUaOAgBiUY8eeLa0a4xYWN8GmheXTFXmataFwM" - keychainRepository.getAccessTokenResult = .success(token) + keychainRepository.getAccessTokenReturnValue = token stateService.activeAccount = .fixture() let isExternal = try await subject.getIsExternal() @@ -101,7 +103,7 @@ class TokenServiceTests: BitwardenTestCase { func test_getIsExternal_true() async throws { // swiftlint:disable:next line_length let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTY5MDg4NzksInN1YiI6IjEzNTEyNDY3LTljZmUtNDNiMC05NjlmLTA3NTM0MDg0NzY0YiIsIm5hbWUiOiJCaXR3YXJkZW4gVXNlciIsImVtYWlsIjoidXNlckBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicHJlbWl1bSI6ZmFsc2UsImFtciI6WyJleHRlcm5hbCJdfQ.POnwEWm09reMUfiHKZP-PIW_fvIl-ZzXs9pLZJVYf9A" - keychainRepository.getAccessTokenResult = .success(token) + keychainRepository.getAccessTokenReturnValue = token stateService.activeAccount = .fixture() let isExternal = try await subject.getIsExternal() @@ -117,7 +119,7 @@ class TokenServiceTests: BitwardenTestCase { /// `getIsExternal()` throws an error if fetching the user's access token fails. func test_getIsExternal_tokenError() async throws { - keychainRepository.getAccessTokenResult = .failure(BitwardenTestError.example) + keychainRepository.getAccessTokenThrowableError = BitwardenTestError.example stateService.activeAccount = .fixture() await assertAsyncThrows(error: BitwardenTestError.example) { @@ -127,7 +129,7 @@ class TokenServiceTests: BitwardenTestCase { /// `getIsExternal()` throws an error if fetching the user's access token fails. func test_getIsExternal_tokenParsingError() async throws { - keychainRepository.getAccessTokenResult = .success("token") + keychainRepository.getAccessTokenReturnValue = "token" stateService.activeAccount = .fixture() await assertAsyncThrows(error: TokenParserError.invalidToken) { @@ -142,7 +144,7 @@ class TokenServiceTests: BitwardenTestCase { let refreshToken = try await subject.getRefreshToken() XCTAssertEqual(refreshToken, "REFRESH_TOKEN") - keychainRepository.getRefreshTokenResult = .success("🔒") + keychainRepository.getRefreshTokenReturnValue = "🔒" let updatedRefreshToken = try await subject.getRefreshToken() XCTAssertEqual(updatedRefreshToken, "🔒") @@ -164,14 +166,8 @@ class TokenServiceTests: BitwardenTestCase { let expirationDate = Date(year: 2025, month: 10, day: 1) try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒", expirationDate: expirationDate) - XCTAssertEqual( - keychainRepository.mockStorage[keychainRepository.formattedKey(for: .accessToken(userId: "1"))], - "🔑", - ) - XCTAssertEqual( - keychainRepository.mockStorage[keychainRepository.formattedKey(for: .refreshToken(userId: "1"))], - "🔒", - ) + XCTAssertEqual(keychainRepository.setAccessTokenReceivedArguments?.value, "🔑") + XCTAssertEqual(keychainRepository.setRefreshTokenReceivedArguments?.value, "🔒") XCTAssertEqual(stateService.accessTokenExpirationDateByUserId["1"], expirationDate) } }