From ce280a33f8735088b1a12b10f902ea66ee16b18f Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 12 Sep 2024 14:28:48 -0400 Subject: [PATCH 01/52] [BITAU-171] Rename Package to AuthenticatorBridgeKit --- .../AuthenticatorKeychainService.swift | 0 .../Info.plist | 2 +- .../SharedKeychainRepository.swift | 0 .../Tests/SharedKeychainRepositoryTests.swift | 4 +-- .../MockAuthenticatorKeychainService.swift | 2 +- .../MockSharedKeychainRepository.swift | 2 +- .../AuthenticatorBridgeKitTestCase.swift | 4 +-- .../Tests/TestHelpers/Support/Info.plist | 0 .../Core/Auth/Services/KeychainService.swift | 2 +- ...config => AuthenticatorBridgeKit.xcconfig} | 2 +- project.yml | 36 +++++++++---------- 11 files changed, 27 insertions(+), 27 deletions(-) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/AuthenticatorKeychainService.swift (100%) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/Info.plist (92%) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/SharedKeychainRepository.swift (100%) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/Tests/SharedKeychainRepositoryTests.swift (97%) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/Tests/TestHelpers/MockAuthenticatorKeychainService.swift (96%) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/Tests/TestHelpers/MockSharedKeychainRepository.swift (94%) rename AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift => AuthenticatorBridgeKit/Tests/TestHelpers/Support/AuthenticatorBridgeKitTestCase.swift (98%) rename {AuthenticatorSyncKit => AuthenticatorBridgeKit}/Tests/TestHelpers/Support/Info.plist (100%) rename Configs/{AuthenticatorSyncKit.xcconfig => AuthenticatorBridgeKit.xcconfig} (88%) diff --git a/AuthenticatorSyncKit/AuthenticatorKeychainService.swift b/AuthenticatorBridgeKit/AuthenticatorKeychainService.swift similarity index 100% rename from AuthenticatorSyncKit/AuthenticatorKeychainService.swift rename to AuthenticatorBridgeKit/AuthenticatorKeychainService.swift diff --git a/AuthenticatorSyncKit/Info.plist b/AuthenticatorBridgeKit/Info.plist similarity index 92% rename from AuthenticatorSyncKit/Info.plist rename to AuthenticatorBridgeKit/Info.plist index 621b0bd758..014ab4d0cc 100644 --- a/AuthenticatorSyncKit/Info.plist +++ b/AuthenticatorBridgeKit/Info.plist @@ -7,7 +7,7 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleName - AuthenticatorSyncKit + AuthenticatorBridgeKit CFBundlePackageType FMWK CFBundleShortVersionString diff --git a/AuthenticatorSyncKit/SharedKeychainRepository.swift b/AuthenticatorBridgeKit/SharedKeychainRepository.swift similarity index 100% rename from AuthenticatorSyncKit/SharedKeychainRepository.swift rename to AuthenticatorBridgeKit/SharedKeychainRepository.swift diff --git a/AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift b/AuthenticatorBridgeKit/Tests/SharedKeychainRepositoryTests.swift similarity index 97% rename from AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift rename to AuthenticatorBridgeKit/Tests/SharedKeychainRepositoryTests.swift index fcf497dbde..78bf837483 100644 --- a/AuthenticatorSyncKit/Tests/SharedKeychainRepositoryTests.swift +++ b/AuthenticatorBridgeKit/Tests/SharedKeychainRepositoryTests.swift @@ -2,9 +2,9 @@ import CryptoKit import Foundation import XCTest -@testable import AuthenticatorSyncKit +@testable import AuthenticatorBridgeKit -final class SharedKeychainRepositoryTests: AuthenticatorSyncKitTestCase { +final class SharedKeychainRepositoryTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden" diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift similarity index 96% rename from AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift rename to AuthenticatorBridgeKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift index db905b4e66..1a0db0124e 100644 --- a/AuthenticatorSyncKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockAuthenticatorKeychainService.swift @@ -1,6 +1,6 @@ import Foundation -@testable import AuthenticatorSyncKit +@testable import AuthenticatorBridgeKit class MockAuthenticatorKeychainService { // MARK: Properties diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedKeychainRepository.swift similarity index 94% rename from AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift rename to AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedKeychainRepository.swift index 394ec002a2..689202e571 100644 --- a/AuthenticatorSyncKit/Tests/TestHelpers/MockSharedKeychainRepository.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedKeychainRepository.swift @@ -1,7 +1,7 @@ import CryptoKit import Foundation -@testable import AuthenticatorSyncKit +@testable import AuthenticatorBridgeKit class MockSharedKeychainRepository { var authenticatorKey: Data? diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/Support/AuthenticatorBridgeKitTestCase.swift similarity index 98% rename from AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift rename to AuthenticatorBridgeKit/Tests/TestHelpers/Support/AuthenticatorBridgeKitTestCase.swift index 2df9280578..1b25fbb898 100644 --- a/AuthenticatorSyncKit/Tests/TestHelpers/Support/AuthenticatorSyncKitTestCase.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/Support/AuthenticatorBridgeKitTestCase.swift @@ -1,8 +1,8 @@ import XCTest -/// Base class for any tests in the AuthenticatorSyncKit framework. +/// Base class for any tests in the AuthenticatorBridgeKit framework. /// -open class AuthenticatorSyncKitTestCase: XCTestCase { +open class AuthenticatorBridgeKitTestCase: XCTestCase { /// Asserts that an asynchronous block of code will throw an error. The test will fail if the /// block does not throw an error. /// diff --git a/AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist b/AuthenticatorBridgeKit/Tests/TestHelpers/Support/Info.plist similarity index 100% rename from AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist rename to AuthenticatorBridgeKit/Tests/TestHelpers/Support/Info.plist diff --git a/BitwardenShared/Core/Auth/Services/KeychainService.swift b/BitwardenShared/Core/Auth/Services/KeychainService.swift index 3d62dff68b..3b2a99aa72 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainService.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainService.swift @@ -1,4 +1,4 @@ -import AuthenticatorSyncKit +import AuthenticatorBridgeKit import Foundation // MARK: - KeychainService diff --git a/Configs/AuthenticatorSyncKit.xcconfig b/Configs/AuthenticatorBridgeKit.xcconfig similarity index 88% rename from Configs/AuthenticatorSyncKit.xcconfig rename to Configs/AuthenticatorBridgeKit.xcconfig index 13f6dd5ec5..20ce4458ae 100644 --- a/Configs/AuthenticatorSyncKit.xcconfig +++ b/Configs/AuthenticatorBridgeKit.xcconfig @@ -1,4 +1,4 @@ #include "./Common.xcconfig" #include? "./Local.xcconfig" -PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).authenticator-sync-kit +PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).authenticator-bridge-kit diff --git a/project.yml b/project.yml index ca3b490592..b53692179c 100644 --- a/project.yml +++ b/project.yml @@ -36,15 +36,15 @@ packages: url: https://github.com/nalexn/ViewInspector exactVersion: 0.9.10 schemes: - AuthenticatorSyncKit: + AuthenticatorBridgeKit: build: targets: - AuthenticatorSyncKit: all - AuthenticatorSyncKitTests: [test] + AuthenticatorBridgeKit: all + AuthenticatorBridgeKitTests: [test] test: gatherCoverageData: true targets: - - AuthenticatorSyncKitTests + - AuthenticatorBridgeKitTests Bitwarden: build: targets: @@ -59,14 +59,14 @@ schemes: language: en region: US coverageTargets: - - AuthenticatorSyncKit + - AuthenticatorBridgeKit - Bitwarden - BitwardenActionExtension - BitwardenAutoFillExtension - BitwardenShareExtension - BitwardenShared targets: - - AuthenticatorSyncKitTests + - AuthenticatorBridgeKitTests - BitwardenTests - BitwardenActionExtensionTests - BitwardenAutoFillExtensionTests @@ -136,32 +136,32 @@ schemes: targets: BitwardenWatchWidgetExtension: all targets: - AuthenticatorSyncKit: + AuthenticatorBridgeKit: type: framework platform: iOS configFiles: - Debug: Configs/AuthenticatorSyncKit.xcconfig - Release: Configs/AuthenticatorSyncKit.xcconfig + Debug: Configs/AuthenticatorBridgeKit.xcconfig + Release: Configs/AuthenticatorBridgeKit.xcconfig settings: base: APPLICATION_EXTENSION_API_ONLY: true - INFOPLIST_FILE: AuthenticatorSyncKit/Info.plist + INFOPLIST_FILE: AuthenticatorBridgeKit/Info.plist sources: - - path: AuthenticatorSyncKit + - path: AuthenticatorBridgeKit excludes: - "**/Tests/*" - AuthenticatorSyncKitTests: + AuthenticatorBridgeKitTests: type: bundle.unit-test platform: iOS settings: base: - INFOPLIST_FILE: AuthenticatorSyncKit/Tests/TestHelpers/Support/Info.plist + INFOPLIST_FILE: AuthenticatorBridgeKit/Tests/TestHelpers/Support/Info.plist sources: - - path: AuthenticatorSyncKit + - path: AuthenticatorBridgeKit includes: - "**/Tests/*" dependencies: - - target: AuthenticatorSyncKit + - target: AuthenticatorBridgeKit randomExecutionOrder: true Bitwarden: type: application @@ -192,7 +192,7 @@ targets: - path: swiftgen.yml buildPhase: none dependencies: - - target: AuthenticatorSyncKit + - target: AuthenticatorBridgeKit - target: BitwardenShared - target: BitwardenActionExtension - target: BitwardenAutoFillExtension @@ -245,7 +245,7 @@ targets: - "**/TestHelpers/*" - path: GlobalTestHelpers dependencies: - - target: AuthenticatorSyncKit + - target: AuthenticatorBridgeKit - target: Bitwarden - target: BitwardenShared - package: SnapshotTesting @@ -391,7 +391,7 @@ targets: optional: true - path: BitwardenWatchShared dependencies: - - target: AuthenticatorSyncKit + - target: AuthenticatorBridgeKit - package: BitwardenSdk - package: Networking - package: SwiftUIIntrospect From 74212c63cdd13fa2ccd6bbcbbbe80af5e6a95bcd Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 12 Sep 2024 16:15:15 -0400 Subject: [PATCH 02/52] Added Initial CoreData structure and tests --- .../AuthenticatorBridgeDataStore.swift | 150 +++++++++++++++ .../AuthenticatorBridgeItemData.swift | 180 ++++++++++++++++++ .../AuthenticatorBridgeItemDataModel.swift | 39 ++++ .../AuthenticatorCryptographyService.swift | 143 ++++++++++++++ .../Bitwarden.xcdatamodel/contents | 14 ++ .../NSManagedObjectContext+Extension.swift | 52 +++++ .../AuthenticatorBridgeDataStoreTests.swift | 132 +++++++++++++ .../AuthenticatorBridgeItemDataTests.swift | 93 +++++++++ ...uthenticatorCryptographyServiceTests.swift | 88 +++++++++ ...nticatorBridgeItemDataModel+Fixtures.swift | 34 ++++ 10 files changed, 925 insertions(+) create mode 100644 AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift create mode 100644 AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift create mode 100644 AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift create mode 100644 AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift create mode 100644 AuthenticatorBridgeKit/Bitwarden-Authenticator.xcdatamodeld/Bitwarden.xcdatamodel/contents create mode 100644 AuthenticatorBridgeKit/NSManagedObjectContext+Extension.swift create mode 100644 AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift create mode 100644 AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift create mode 100644 AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift create mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift new file mode 100644 index 0000000000..dfcfdd2b96 --- /dev/null +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -0,0 +1,150 @@ +import CoreData + +// MARK: - AuthenticatorStoreType + +/// A type of data store. +/// +public enum AuthenticatorBridgeStoreType { + /// The data store is stored only in memory and isn't persisted to the device. This is used for + /// unit testing. + case memory + + /// The data store is persisted to the device. + case persisted +} + +// MARK: - AuthenticatorDataStore + +/// A data store that manages persisting data across app launches in Core Data. +/// +public class AuthenticatorBridgeDataStore { + // MARK: Properties + + /// A managed object context which executes on a background queue. + private(set) lazy var backgroundContext: NSManagedObjectContext = { + let context = persistentContainer.newBackgroundContext() + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + return context + }() + + /// The Core Data persistent container. + public let persistentContainer: NSPersistentContainer + + // MARK: Initialization + + /// Initialize a `AuthenticatorBridgeDataStore`. + /// + /// - Parameters: + /// - storeType: The type of store to create. + /// - appGroupIdentifier: The app group identifier for the shared resource. + /// - errorHandler: Callback if an error occurs on load of store. + /// + public init( + storeType: AuthenticatorBridgeStoreType = .persisted, + groupIdentifier: String, + errorHandler: @escaping (Error) -> Void + ) { + #if SWIFT_PACKAGE + let modelURL = Bundle.module.url(forResource: "Bitwarden-Authenticator", withExtension: "momd")! + #else + let modelURL = Bundle(for: type(of: self)).url(forResource: "Bitwarden-Authenticator", withExtension: "momd")! + #endif + + let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)! + persistentContainer = NSPersistentContainer( + name: "Bitwarden-Authenticator", + managedObjectModel: managedObjectModel + ) + let storeDescription: NSPersistentStoreDescription + switch storeType { + case .memory: + storeDescription = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null")) + case .persisted: + let storeURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)! + .appendingPathComponent("Bitwarden-Authenticator.sqlite") + storeDescription = NSPersistentStoreDescription(url: storeURL) + } + persistentContainer.persistentStoreDescriptions = [storeDescription] + + persistentContainer.loadPersistentStores { _, error in + if let error { + errorHandler(error) + } + } + + persistentContainer.viewContext.automaticallyMergesChangesFromParent = true + } + + // MARK: Methods + + /// Removes all items that are owned by the specific userId + /// + /// - Parameter userId: the id of the user for which to delete all items. + /// + public func deleteAllForUserId(_ userId: String) async throws { + try await executeBatchDelete(AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId)) + } + + /// Fetchs all items that are owned by the specific userId + /// + /// - Parameter userId: the id of the user for which to delete all items. + /// + public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) + let result = try persistentContainer.viewContext.fetch(fetchRequest) + + return try result.map { data in + try data.model + } + } + + /// Deletes all existing items for a given user and inserts new items for the list of items provided. + /// + /// - Parameters: + /// - items: The new items to be inserted into the store + /// - userId: The userId of the items to be removed and then replaces with items. + /// + public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], + forUserId userId: String) async throws { + let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + try await executeBatchReplace( + deleteRequest: deleteRequest, + insertRequest: insertRequest + ) + } + + /// Executes a batch delete request and merges the changes into the background and view contexts. + /// + /// - Parameter request: The batch delete request to perform. + /// + private func executeBatchDelete(_ request: NSBatchDeleteRequest) async throws { + try await backgroundContext.perform { + try self.backgroundContext.executeAndMergeChanges( + batchDeleteRequest: request, + additionalContexts: [self.persistentContainer.viewContext] + ) + } + } + + /// Executes a batch delete and batch insert request and merges the changes into the background + /// and view contexts. + /// + /// - Parameters: + /// - deleteRequest: The batch delete request to perform. + /// - insertRequest: The batch insert request to perform. + /// + private func executeBatchReplace( + deleteRequest: NSBatchDeleteRequest, + insertRequest: NSBatchInsertRequest + ) async throws { + try await backgroundContext.perform { + try self.backgroundContext.executeAndMergeChanges( + batchDeleteRequest: deleteRequest, + batchInsertRequest: insertRequest, + additionalContexts: [self.persistentContainer.viewContext] + ) + } + } +} diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift new file mode 100644 index 0000000000..1e0cf06f1c --- /dev/null +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift @@ -0,0 +1,180 @@ +import CoreData +import Foundation + +/// A data model for persisting authenticator items into the shared CoreData store. +/// +public class AuthenticatorBridgeItemData: NSManagedObject { + // MARK: Properties + + /// The item's ID + @NSManaged public var id: String + + /// The decoded object that is stored in modelData. + public var model: AuthenticatorBridgeItemDataModel { + get throws { + try JSONDecoder().decode(AuthenticatorBridgeItemDataModel.self, from: modelData) + } + } + + /// The data model encoded as encrypted JSON data + @NSManaged public var modelData: Data + + /// The ID of the user who owns the item + @NSManaged public var userId: String + + // MARK: Initialization + + /// Initialize an `AuthenticatorBridgeItemData` object for insertion into the managed object context + /// + /// - Parameters: + /// - context: The managed object context to insert the initialized item + /// - userId: The ID of the user who owns the item + /// - authenticatorItem: the `AuthenticatorBridgeItemDataModel` used to create the item + convenience init( + context: NSManagedObjectContext, + userId: String, + authenticatorItem: AuthenticatorBridgeItemDataModel + ) throws { + self.init(context: context) + id = authenticatorItem.id + modelData = try JSONEncoder().encode(authenticatorItem) + self.userId = userId + } + + // MARK: Methods + + /// Updates the `AuthenticatorBridgeItemData` object from an `AuthenticatorBridgeItemDataModel` and user ID + /// + /// - Parameters: + /// - authenticatorItem: The `AuthenticatorBridgeItemDataModel` used to update + /// the `AuthenticatorBridgeItemData` instance + /// - userId: The user ID associated with the item + /// + public func update(with authenticatorItem: AuthenticatorBridgeItemDataModel, userId: String) throws { + id = authenticatorItem.id + modelData = try JSONEncoder().encode(authenticatorItem) + self.userId = userId + } +} + +public extension AuthenticatorBridgeItemData { + /// The name of the entity of the managed object, as defined in the data model. + static var entityName: String { + String(describing: self) + } + + /// A `NSBatchInsertRequest` that inserts objects for the specified user. + /// + /// - Parameters: + /// - objects: The list of objects to insert. + /// - userId: The user associated with the objects to insert. + /// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user. + /// + static func batchInsertRequest( + objects: [AuthenticatorBridgeItemDataModel], + userId: String + ) throws -> NSBatchInsertRequest { + var index = 0 + var errorToThrow: Error? + + let insertRequest = NSBatchInsertRequest( + entityName: AuthenticatorBridgeItemData.entityName + ) { (managedObject: NSManagedObject) -> Bool in + + if let managedObject = (managedObject as? AuthenticatorBridgeItemData) { + guard index < objects.count else { return true } + defer { index += 1 } + + do { + try managedObject.update(with: objects[index], userId: userId) + } catch { + // The error can't be thrown directly in this closure, so capture it, return + // from the closure, and then throw it. + errorToThrow = error + return true + } + } + + return false + } + + if let errorToThrow { + throw errorToThrow + } + + return insertRequest + } + + /// A `NSBatchDeleteRequest` that deletes all objects for the specified user. + /// + /// - Parameter userId: The user associated with the objects to delete. + /// - Returns: A `NSBatchDeleteRequest` that deletes all objects for the user. + /// + static func deleteByUserIdRequest(userId: String) -> NSBatchDeleteRequest { + let fetchRequest = NSFetchRequest( + entityName: AuthenticatorBridgeItemData.entityName + ) + fetchRequest.predicate = AuthenticatorBridgeItemData.userIdPredicate(userId: userId) + return NSBatchDeleteRequest(fetchRequest: fetchRequest) + } + + /// A `NSFetchRequest` that fetches objects for the specified user matching an ID. + /// + /// - Parameters: + /// - id: The Id of the object to fetch. + /// - userId: The user associated with the object to fetch. + /// - Returns: A `NSFetchRequest` that fetches all objects for the user. + /// + static func fetchByIdRequest( + id: String, + userId: String + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest( + entityName: AuthenticatorBridgeItemData.entityName + ) + fetchRequest.predicate = AuthenticatorBridgeItemData.userIdAndIdPredicate( + userId: userId, + id: id + ) + return fetchRequest + } + + /// A `NSFetchRequest` that fetches all `AuthenticatorBridgeItemData` for the specified user. + /// + /// - Parameter userId: The user associated with the objects to fetch. + /// - Returns: A `NSFetchRequest` that fetches all objects for the user. + /// + static func fetchByUserIdRequest(userId: String) -> NSFetchRequest { + let fetchRequest = NSFetchRequest( + entityName: AuthenticatorBridgeItemData.entityName + ) + fetchRequest.predicate = AuthenticatorBridgeItemData.userIdPredicate(userId: userId) + return fetchRequest + } + + /// Create an NSPredicate based on both the userId and id properties. + /// + /// - Parameters: + /// - userId: The userId to match in the predicate + /// - id: The id to match in the predicate + /// - Returns: The NSPredicate for searching/filtering by userId and id + /// + static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate { + NSPredicate( + format: "%K == %@ AND %K == %@", + #keyPath(AuthenticatorBridgeItemData.userId), + userId, + #keyPath(AuthenticatorBridgeItemData.id), + id + ) + } + + /// Create an NSPredicate based on the userId property. + /// + /// - Parameter userId: The userId to match in the predicate + /// - Returns: The NSPredicate for searching/filtering by userId + /// + static func userIdPredicate(userId: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(AuthenticatorBridgeItemData.userId), userId) + } +} diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift new file mode 100644 index 0000000000..38eb095e60 --- /dev/null +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A struct for storing information about items that are shared between the Bitwarden and Authenticator apps. +/// +public struct AuthenticatorBridgeItemDataModel: Codable, Equatable { + // MARK: Properties + + /// Bool indicating if this item is a favorite. + public let favorite: Bool + + /// The unique id of the item. + public let id: String + + /// The name of the item. + public let name: String + + /// The TOTP key used to generate codes. + public let totpKey: String? + + /// The username of the Bitwarden account that owns this iteam. + public let username: String? + + /// Initialize an `AuthenticatorBridgeItemDataModel` with the values provided. + /// + /// - Parameters: + /// - favorite: Bool indicating if this item is a favorite. + /// - id: The unique id of the item. + /// - name: The name of the item. + /// - totpKey: The TOTP key used to generate codes. + /// - username: The username of the Bitwarden account that owns this iteam. + /// + public init(favorite: Bool, id: String, name: String, totpKey: String?, username: String?) { + self.favorite = favorite + self.id = id + self.name = name + self.totpKey = totpKey + self.username = username + } +} diff --git a/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift b/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift new file mode 100644 index 0000000000..21d1628aff --- /dev/null +++ b/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift @@ -0,0 +1,143 @@ +import CryptoKit +import Foundation + +// MARK: - AuthenticatorCryptographyService + +/// A service for handling encrypting/decrypting items to be shared between the main +/// Bitwarden app and the Authenticator app. +/// +public protocol AuthenticatorCryptographyService: AnyObject { + /// Takes an array of `AuthenticatorBridgeItemDataModel` with encrypted data and + /// returns the list with each member decrypted. + /// + /// - Parameter items: The encrypted array of items to be decrypted + /// - Returns: the array of items with their data decrypted + /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator + /// key is not in the shared repository. + /// + func decryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] + + /// Takes an array of `AuthenticatorBridgeItemDataModel` with decrypted data and + /// returns the list with each member encrypted. + /// + /// - Parameter items: The decrypted array of items to be encrypted + /// - Returns: the array of items with their data encrypted + /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator + /// key is not in the shared repository. + /// + func encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] +} + +/// A concreate implementation of the `AuthenticatorCryptographyService` protocol. +/// +public class DefaultAuthenticatorCryptographyService: AuthenticatorCryptographyService { + // MARK: Properties + + /// the `SharedKeyRepository` to obtain the shared Authenticator + /// key to use in encrypting/decrypting + private let sharedKeychainRepository: SharedKeychainRepository + + // MARK: Initialization + + /// Initialize a `DefaultAuthenticatorCryptographyService` + /// + /// - Parameter sharedKeychainRepository: the `SharedKeyRepository` to obtain the shared Authenticator + /// key to use in encrypting/decrypting + /// + public init(sharedKeychainRepository: SharedKeychainRepository) { + self.sharedKeychainRepository = sharedKeychainRepository + } + + // MARK: Methods + + public func decryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + let key = try await sharedKeychainRepository.getAuthenticatorKey() + let symmetricKey = SymmetricKey(data: key) + + return items.map { item in + AuthenticatorBridgeItemDataModel( + favorite: item.favorite, + id: item.id, + name: item.name, + totpKey: try? decrypt(item.totpKey, withKey: symmetricKey), + username: try? decrypt(item.username, withKey: symmetricKey) + ) + } + } + + public func encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + let key = try await sharedKeychainRepository.getAuthenticatorKey() + let symmetricKey = SymmetricKey(data: key) + + return items.map { item in + AuthenticatorBridgeItemDataModel( + favorite: item.favorite, + id: item.id, + name: item.name, + totpKey: encrypt(item.totpKey, withKey: symmetricKey), + username: encrypt(item.username, withKey: symmetricKey) + ) + } + } + + /// Decrypts a string given a key. + /// + /// - Parameters: + /// - string: The string to decrypt. + /// - key: The key to decrypt with. + /// - Returns: A decrypted string, or `nil` if the passed-in string was not encoded in Base64. + /// + private func decrypt(_ string: String?, withKey key: SymmetricKey) throws -> String? { + guard let string, !string.isEmpty, let data = Data(base64Encoded: string) else { + return nil + } + let encryptedSealedBox = try AES.GCM.SealedBox( + combined: data + ) + let decryptedBox = try AES.GCM.open( + encryptedSealedBox, + using: key + ) + return String(data: decryptedBox, encoding: .utf8) + } + + /// Encrypt a string with the given key. + /// + /// - Parameters: + /// - plainText: The string to encrypt + /// - key: The key to use to encrypt the string + /// - Returns: An encrypted string or `nil` if the string was nil + /// + private func encrypt(_ plainText: String?, withKey key: SymmetricKey) -> String? { + guard let plainText else { + return nil + } + + let nonce = randomData(lengthInBytes: 12) + + let plainData = plainText.data(using: .utf8) + let sealedData = try? AES.GCM.seal(plainData!, using: key, nonce: AES.GCM.Nonce(data: nonce)) + return sealedData?.combined?.base64EncodedString() + } + + /// Generate random data of the length specified + /// + /// - Parameter lengthInBytes: the length of the random data to generate + /// - Returns: random `Data` of the length in bytes requested. + /// + private func randomData(lengthInBytes: Int) -> Data { + var data = Data(count: lengthInBytes) + _ = data.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, lengthInBytes, bytes.baseAddress!) + } + return data + } +} diff --git a/AuthenticatorBridgeKit/Bitwarden-Authenticator.xcdatamodeld/Bitwarden.xcdatamodel/contents b/AuthenticatorBridgeKit/Bitwarden-Authenticator.xcdatamodeld/Bitwarden.xcdatamodel/contents new file mode 100644 index 0000000000..fdb0f7f751 --- /dev/null +++ b/AuthenticatorBridgeKit/Bitwarden-Authenticator.xcdatamodeld/Bitwarden.xcdatamodel/contents @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AuthenticatorBridgeKit/NSManagedObjectContext+Extension.swift b/AuthenticatorBridgeKit/NSManagedObjectContext+Extension.swift new file mode 100644 index 0000000000..6f78ad045f --- /dev/null +++ b/AuthenticatorBridgeKit/NSManagedObjectContext+Extension.swift @@ -0,0 +1,52 @@ +import CoreData + +extension NSManagedObjectContext { + /// Executes the batch delete request and/or batch insert request and merges any changes into + /// the current context plus any additional contexts. + /// + /// - Parameters: + /// - batchDeleteRequest: The batch delete request to execute. + /// - batchInsertRequest: The batch insert request to execute. + /// - additionalContexts: Any additional contexts other than the current to merge the changes into. + /// + func executeAndMergeChanges( + batchDeleteRequest: NSBatchDeleteRequest? = nil, + batchInsertRequest: NSBatchInsertRequest? = nil, + additionalContexts: [NSManagedObjectContext] = [] + ) throws { + var changes: [AnyHashable: Any] = [:] + + if let batchDeleteRequest { + batchDeleteRequest.resultType = .resultTypeObjectIDs + if let deleteResult = try execute(batchDeleteRequest) as? NSBatchDeleteResult { + changes[NSDeletedObjectsKey] = deleteResult.result as? [NSManagedObjectID] ?? [] + } + } + + if let batchInsertRequest { + batchInsertRequest.resultType = .objectIDs + if let insertResult = try execute(batchInsertRequest) as? NSBatchInsertResult { + changes[NSInsertedObjectsKey] = insertResult.result as? [NSManagedObjectID] ?? [] + } + } + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self] + additionalContexts) + } + + /// Performs the closure on the context's queue and saves the context if there are any changes. + /// + /// - Parameter closure: The closure to perform. + /// + func performAndSave(closure: @escaping () throws -> Void) async throws { + try await perform { + try closure() + try self.saveIfChanged() + } + } + + /// Saves the context if there are changes. + func saveIfChanged() throws { + guard hasChanges else { return } + try save() + } +} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift new file mode 100644 index 0000000000..2f2ae9815a --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift @@ -0,0 +1,132 @@ +import Foundation +import XCTest + +@testable import AuthenticatorBridgeKit + +final class AuthenticatorBridgeDataStoreTests: XCTestCase { + // MARK: Properties + + let accessGroup = "group.com.example.bitwarden-authenticator" + var subject: AuthenticatorBridgeDataStore! + var error: Error? + + // MARK: Setup & Teardown + + override func setUp() { + let errorHandler: (Error) -> Void = { error in + self.error = error + } + subject = AuthenticatorBridgeDataStore( + storeType: .memory, + groupIdentifier: accessGroup, + errorHandler: errorHandler + ) + } + + override func tearDown() { + error = nil + subject = nil + } + + // MARK: Tests + + + /// Verify that the `deleteAllForUserId` method successfully deletes all of the data for a given userId from the store. + /// Verify that it does NOT delete the data for a different userId + /// + func testDeleteAllForUserId() async throws { + let items = AuthenticatorBridgeItemDataModel.fixtures() + + // First Insert for "userId" + try await subject.replaceAllItems(with: items, forUserId: "userId") + + // Separate Insert for "differentUserId" + try await subject.replaceAllItems(with: AuthenticatorBridgeItemDataModel.fixtures(), forUserId: "differentUserId") + + // Remove the items for "differentUserId" + try await subject.deleteAllForUserId("differentUserId") + try subject.persistentContainer.viewContext.saveIfChanged() + try subject.backgroundContext.saveIfChanged() + + // Verify items are removed for "differentUserId" + let deletedFetchResult = try await subject.fetchAllForUserId("differentUserId") + + XCTAssertNotNil(deletedFetchResult) + XCTAssertEqual(deletedFetchResult.count, 0) + + // Verify items are still present for "userId" + let result = try await subject.fetchAllForUserId("userId") + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, items.count) + } + + /// Verify that the `fetchAllForUserId` method successfully fetches the data for the given user id, and does not + /// include data for a different user id. + /// + func testFetchAllForUserId() async throws { + // Insert items for "userId" + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") + + // Separate Insert for "differentUserId" + let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() + try await subject.replaceAllItems(with: [differentUserItem], forUserId: "differentUserId") + + // Fetch should return only the expectedItem + let result = try await subject.fetchAllForUserId("userId") + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, expectedItems.count) + XCTAssertEqual(result, expectedItems) + + // None of the items for userId should contain the item inserted for differentUserId + let emptyResult = result.filter { $0.id == differentUserItem.id } + XCTAssertEqual(emptyResult.count, 0) + } + + /// Verify that the fetchById request correctly finds the item with the given userId and id. + /// + func testFetchById() async throws { + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() + let expectedItem = expectedItems[3] + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( + objects: expectedItems, + userId: "userId" + ) + try subject.persistentContainer.viewContext.executeAndMergeChanges(batchInsertRequest: insertRequest) + + let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId") + let result = try subject.persistentContainer.viewContext.fetch(fetchRequest) + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, 1) + + let item = try XCTUnwrap(result.first?.model) + XCTAssertEqual(item, expectedItem) + } + + /// Verify that the Batch Delete Request successfully deletes all of the data for a given userId from the store. + /// Verify that it does NOT delete the data for a different userId + /// + func testFetchByUserIdRequest() async throws { + // Insert items for "userId" + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") + + // Separate Insert for "differentUserId" + let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() + try await subject.replaceAllItems(with: [differentUserItem], forUserId: "differentUserId") + + // Verify items returned for "userId" do not contain items from "differentUserId" + let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: "userId") + let result = try subject.persistentContainer.viewContext.fetch(fetchRequest) + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, expectedItems.count) + + /// None of the items for userId should contain the item inserted for differentUserId + let emptyResult = result.filter { $0.id == differentUserItem.id } + XCTAssertEqual(emptyResult.count, 0) + } +} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift new file mode 100644 index 0000000000..ee331ce304 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -0,0 +1,93 @@ +import Foundation +import XCTest + +@testable import AuthenticatorBridgeKit + +final class AuthenticatorBridgeItemDataTests: XCTestCase { + // MARK: Properties + + let accessGroup = "group.com.example.bitwarden-authenticator" + var dataStore: AuthenticatorBridgeDataStore! + var error: Error? + var subject: AuthenticatorBridgeItemData! + + // MARK: Setup & Teardown + + override func setUp() { + let errorHandler: (Error) -> Void = { error in + self.error = error + } + dataStore = AuthenticatorBridgeDataStore( + storeType: .memory, + groupIdentifier: accessGroup, + errorHandler: errorHandler + ) + } + + override func tearDown() { + dataStore = nil + error = nil + subject = nil + } + + // MARK: Tests + /// Verify that creating an `AuthenticatorBridgeItemData` succeeds and returns the expected modelData + /// correctly coded. + /// + func testCreateAndVeryifyData() async throws { + subject = try AuthenticatorBridgeItemData( + context: dataStore.persistentContainer.viewContext, + userId: "userId", + authenticatorItem: AuthenticatorBridgeItemDataModel( + favorite: true, id: "is", name: "name", totpKey: "TOTP Key", username: "username" + ) + ) + + let modelData = try XCTUnwrap(subject.modelData) + let model = try JSONDecoder().decode(AuthenticatorBridgeItemDataModel.self, from: modelData) + + XCTAssertEqual(try? subject.model, model) + } + + /// Verify that the fetchById request correctly finds the item with the given userId and id. + /// + func testFetchById() async throws { + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() + let expectedItem = expectedItems[3] + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( + objects: expectedItems, + userId: "userId" + ) + try dataStore.persistentContainer.viewContext.executeAndMergeChanges(batchInsertRequest: insertRequest) + + let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId") + let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, 1) + + let item = try XCTUnwrap(result.first?.model) + XCTAssertEqual(item, expectedItem) + } + + /// Verify that updating an `AuthenticatorBridgeItemData` successfully updates the model's contents + /// + func testUpdates() async throws { + subject = try AuthenticatorBridgeItemData( + context: dataStore.persistentContainer.viewContext, + userId: "userId", + authenticatorItem: AuthenticatorBridgeItemDataModel( + favorite: true, id: "id", name: "name", totpKey: "TOTP Key", username: "username" + ) + ) + + let model = AuthenticatorBridgeItemDataModel( + favorite: false, id: "newId", name: "newName", totpKey: "new TOTP Key", username: "new username" + ) + + try? subject.update(with: model, userId: "newUserId") + + XCTAssertEqual(try? subject.model, model) + XCTAssertEqual(subject.userId, "newUserId") + } +} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift new file mode 100644 index 0000000000..1336f5c668 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift @@ -0,0 +1,88 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import AuthenticatorBridgeKit + +final class AuthenticatorCryptographyServiceTests: XCTestCase { + // MARK: Properties + + let items: [AuthenticatorBridgeItemDataModel] = AuthenticatorBridgeItemDataModel.fixtures() + var sharedKeychainRepository: MockSharedKeychainRepository! + var subject: AuthenticatorCryptographyService! + + // MARK: Setup & Teardown + + override func setUp() { + sharedKeychainRepository = MockSharedKeychainRepository() + sharedKeychainRepository.authenticatorKey = sharedKeychainRepository.generateKeyData() + subject = DefaultAuthenticatorCryptographyService( + sharedKeychainRepository: sharedKeychainRepository + ) + } + + override func tearDown() { + sharedKeychainRepository = nil + subject = nil + } + + // MARK: Tests + + /// Verify that `AuthenticatorCryptographyService.decryptAuthenticatorItems(:)` correctly + /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. + /// + func testDecrypt() async throws { + let encrytpedItems = try await subject.encryptAuthenticatorItems(items) + let decrytpedItems = try await subject.decryptAuthenticatorItems(encrytpedItems) + + XCTAssertEqual(items, decrytpedItems) + } + + /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems(:)` correctly + /// encrypts an array of `AuthenticatorBridgeItemDataModel`. + /// + func testEncrypt() async throws { + let encrytpedItems = try await subject.encryptAuthenticatorItems(items) + + XCTAssertEqual(items.count, encrytpedItems.count) + + for index in 0 ..< items.count { + let item = try XCTUnwrap(items[index]) + let encrytpedItem = try XCTUnwrap(encrytpedItems[index]) + + // Unencrypted values remain equal + XCTAssertEqual(item.favorite, encrytpedItem.favorite) + XCTAssertEqual(item.id, encrytpedItem.id) + XCTAssertEqual(item.name, encrytpedItem.name) + + // Encrypted values should not remain equal, unless they were `nil` + if item.totpKey != nil { + XCTAssertNotEqual(item.totpKey, encrytpedItem.totpKey) + } else { + XCTAssertNil(encrytpedItem.totpKey) + } + if item.username != nil { + XCTAssertNotEqual(item.username, encrytpedItem.username) + } else { + XCTAssertNil(encrytpedItem.username) + } + } + } + + /// Verify that `AuthenticatorCryptographyService' throws when the `SharedKeyRrepository` + /// authenticator key is missing. + /// + func testEncryptAndDecryptThrowWhenKeyMissing() async throws { + try sharedKeychainRepository.deleteAuthenticatorKey() + + do { + _ = try await subject.encryptAuthenticatorItems(items) + XCTFail("AuthenticatorKeychainServiceError.keyNotFound should have been thrown") + } catch AuthenticatorKeychainServiceError.keyNotFound(_) {} + + do { + _ = try await subject.decryptAuthenticatorItems(items) + XCTFail("AuthenticatorKeychainServiceError.keyNotFound should have been thrown") + } catch AuthenticatorKeychainServiceError.keyNotFound(_) {} + } +} diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift new file mode 100644 index 0000000000..ba23db5aef --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift @@ -0,0 +1,34 @@ +import Foundation + +@testable import AuthenticatorBridgeKit + +extension AuthenticatorBridgeItemDataModel { + static func fixture( + favorite: Bool = true, + id: String = UUID().uuidString, + name: String = "Name", + totpKey: String? = "TOTP Key", + username: String? = "Username" + ) -> AuthenticatorBridgeItemDataModel { + AuthenticatorBridgeItemDataModel( + favorite: favorite, + id: id, + name: name, + totpKey: totpKey, + username: username + ) + } + + static func fixtures() -> [AuthenticatorBridgeItemDataModel] { + [ + AuthenticatorBridgeItemDataModel.fixture(), + AuthenticatorBridgeItemDataModel.fixture(favorite: false), + AuthenticatorBridgeItemDataModel.fixture(totpKey: nil), + AuthenticatorBridgeItemDataModel.fixture(username: nil), + AuthenticatorBridgeItemDataModel.fixture(totpKey: nil, username: nil), + AuthenticatorBridgeItemDataModel.fixture(totpKey: ""), + AuthenticatorBridgeItemDataModel.fixture(username: ""), + AuthenticatorBridgeItemDataModel.fixture(totpKey: "", username: ""), + ] + } +} From 61163d713f4e32cb173193ae96a9003d75b7c781 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 13 Sep 2024 12:57:52 -0400 Subject: [PATCH 03/52] Updated tests to match the style guide, implemented additional tests --- .../AuthenticatorBridgeDataStore.swift | 2 +- .../AuthenticatorCryptographyService.swift | 2 +- .../AuthenticatorBridgeDataStoreTests.swift | 70 ++++++++++--------- .../AuthenticatorBridgeItemDataTests.swift | 70 ++++++++++++++++--- ...uthenticatorCryptographyServiceTests.swift | 65 +++++++++-------- 5 files changed, 136 insertions(+), 73 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index dfcfdd2b96..f3111ffcb9 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -86,7 +86,7 @@ public class AuthenticatorBridgeDataStore { try await executeBatchDelete(AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId)) } - /// Fetchs all items that are owned by the specific userId + /// Fetches all items that are owned by the specific userId /// /// - Parameter userId: the id of the user for which to delete all items. /// diff --git a/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift b/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift index 21d1628aff..c2205a276c 100644 --- a/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift @@ -32,7 +32,7 @@ public protocol AuthenticatorCryptographyService: AnyObject { ) async throws -> [AuthenticatorBridgeItemDataModel] } -/// A concreate implementation of the `AuthenticatorCryptographyService` protocol. +/// A concrete implementation of the `AuthenticatorCryptographyService` protocol. /// public class DefaultAuthenticatorCryptographyService: AuthenticatorCryptographyService { // MARK: Properties diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift index 2f2ae9815a..4b673bbda5 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import AuthenticatorBridgeKit -final class AuthenticatorBridgeDataStoreTests: XCTestCase { +final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" @@ -13,6 +13,7 @@ final class AuthenticatorBridgeDataStoreTests: XCTestCase { // MARK: Setup & Teardown override func setUp() { + super.setUp() let errorHandler: (Error) -> Void = { error in self.error = error } @@ -26,6 +27,7 @@ final class AuthenticatorBridgeDataStoreTests: XCTestCase { override func tearDown() { error = nil subject = nil + super.tearDown() } // MARK: Tests @@ -34,7 +36,7 @@ final class AuthenticatorBridgeDataStoreTests: XCTestCase { /// Verify that the `deleteAllForUserId` method successfully deletes all of the data for a given userId from the store. /// Verify that it does NOT delete the data for a different userId /// - func testDeleteAllForUserId() async throws { + func test_deleteAllForUserId_success() async throws { let items = AuthenticatorBridgeItemDataModel.fixtures() // First Insert for "userId" @@ -64,7 +66,7 @@ final class AuthenticatorBridgeDataStoreTests: XCTestCase { /// Verify that the `fetchAllForUserId` method successfully fetches the data for the given user id, and does not /// include data for a different user id. /// - func testFetchAllForUserId() async throws { + func test_fetchAllForUserId_success() async throws { // Insert items for "userId" let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") @@ -85,48 +87,48 @@ final class AuthenticatorBridgeDataStoreTests: XCTestCase { XCTAssertEqual(emptyResult.count, 0) } - /// Verify that the fetchById request correctly finds the item with the given userId and id. + /// Verify the `replaceAllItems` correctly deletes all of the items in the store previously when given + /// an empty list of items to insert for the given userId. /// - func testFetchById() async throws { - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() - let expectedItem = expectedItems[3] - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( - objects: expectedItems, - userId: "userId" - ) - try subject.persistentContainer.viewContext.executeAndMergeChanges(batchInsertRequest: insertRequest) + func test_replaceAllItems_emptyInsertDeletesExisting() async throws { + // Insert initial items for "userId" + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") - let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId") - let result = try subject.persistentContainer.viewContext.fetch(fetchRequest) + // Replace with empty list, deleting all + try await subject.replaceAllItems(with: [], forUserId: "userId") - XCTAssertNotNil(result) - XCTAssertEqual(result.count, 1) - - let item = try XCTUnwrap(result.first?.model) - XCTAssertEqual(item, expectedItem) + let result = try await subject.fetchAllForUserId("userId") + XCTAssertEqual(result, []) } - /// Verify that the Batch Delete Request successfully deletes all of the data for a given userId from the store. - /// Verify that it does NOT delete the data for a different userId + /// Verify the `replaceAllItems` correctly replaces all of the items in the store previously with the new + /// list of items for the given userId /// - func testFetchByUserIdRequest() async throws { - // Insert items for "userId" + func test_replaceAllItems_replacesExisting() async throws { + // Insert initial items for "userId" + let initialItems = [AuthenticatorBridgeItemDataModel.fixture()] + try await subject.replaceAllItems(with: initialItems, forUserId: "userId") + + // Replace items for "userId" let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") - // Separate Insert for "differentUserId" - let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() - try await subject.replaceAllItems(with: [differentUserItem], forUserId: "differentUserId") + let result = try await subject.fetchAllForUserId("userId") - // Verify items returned for "userId" do not contain items from "differentUserId" - let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: "userId") - let result = try subject.persistentContainer.viewContext.fetch(fetchRequest) + XCTAssertEqual(result, expectedItems) + XCTAssertFalse(result.contains { $0 == initialItems.first }) + } - XCTAssertNotNil(result) - XCTAssertEqual(result.count, expectedItems.count) + /// Verify the `replaceAllItems` correctly inserts items when a userId doesn't contain any items in the store previously. + /// + func test_replaceAllItems_startingFromEmpty() async throws { + // Insert items for "userId" + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") - /// None of the items for userId should contain the item inserted for differentUserId - let emptyResult = result.filter { $0.id == differentUserItem.id } - XCTAssertEqual(emptyResult.count, 0) + let result = try await subject.fetchAllForUserId("userId") + + XCTAssertEqual(result, expectedItems) } } diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index ee331ce304..9a813a3aef 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import AuthenticatorBridgeKit -final class AuthenticatorBridgeItemDataTests: XCTestCase { +final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" @@ -14,6 +14,7 @@ final class AuthenticatorBridgeItemDataTests: XCTestCase { // MARK: Setup & Teardown override func setUp() { + super.setUp() let errorHandler: (Error) -> Void = { error in self.error = error } @@ -28,13 +29,15 @@ final class AuthenticatorBridgeItemDataTests: XCTestCase { dataStore = nil error = nil subject = nil + super.tearDown() } // MARK: Tests + /// Verify that creating an `AuthenticatorBridgeItemData` succeeds and returns the expected modelData /// correctly coded. /// - func testCreateAndVeryifyData() async throws { + func test_init_success() async throws { subject = try AuthenticatorBridgeItemData( context: dataStore.persistentContainer.viewContext, userId: "userId", @@ -49,16 +52,25 @@ final class AuthenticatorBridgeItemDataTests: XCTestCase { XCTAssertEqual(try? subject.model, model) } + /// Verify that the fetchById request correctly returns an empty list when no item matches the given userId and id. + /// + func test_fetchByIdRequest_empty() async throws { + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() + try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + + let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: "bad id", userId: "userId") + let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, 0) + } + /// Verify that the fetchById request correctly finds the item with the given userId and id. /// - func testFetchById() async throws { + func test_fetchByIdRequest_success() async throws { let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() let expectedItem = expectedItems[3] - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( - objects: expectedItems, - userId: "userId" - ) - try dataStore.persistentContainer.viewContext.executeAndMergeChanges(batchInsertRequest: insertRequest) + try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId") let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) @@ -70,9 +82,49 @@ final class AuthenticatorBridgeItemDataTests: XCTestCase { XCTAssertEqual(item, expectedItem) } + /// Verify that the `fetchByUserIdRequest(userId:)` successfully returns an empty list when their are no + /// items for the given userId + /// + func test_fetchByUserIdRequest_empty() async throws { + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + + let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest( + userId: "nonexistent userId" + ) + let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, 0) + } + + /// Verify that the `fetchByUserIdRequest(userId:)` successfully finds all of the data for a given userId from the store. + /// Verify that it does NOT return any data for a different userId + /// + func test_fetchByUserIdRequest_success() async throws { + // Insert items for "userId" + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + + // Separate Insert for "differentUserId" + let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() + try await dataStore.replaceAllItems(with: [differentUserItem], forUserId: "differentUserId") + + // Verify items returned for "userId" do not contain items from "differentUserId" + let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: "userId") + let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) + + XCTAssertNotNil(result) + XCTAssertEqual(result.count, expectedItems.count) + + /// None of the items for userId should contain the item inserted for differentUserId + let emptyResult = result.filter { $0.id == differentUserItem.id } + XCTAssertEqual(emptyResult.count, 0) + } + /// Verify that updating an `AuthenticatorBridgeItemData` successfully updates the model's contents /// - func testUpdates() async throws { + func test_update_success() async throws { subject = try AuthenticatorBridgeItemData( context: dataStore.persistentContainer.viewContext, userId: "userId", diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift index 1336f5c668..2bd613dcbd 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift @@ -4,7 +4,7 @@ import XCTest @testable import AuthenticatorBridgeKit -final class AuthenticatorCryptographyServiceTests: XCTestCase { +final class AuthenticatorCryptographyServiceTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let items: [AuthenticatorBridgeItemDataModel] = AuthenticatorBridgeItemDataModel.fixtures() @@ -14,6 +14,7 @@ final class AuthenticatorCryptographyServiceTests: XCTestCase { // MARK: Setup & Teardown override func setUp() { + super.setUp() sharedKeychainRepository = MockSharedKeychainRepository() sharedKeychainRepository.authenticatorKey = sharedKeychainRepository.generateKeyData() subject = DefaultAuthenticatorCryptographyService( @@ -24,6 +25,7 @@ final class AuthenticatorCryptographyServiceTests: XCTestCase { override func tearDown() { sharedKeychainRepository = nil subject = nil + super.tearDown() } // MARK: Tests @@ -31,58 +33,65 @@ final class AuthenticatorCryptographyServiceTests: XCTestCase { /// Verify that `AuthenticatorCryptographyService.decryptAuthenticatorItems(:)` correctly /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. /// - func testDecrypt() async throws { - let encrytpedItems = try await subject.encryptAuthenticatorItems(items) - let decrytpedItems = try await subject.decryptAuthenticatorItems(encrytpedItems) + func test_decryptAuthenticatorItems_success() async throws { + let encryptedItems = try await subject.encryptAuthenticatorItems(items) + let decryptedItems = try await subject.decryptAuthenticatorItems(encryptedItems) - XCTAssertEqual(items, decrytpedItems) + XCTAssertEqual(items, decryptedItems) + } + + /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems()' throws + /// when the `SharedKeyRepository` authenticator key is missing. + /// + func test_decryptAuthenticatorItems_throwsKeyMissingError() async throws { + let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) + + try sharedKeychainRepository.deleteAuthenticatorKey() + await assertAsyncThrows(error: error) { + _ = try await subject.decryptAuthenticatorItems(items) + } } /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems(:)` correctly /// encrypts an array of `AuthenticatorBridgeItemDataModel`. /// - func testEncrypt() async throws { - let encrytpedItems = try await subject.encryptAuthenticatorItems(items) + func test_encryptAuthenticatorItems_success() async throws { + let encryptedItems = try await subject.encryptAuthenticatorItems(items) - XCTAssertEqual(items.count, encrytpedItems.count) + XCTAssertEqual(items.count, encryptedItems.count) for index in 0 ..< items.count { let item = try XCTUnwrap(items[index]) - let encrytpedItem = try XCTUnwrap(encrytpedItems[index]) + let encryptedItem = try XCTUnwrap(encryptedItems[index]) // Unencrypted values remain equal - XCTAssertEqual(item.favorite, encrytpedItem.favorite) - XCTAssertEqual(item.id, encrytpedItem.id) - XCTAssertEqual(item.name, encrytpedItem.name) + XCTAssertEqual(item.favorite, encryptedItem.favorite) + XCTAssertEqual(item.id, encryptedItem.id) + XCTAssertEqual(item.name, encryptedItem.name) // Encrypted values should not remain equal, unless they were `nil` if item.totpKey != nil { - XCTAssertNotEqual(item.totpKey, encrytpedItem.totpKey) + XCTAssertNotEqual(item.totpKey, encryptedItem.totpKey) } else { - XCTAssertNil(encrytpedItem.totpKey) + XCTAssertNil(encryptedItem.totpKey) } if item.username != nil { - XCTAssertNotEqual(item.username, encrytpedItem.username) + XCTAssertNotEqual(item.username, encryptedItem.username) } else { - XCTAssertNil(encrytpedItem.username) + XCTAssertNil(encryptedItem.username) } } } - /// Verify that `AuthenticatorCryptographyService' throws when the `SharedKeyRrepository` - /// authenticator key is missing. + /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems()' throws + /// when the `SharedKeyRepository` authenticator key is missing. /// - func testEncryptAndDecryptThrowWhenKeyMissing() async throws { - try sharedKeychainRepository.deleteAuthenticatorKey() + func test_encryptAuthenticatorItems_throwsKeyMissingError() async throws { + let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) - do { + try sharedKeychainRepository.deleteAuthenticatorKey() + await assertAsyncThrows(error: error) { _ = try await subject.encryptAuthenticatorItems(items) - XCTFail("AuthenticatorKeychainServiceError.keyNotFound should have been thrown") - } catch AuthenticatorKeychainServiceError.keyNotFound(_) {} - - do { - _ = try await subject.decryptAuthenticatorItems(items) - XCTFail("AuthenticatorKeychainServiceError.keyNotFound should have been thrown") - } catch AuthenticatorKeychainServiceError.keyNotFound(_) {} + } } } From d0ad4c9d899f36bfbce2c20b2958211fa8d7cc30 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 13 Sep 2024 13:06:05 -0400 Subject: [PATCH 04/52] Remove cryptography serivce (for now). Clean up comments and other linting issues --- .../AuthenticatorBridgeDataStore.swift | 4 +- .../AuthenticatorCryptographyService.swift | 143 ------------------ .../AuthenticatorBridgeDataStoreTests.swift | 11 +- .../AuthenticatorBridgeItemDataTests.swift | 6 +- ...uthenticatorCryptographyServiceTests.swift | 97 ------------ 5 files changed, 11 insertions(+), 250 deletions(-) delete mode 100644 AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift delete mode 100644 AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index f3111ffcb9..8f4ad25661 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -77,7 +77,7 @@ public class AuthenticatorBridgeDataStore { } // MARK: Methods - + /// Removes all items that are owned by the specific userId /// /// - Parameter userId: the id of the user for which to delete all items. @@ -90,7 +90,7 @@ public class AuthenticatorBridgeDataStore { /// /// - Parameter userId: the id of the user for which to delete all items. /// - public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) let result = try persistentContainer.viewContext.fetch(fetchRequest) diff --git a/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift b/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift deleted file mode 100644 index c2205a276c..0000000000 --- a/AuthenticatorBridgeKit/AuthenticatorCryptographyService.swift +++ /dev/null @@ -1,143 +0,0 @@ -import CryptoKit -import Foundation - -// MARK: - AuthenticatorCryptographyService - -/// A service for handling encrypting/decrypting items to be shared between the main -/// Bitwarden app and the Authenticator app. -/// -public protocol AuthenticatorCryptographyService: AnyObject { - /// Takes an array of `AuthenticatorBridgeItemDataModel` with encrypted data and - /// returns the list with each member decrypted. - /// - /// - Parameter items: The encrypted array of items to be decrypted - /// - Returns: the array of items with their data decrypted - /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator - /// key is not in the shared repository. - /// - func decryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] - - /// Takes an array of `AuthenticatorBridgeItemDataModel` with decrypted data and - /// returns the list with each member encrypted. - /// - /// - Parameter items: The decrypted array of items to be encrypted - /// - Returns: the array of items with their data encrypted - /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator - /// key is not in the shared repository. - /// - func encryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] -} - -/// A concrete implementation of the `AuthenticatorCryptographyService` protocol. -/// -public class DefaultAuthenticatorCryptographyService: AuthenticatorCryptographyService { - // MARK: Properties - - /// the `SharedKeyRepository` to obtain the shared Authenticator - /// key to use in encrypting/decrypting - private let sharedKeychainRepository: SharedKeychainRepository - - // MARK: Initialization - - /// Initialize a `DefaultAuthenticatorCryptographyService` - /// - /// - Parameter sharedKeychainRepository: the `SharedKeyRepository` to obtain the shared Authenticator - /// key to use in encrypting/decrypting - /// - public init(sharedKeychainRepository: SharedKeychainRepository) { - self.sharedKeychainRepository = sharedKeychainRepository - } - - // MARK: Methods - - public func decryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] { - let key = try await sharedKeychainRepository.getAuthenticatorKey() - let symmetricKey = SymmetricKey(data: key) - - return items.map { item in - AuthenticatorBridgeItemDataModel( - favorite: item.favorite, - id: item.id, - name: item.name, - totpKey: try? decrypt(item.totpKey, withKey: symmetricKey), - username: try? decrypt(item.username, withKey: symmetricKey) - ) - } - } - - public func encryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] { - let key = try await sharedKeychainRepository.getAuthenticatorKey() - let symmetricKey = SymmetricKey(data: key) - - return items.map { item in - AuthenticatorBridgeItemDataModel( - favorite: item.favorite, - id: item.id, - name: item.name, - totpKey: encrypt(item.totpKey, withKey: symmetricKey), - username: encrypt(item.username, withKey: symmetricKey) - ) - } - } - - /// Decrypts a string given a key. - /// - /// - Parameters: - /// - string: The string to decrypt. - /// - key: The key to decrypt with. - /// - Returns: A decrypted string, or `nil` if the passed-in string was not encoded in Base64. - /// - private func decrypt(_ string: String?, withKey key: SymmetricKey) throws -> String? { - guard let string, !string.isEmpty, let data = Data(base64Encoded: string) else { - return nil - } - let encryptedSealedBox = try AES.GCM.SealedBox( - combined: data - ) - let decryptedBox = try AES.GCM.open( - encryptedSealedBox, - using: key - ) - return String(data: decryptedBox, encoding: .utf8) - } - - /// Encrypt a string with the given key. - /// - /// - Parameters: - /// - plainText: The string to encrypt - /// - key: The key to use to encrypt the string - /// - Returns: An encrypted string or `nil` if the string was nil - /// - private func encrypt(_ plainText: String?, withKey key: SymmetricKey) -> String? { - guard let plainText else { - return nil - } - - let nonce = randomData(lengthInBytes: 12) - - let plainData = plainText.data(using: .utf8) - let sealedData = try? AES.GCM.seal(plainData!, using: key, nonce: AES.GCM.Nonce(data: nonce)) - return sealedData?.combined?.base64EncodedString() - } - - /// Generate random data of the length specified - /// - /// - Parameter lengthInBytes: the length of the random data to generate - /// - Returns: random `Data` of the length in bytes requested. - /// - private func randomData(lengthInBytes: Int) -> Data { - var data = Data(count: lengthInBytes) - _ = data.withUnsafeMutableBytes { bytes in - SecRandomCopyBytes(kSecRandomDefault, lengthInBytes, bytes.baseAddress!) - } - return data - } -} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift index 4b673bbda5..8783e9c069 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift @@ -32,9 +32,8 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { // MARK: Tests - - /// Verify that the `deleteAllForUserId` method successfully deletes all of the data for a given userId from the store. - /// Verify that it does NOT delete the data for a different userId + /// Verify that the `deleteAllForUserId` method successfully deletes all of the data for a given + /// userId from the store. Verify that it does NOT delete the data for a different userId /// func test_deleteAllForUserId_success() async throws { let items = AuthenticatorBridgeItemDataModel.fixtures() @@ -43,7 +42,8 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { try await subject.replaceAllItems(with: items, forUserId: "userId") // Separate Insert for "differentUserId" - try await subject.replaceAllItems(with: AuthenticatorBridgeItemDataModel.fixtures(), forUserId: "differentUserId") + try await subject.replaceAllItems(with: AuthenticatorBridgeItemDataModel.fixtures(), + forUserId: "differentUserId") // Remove the items for "differentUserId" try await subject.deleteAllForUserId("differentUserId") @@ -120,7 +120,8 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { XCTAssertFalse(result.contains { $0 == initialItems.first }) } - /// Verify the `replaceAllItems` correctly inserts items when a userId doesn't contain any items in the store previously. + /// Verify the `replaceAllItems` correctly inserts items when a userId doesn't contain any + /// items in the store previously. /// func test_replaceAllItems_startingFromEmpty() async throws { // Insert items for "userId" diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index 9a813a3aef..54b98cd095 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -98,8 +98,8 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { XCTAssertEqual(result.count, 0) } - /// Verify that the `fetchByUserIdRequest(userId:)` successfully finds all of the data for a given userId from the store. - /// Verify that it does NOT return any data for a different userId + /// Verify that the `fetchByUserIdRequest(userId:)` successfully finds all of the data for a given + /// userId from the store. Verify that it does NOT return any data for a different userId /// func test_fetchByUserIdRequest_success() async throws { // Insert items for "userId" @@ -117,7 +117,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { XCTAssertNotNil(result) XCTAssertEqual(result.count, expectedItems.count) - /// None of the items for userId should contain the item inserted for differentUserId + // None of the items for userId should contain the item inserted for differentUserId let emptyResult = result.filter { $0.id == differentUserItem.id } XCTAssertEqual(emptyResult.count, 0) } diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift deleted file mode 100644 index 2bd613dcbd..0000000000 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorCryptographyServiceTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import CryptoKit -import Foundation -import XCTest - -@testable import AuthenticatorBridgeKit - -final class AuthenticatorCryptographyServiceTests: AuthenticatorBridgeKitTestCase { - // MARK: Properties - - let items: [AuthenticatorBridgeItemDataModel] = AuthenticatorBridgeItemDataModel.fixtures() - var sharedKeychainRepository: MockSharedKeychainRepository! - var subject: AuthenticatorCryptographyService! - - // MARK: Setup & Teardown - - override func setUp() { - super.setUp() - sharedKeychainRepository = MockSharedKeychainRepository() - sharedKeychainRepository.authenticatorKey = sharedKeychainRepository.generateKeyData() - subject = DefaultAuthenticatorCryptographyService( - sharedKeychainRepository: sharedKeychainRepository - ) - } - - override func tearDown() { - sharedKeychainRepository = nil - subject = nil - super.tearDown() - } - - // MARK: Tests - - /// Verify that `AuthenticatorCryptographyService.decryptAuthenticatorItems(:)` correctly - /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. - /// - func test_decryptAuthenticatorItems_success() async throws { - let encryptedItems = try await subject.encryptAuthenticatorItems(items) - let decryptedItems = try await subject.decryptAuthenticatorItems(encryptedItems) - - XCTAssertEqual(items, decryptedItems) - } - - /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems()' throws - /// when the `SharedKeyRepository` authenticator key is missing. - /// - func test_decryptAuthenticatorItems_throwsKeyMissingError() async throws { - let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) - - try sharedKeychainRepository.deleteAuthenticatorKey() - await assertAsyncThrows(error: error) { - _ = try await subject.decryptAuthenticatorItems(items) - } - } - - /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems(:)` correctly - /// encrypts an array of `AuthenticatorBridgeItemDataModel`. - /// - func test_encryptAuthenticatorItems_success() async throws { - let encryptedItems = try await subject.encryptAuthenticatorItems(items) - - XCTAssertEqual(items.count, encryptedItems.count) - - for index in 0 ..< items.count { - let item = try XCTUnwrap(items[index]) - let encryptedItem = try XCTUnwrap(encryptedItems[index]) - - // Unencrypted values remain equal - XCTAssertEqual(item.favorite, encryptedItem.favorite) - XCTAssertEqual(item.id, encryptedItem.id) - XCTAssertEqual(item.name, encryptedItem.name) - - // Encrypted values should not remain equal, unless they were `nil` - if item.totpKey != nil { - XCTAssertNotEqual(item.totpKey, encryptedItem.totpKey) - } else { - XCTAssertNil(encryptedItem.totpKey) - } - if item.username != nil { - XCTAssertNotEqual(item.username, encryptedItem.username) - } else { - XCTAssertNil(encryptedItem.username) - } - } - } - - /// Verify that `AuthenticatorCryptographyService.encryptAuthenticatorItems()' throws - /// when the `SharedKeyRepository` authenticator key is missing. - /// - func test_encryptAuthenticatorItems_throwsKeyMissingError() async throws { - let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) - - try sharedKeychainRepository.deleteAuthenticatorKey() - await assertAsyncThrows(error: error) { - _ = try await subject.encryptAuthenticatorItems(items) - } - } -} From cf4e610841b8f4c49214f6ceffaa24d74b524866 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 16 Sep 2024 11:09:04 -0400 Subject: [PATCH 05/52] Moved to using a more direct batvh insert. Cleaned up tests. Cleaned up doc comments --- .../AuthenticatorBridgeDataStore.swift | 30 +++++++++- .../AuthenticatorBridgeItemData.swift | 58 ++++--------------- .../AuthenticatorBridgeDataStoreTests.swift | 27 ++++++--- .../AuthenticatorBridgeItemDataTests.swift | 21 ------- 4 files changed, 58 insertions(+), 78 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index 8f4ad25661..81a7cfc7e9 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -88,7 +88,7 @@ public class AuthenticatorBridgeDataStore { /// Fetches all items that are owned by the specific userId /// - /// - Parameter userId: the id of the user for which to delete all items. + /// - Parameter userId: the id of the user for which to fetch items. /// public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) @@ -99,6 +99,19 @@ public class AuthenticatorBridgeDataStore { } } + /// Inserts the list of items into the store for the given userId. + /// + /// - Parameters: + /// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store. + /// - userId: the id of the user for which to insert the items. + /// + public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], + forUserId userId: String) async throws { + try await executeBatchInsert( + AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + ) + } + /// Deletes all existing items for a given user and inserts new items for the list of items provided. /// /// - Parameters: @@ -108,7 +121,7 @@ public class AuthenticatorBridgeDataStore { public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) try await executeBatchReplace( deleteRequest: deleteRequest, insertRequest: insertRequest @@ -128,6 +141,19 @@ public class AuthenticatorBridgeDataStore { } } + /// Executes a batch insert request and merges the changes into the background and view contexts. + /// + /// - Parameter request: The batch insert request to perform. + /// + private func executeBatchInsert(_ request: NSBatchInsertRequest) async throws { + try await backgroundContext.perform { + try self.backgroundContext.executeAndMergeChanges( + batchInsertRequest: request, + additionalContexts: [self.persistentContainer.viewContext] + ) + } + } + /// Executes a batch delete and batch insert request and merges the changes into the background /// and view contexts. /// diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift index 1e0cf06f1c..9bcbbcebf6 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift @@ -40,21 +40,6 @@ public class AuthenticatorBridgeItemData: NSManagedObject { modelData = try JSONEncoder().encode(authenticatorItem) self.userId = userId } - - // MARK: Methods - - /// Updates the `AuthenticatorBridgeItemData` object from an `AuthenticatorBridgeItemDataModel` and user ID - /// - /// - Parameters: - /// - authenticatorItem: The `AuthenticatorBridgeItemDataModel` used to update - /// the `AuthenticatorBridgeItemData` instance - /// - userId: The user ID associated with the item - /// - public func update(with authenticatorItem: AuthenticatorBridgeItemDataModel, userId: String) throws { - id = authenticatorItem.id - modelData = try JSONEncoder().encode(authenticatorItem) - self.userId = userId - } } public extension AuthenticatorBridgeItemData { @@ -71,38 +56,19 @@ public extension AuthenticatorBridgeItemData { /// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user. /// static func batchInsertRequest( - objects: [AuthenticatorBridgeItemDataModel], + models: [AuthenticatorBridgeItemDataModel], userId: String ) throws -> NSBatchInsertRequest { - var index = 0 - var errorToThrow: Error? - - let insertRequest = NSBatchInsertRequest( - entityName: AuthenticatorBridgeItemData.entityName - ) { (managedObject: NSManagedObject) -> Bool in - - if let managedObject = (managedObject as? AuthenticatorBridgeItemData) { - guard index < objects.count else { return true } - defer { index += 1 } - - do { - try managedObject.update(with: objects[index], userId: userId) - } catch { - // The error can't be thrown directly in this closure, so capture it, return - // from the closure, and then throw it. - errorToThrow = error - return true - } + try NSBatchInsertRequest( + entityName: AuthenticatorBridgeItemData.entityName, + objects: models.map { model in + try [ + "id": model.id, + "modelData": JSONEncoder().encode(model), + "userId": userId, + ] } - - return false - } - - if let errorToThrow { - throw errorToThrow - } - - return insertRequest + ) } /// A `NSBatchDeleteRequest` that deletes all objects for the specified user. @@ -118,12 +84,12 @@ public extension AuthenticatorBridgeItemData { return NSBatchDeleteRequest(fetchRequest: fetchRequest) } - /// A `NSFetchRequest` that fetches objects for the specified user matching an ID. + /// A `NSFetchRequest` that fetches a specific item owned by the specified user matching the provided Id. /// /// - Parameters: /// - id: The Id of the object to fetch. /// - userId: The user associated with the object to fetch. - /// - Returns: A `NSFetchRequest` that fetches all objects for the user. + /// - Returns: A `NSFetchRequest` that fetches the object owned by the user with the given id. /// static func fetchByIdRequest( id: String, diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift index 8783e9c069..756ae67b49 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift @@ -39,16 +39,14 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { let items = AuthenticatorBridgeItemDataModel.fixtures() // First Insert for "userId" - try await subject.replaceAllItems(with: items, forUserId: "userId") + try await subject.insertItems(items, forUserId: "userId") // Separate Insert for "differentUserId" - try await subject.replaceAllItems(with: AuthenticatorBridgeItemDataModel.fixtures(), - forUserId: "differentUserId") + try await subject.insertItems(AuthenticatorBridgeItemDataModel.fixtures(), + forUserId: "differentUserId") // Remove the items for "differentUserId" try await subject.deleteAllForUserId("differentUserId") - try subject.persistentContainer.viewContext.saveIfChanged() - try subject.backgroundContext.saveIfChanged() // Verify items are removed for "differentUserId" let deletedFetchResult = try await subject.fetchAllForUserId("differentUserId") @@ -69,11 +67,11 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { func test_fetchAllForUserId_success() async throws { // Insert items for "userId" let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") + try await subject.insertItems(expectedItems, forUserId: "userId") // Separate Insert for "differentUserId" let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() - try await subject.replaceAllItems(with: [differentUserItem], forUserId: "differentUserId") + try await subject.insertItems([differentUserItem], forUserId: "differentUserId") // Fetch should return only the expectedItem let result = try await subject.fetchAllForUserId("userId") @@ -87,13 +85,24 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { XCTAssertEqual(emptyResult.count, 0) } + /// Verify that the `insertItems(_:forUserId:)` method successfully inserts the list of items + /// for the given user id. + /// + func test_insertItemsForUserId_success() async throws { + let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + try await subject.insertItems(expectedItems, forUserId: "userId") + let result = try await subject.fetchAllForUserId("userId") + + XCTAssertEqual(result, expectedItems) + } + /// Verify the `replaceAllItems` correctly deletes all of the items in the store previously when given /// an empty list of items to insert for the given userId. /// func test_replaceAllItems_emptyInsertDeletesExisting() async throws { // Insert initial items for "userId" let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") + try await subject.insertItems(expectedItems, forUserId: "userId") // Replace with empty list, deleting all try await subject.replaceAllItems(with: [], forUserId: "userId") @@ -108,7 +117,7 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { func test_replaceAllItems_replacesExisting() async throws { // Insert initial items for "userId" let initialItems = [AuthenticatorBridgeItemDataModel.fixture()] - try await subject.replaceAllItems(with: initialItems, forUserId: "userId") + try await subject.insertItems(initialItems, forUserId: "userId") // Replace items for "userId" let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index 54b98cd095..f2bb6d94a4 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -121,25 +121,4 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { let emptyResult = result.filter { $0.id == differentUserItem.id } XCTAssertEqual(emptyResult.count, 0) } - - /// Verify that updating an `AuthenticatorBridgeItemData` successfully updates the model's contents - /// - func test_update_success() async throws { - subject = try AuthenticatorBridgeItemData( - context: dataStore.persistentContainer.viewContext, - userId: "userId", - authenticatorItem: AuthenticatorBridgeItemDataModel( - favorite: true, id: "id", name: "name", totpKey: "TOTP Key", username: "username" - ) - ) - - let model = AuthenticatorBridgeItemDataModel( - favorite: false, id: "newId", name: "newName", totpKey: "new TOTP Key", username: "new username" - ) - - try? subject.update(with: model, userId: "newUserId") - - XCTAssertEqual(try? subject.model, model) - XCTAssertEqual(subject.userId, "newUserId") - } } From 4a153664b118006b16ef1313d49042745bfa6779 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 16 Sep 2024 14:13:14 -0400 Subject: [PATCH 06/52] ]BITAU-122] [BITAU-133] Add encryption to/from shared the CoreData store --- .../AuthenticatorBridgeDataStore.swift | 22 ++- .../SharedCryptographyService.swift | 143 ++++++++++++++++++ .../AuthenticatorBridgeDataStoreTests.swift | 4 + .../AuthenticatorBridgeItemDataTests.swift | 4 + .../SharedCryptographyServiceTests.swift | 97 ++++++++++++ .../MockSharedCryptographyService.swift | 18 +++ 6 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 AuthenticatorBridgeKit/SharedCryptographyService.swift create mode 100644 AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift create mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index 81a7cfc7e9..bd9cfbfc24 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -27,6 +27,9 @@ public class AuthenticatorBridgeDataStore { return context }() + /// The cryptography service to use in en/decrypting the items stored. + private let cryptoService: SharedCryptographyService + /// The Core Data persistent container. public let persistentContainer: NSPersistentContainer @@ -36,14 +39,18 @@ public class AuthenticatorBridgeDataStore { /// /// - Parameters: /// - storeType: The type of store to create. - /// - appGroupIdentifier: The app group identifier for the shared resource. + /// - groupIdentifier: The app group identifier for the shared resource. + /// - cryptoService: The cryptography service to use in en/decrypting the items stored. /// - errorHandler: Callback if an error occurs on load of store. /// public init( storeType: AuthenticatorBridgeStoreType = .persisted, groupIdentifier: String, + cryptoService: SharedCryptographyService, errorHandler: @escaping (Error) -> Void ) { + self.cryptoService = cryptoService + #if SWIFT_PACKAGE let modelURL = Bundle.module.url(forResource: "Bitwarden-Authenticator", withExtension: "momd")! #else @@ -93,10 +100,10 @@ public class AuthenticatorBridgeDataStore { public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) let result = try persistentContainer.viewContext.fetch(fetchRequest) - - return try result.map { data in + let encryptedItems = try result.map { data in try data.model } + return try await cryptoService.decryptAuthenticatorItems(encryptedItems) } /// Inserts the list of items into the store for the given userId. @@ -107,8 +114,9 @@ public class AuthenticatorBridgeDataStore { /// public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) try await executeBatchInsert( - AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + AuthenticatorBridgeItemData.batchInsertRequest(models: encryptedItems, userId: userId) ) } @@ -120,8 +128,12 @@ public class AuthenticatorBridgeDataStore { /// public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( + models: encryptedItems, + userId: userId + ) try await executeBatchReplace( deleteRequest: deleteRequest, insertRequest: insertRequest diff --git a/AuthenticatorBridgeKit/SharedCryptographyService.swift b/AuthenticatorBridgeKit/SharedCryptographyService.swift new file mode 100644 index 0000000000..a4e641f3d9 --- /dev/null +++ b/AuthenticatorBridgeKit/SharedCryptographyService.swift @@ -0,0 +1,143 @@ +import CryptoKit +import Foundation + +// MARK: - SharedCryptographyService + +/// A service for handling encrypting/decrypting items to be shared between the main +/// Bitwarden app and the Authenticator app. +/// +public protocol SharedCryptographyService: AnyObject { + /// Takes an array of `AuthenticatorBridgeItemDataModel` with encrypted data and + /// returns the list with each member decrypted. + /// + /// - Parameter items: The encrypted array of items to be decrypted + /// - Returns: the array of items with their data decrypted + /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator + /// key is not in the shared repository. + /// + func decryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] + + /// Takes an array of `AuthenticatorBridgeItemDataModel` with decrypted data and + /// returns the list with each member encrypted. + /// + /// - Parameter items: The decrypted array of items to be encrypted + /// - Returns: the array of items with their data encrypted + /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator + /// key is not in the shared repository. + /// + func encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] +} + +/// A concrete implementation of the `SharedCryptographyService` protocol. +/// +public class DefaultAuthenticatorCryptographyService: SharedCryptographyService { + // MARK: Properties + + /// the `SharedKeyRepository` to obtain the shared Authenticator + /// key to use in encrypting/decrypting + private let sharedKeychainRepository: SharedKeychainRepository + + // MARK: Initialization + + /// Initialize a `DefaultAuthenticatorCryptographyService` + /// + /// - Parameter sharedKeychainRepository: the `SharedKeyRepository` to obtain the shared Authenticator + /// key to use in encrypting/decrypting + /// + public init(sharedKeychainRepository: SharedKeychainRepository) { + self.sharedKeychainRepository = sharedKeychainRepository + } + + // MARK: Methods + + public func decryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + let key = try await sharedKeychainRepository.getAuthenticatorKey() + let symmetricKey = SymmetricKey(data: key) + + return items.map { item in + AuthenticatorBridgeItemDataModel( + favorite: item.favorite, + id: item.id, + name: item.name, + totpKey: try? decrypt(item.totpKey, withKey: symmetricKey), + username: try? decrypt(item.username, withKey: symmetricKey) + ) + } + } + + public func encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + let key = try await sharedKeychainRepository.getAuthenticatorKey() + let symmetricKey = SymmetricKey(data: key) + + return items.map { item in + AuthenticatorBridgeItemDataModel( + favorite: item.favorite, + id: item.id, + name: item.name, + totpKey: encrypt(item.totpKey, withKey: symmetricKey), + username: encrypt(item.username, withKey: symmetricKey) + ) + } + } + + /// Decrypts a string given a key. + /// + /// - Parameters: + /// - string: The string to decrypt. + /// - key: The key to decrypt with. + /// - Returns: A decrypted string, or `nil` if the passed-in string was not encoded in Base64. + /// + private func decrypt(_ string: String?, withKey key: SymmetricKey) throws -> String? { + guard let string, !string.isEmpty, let data = Data(base64Encoded: string) else { + return nil + } + let encryptedSealedBox = try AES.GCM.SealedBox( + combined: data + ) + let decryptedBox = try AES.GCM.open( + encryptedSealedBox, + using: key + ) + return String(data: decryptedBox, encoding: .utf8) + } + + /// Encrypt a string with the given key. + /// + /// - Parameters: + /// - plainText: The string to encrypt + /// - key: The key to use to encrypt the string + /// - Returns: An encrypted string or `nil` if the string was nil + /// + private func encrypt(_ plainText: String?, withKey key: SymmetricKey) -> String? { + guard let plainText else { + return nil + } + + let nonce = randomData(lengthInBytes: 12) + + let plainData = plainText.data(using: .utf8) + let sealedData = try? AES.GCM.seal(plainData!, using: key, nonce: AES.GCM.Nonce(data: nonce)) + return sealedData?.combined?.base64EncodedString() + } + + /// Generate random data of the length specified + /// + /// - Parameter lengthInBytes: the length of the random data to generate + /// - Returns: random `Data` of the length in bytes requested. + /// + private func randomData(lengthInBytes: Int) -> Data { + var data = Data(count: lengthInBytes) + _ = data.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, lengthInBytes, bytes.baseAddress!) + } + return data + } +} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift index 756ae67b49..e3d233b1ef 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift @@ -7,6 +7,7 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" + var cryptoService: MockSharedCryptographyService! var subject: AuthenticatorBridgeDataStore! var error: Error? @@ -14,17 +15,20 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { override func setUp() { super.setUp() + let cryptoService = MockSharedCryptographyService() let errorHandler: (Error) -> Void = { error in self.error = error } subject = AuthenticatorBridgeDataStore( storeType: .memory, groupIdentifier: accessGroup, + cryptoService: cryptoService, errorHandler: errorHandler ) } override func tearDown() { + cryptoService = nil error = nil subject = nil super.tearDown() diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index f2bb6d94a4..c67a9e1dc9 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -7,6 +7,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" + var cryptoService: MockSharedCryptographyService! var dataStore: AuthenticatorBridgeDataStore! var error: Error? var subject: AuthenticatorBridgeItemData! @@ -15,17 +16,20 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { override func setUp() { super.setUp() + let cryptoService = MockSharedCryptographyService() let errorHandler: (Error) -> Void = { error in self.error = error } dataStore = AuthenticatorBridgeDataStore( storeType: .memory, groupIdentifier: accessGroup, + cryptoService: cryptoService, errorHandler: errorHandler ) } override func tearDown() { + cryptoService = nil dataStore = nil error = nil subject = nil diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift new file mode 100644 index 0000000000..4e27ab95b5 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift @@ -0,0 +1,97 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import AuthenticatorBridgeKit + +final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { + // MARK: Properties + + let items: [AuthenticatorBridgeItemDataModel] = AuthenticatorBridgeItemDataModel.fixtures() + var sharedKeychainRepository: MockSharedKeychainRepository! + var subject: SharedCryptographyService! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + sharedKeychainRepository = MockSharedKeychainRepository() + sharedKeychainRepository.authenticatorKey = sharedKeychainRepository.generateKeyData() + subject = DefaultAuthenticatorCryptographyService( + sharedKeychainRepository: sharedKeychainRepository + ) + } + + override func tearDown() { + sharedKeychainRepository = nil + subject = nil + super.tearDown() + } + + // MARK: Tests + + /// Verify that `SharedCryptographyService.decryptAuthenticatorItems(:)` correctly + /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. + /// + func test_decryptAuthenticatorItems_success() async throws { + let encryptedItems = try await subject.encryptAuthenticatorItems(items) + let decryptedItems = try await subject.decryptAuthenticatorItems(encryptedItems) + + XCTAssertEqual(items, decryptedItems) + } + + /// Verify that `SharedCryptographyService.encryptAuthenticatorItems()' throws + /// when the `SharedKeyRepository` authenticator key is missing. + /// + func test_decryptAuthenticatorItems_throwsKeyMissingError() async throws { + let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) + + try sharedKeychainRepository.deleteAuthenticatorKey() + await assertAsyncThrows(error: error) { + _ = try await subject.decryptAuthenticatorItems(items) + } + } + + /// Verify that `SharedCryptographyService.encryptAuthenticatorItems(:)` correctly + /// encrypts an array of `AuthenticatorBridgeItemDataModel`. + /// + func test_encryptAuthenticatorItems_success() async throws { + let encryptedItems = try await subject.encryptAuthenticatorItems(items) + + XCTAssertEqual(items.count, encryptedItems.count) + + for index in 0 ..< items.count { + let item = try XCTUnwrap(items[index]) + let encryptedItem = try XCTUnwrap(encryptedItems[index]) + + // Unencrypted values remain equal + XCTAssertEqual(item.favorite, encryptedItem.favorite) + XCTAssertEqual(item.id, encryptedItem.id) + XCTAssertEqual(item.name, encryptedItem.name) + + // Encrypted values should not remain equal, unless they were `nil` + if item.totpKey != nil { + XCTAssertNotEqual(item.totpKey, encryptedItem.totpKey) + } else { + XCTAssertNil(encryptedItem.totpKey) + } + if item.username != nil { + XCTAssertNotEqual(item.username, encryptedItem.username) + } else { + XCTAssertNil(encryptedItem.username) + } + } + } + + /// Verify that `SharedCryptographyService.encryptAuthenticatorItems()' throws + /// when the `SharedKeyRepository` authenticator key is missing. + /// + func test_encryptAuthenticatorItems_throwsKeyMissingError() async throws { + let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) + + try sharedKeychainRepository.deleteAuthenticatorKey() + await assertAsyncThrows(error: error) { + _ = try await subject.encryptAuthenticatorItems(items) + } + } +} diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift new file mode 100644 index 0000000000..f0367e071b --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -0,0 +1,18 @@ +import CryptoKit +import Foundation + +@testable import AuthenticatorBridgeKit + +class MockSharedCryptographyService: SharedCryptographyService { + func decryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + items + } + + func encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + items + } +} From 1dc07570a43059e0a540b0b8e450ba49b55ed344 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 16 Sep 2024 14:26:40 -0400 Subject: [PATCH 07/52] Added name to list of encrypted pieces of the data model --- AuthenticatorBridgeKit/SharedCryptographyService.swift | 4 ++-- .../Tests/SharedCryptographyServiceTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AuthenticatorBridgeKit/SharedCryptographyService.swift b/AuthenticatorBridgeKit/SharedCryptographyService.swift index a4e641f3d9..7f1aff293f 100644 --- a/AuthenticatorBridgeKit/SharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/SharedCryptographyService.swift @@ -64,7 +64,7 @@ public class DefaultAuthenticatorCryptographyService: SharedCryptographyService AuthenticatorBridgeItemDataModel( favorite: item.favorite, id: item.id, - name: item.name, + name: (try? decrypt(item.name, withKey: symmetricKey)) ?? "", totpKey: try? decrypt(item.totpKey, withKey: symmetricKey), username: try? decrypt(item.username, withKey: symmetricKey) ) @@ -81,7 +81,7 @@ public class DefaultAuthenticatorCryptographyService: SharedCryptographyService AuthenticatorBridgeItemDataModel( favorite: item.favorite, id: item.id, - name: item.name, + name: encrypt(item.name, withKey: symmetricKey) ?? "", totpKey: encrypt(item.totpKey, withKey: symmetricKey), username: encrypt(item.username, withKey: symmetricKey) ) diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift index 4e27ab95b5..c8ce092cf9 100644 --- a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift @@ -67,9 +67,9 @@ final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { // Unencrypted values remain equal XCTAssertEqual(item.favorite, encryptedItem.favorite) XCTAssertEqual(item.id, encryptedItem.id) - XCTAssertEqual(item.name, encryptedItem.name) // Encrypted values should not remain equal, unless they were `nil` + XCTAssertNotEqual(item.name, encryptedItem.name) if item.totpKey != nil { XCTAssertNotEqual(item.totpKey, encryptedItem.totpKey) } else { From f78ab0a5d316ab36763f0df64cf84a78fd86c4a9 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 17 Sep 2024 09:45:09 -0400 Subject: [PATCH 08/52] Address comments from PR review --- .../AuthenticatorBridgeDataStore.swift | 12 ++++++++---- ...AuthenticatorBridgeItemDataModel+Fixtures.swift | 14 +++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index 81a7cfc7e9..69c3e6538b 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -27,6 +27,9 @@ public class AuthenticatorBridgeDataStore { return context }() + /// The CoreData model name. + private let modelName = "Bitwarden-Authenticator" + /// The Core Data persistent container. public let persistentContainer: NSPersistentContainer @@ -45,14 +48,15 @@ public class AuthenticatorBridgeDataStore { errorHandler: @escaping (Error) -> Void ) { #if SWIFT_PACKAGE - let modelURL = Bundle.module.url(forResource: "Bitwarden-Authenticator", withExtension: "momd")! + let bundle = Bundle.module #else - let modelURL = Bundle(for: type(of: self)).url(forResource: "Bitwarden-Authenticator", withExtension: "momd")! + let bundle = Bundle(for: type(of: self)) #endif + let modelURL = bundle.url(forResource: modelName, withExtension: "momd")! let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)! persistentContainer = NSPersistentContainer( - name: "Bitwarden-Authenticator", + name: modelName, managedObjectModel: managedObjectModel ) let storeDescription: NSPersistentStoreDescription @@ -92,7 +96,7 @@ public class AuthenticatorBridgeDataStore { /// public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) - let result = try persistentContainer.viewContext.fetch(fetchRequest) + let result = try backgroundContext.fetch(fetchRequest) return try result.map { data in try data.model diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift index ba23db5aef..82c2bbc5e9 100644 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift @@ -4,11 +4,11 @@ import Foundation extension AuthenticatorBridgeItemDataModel { static func fixture( - favorite: Bool = true, + favorite: Bool = false, id: String = UUID().uuidString, name: String = "Name", - totpKey: String? = "TOTP Key", - username: String? = "Username" + totpKey: String? = nil, + username: String? = nil ) -> AuthenticatorBridgeItemDataModel { AuthenticatorBridgeItemDataModel( favorite: favorite, @@ -22,10 +22,10 @@ extension AuthenticatorBridgeItemDataModel { static func fixtures() -> [AuthenticatorBridgeItemDataModel] { [ AuthenticatorBridgeItemDataModel.fixture(), - AuthenticatorBridgeItemDataModel.fixture(favorite: false), - AuthenticatorBridgeItemDataModel.fixture(totpKey: nil), - AuthenticatorBridgeItemDataModel.fixture(username: nil), - AuthenticatorBridgeItemDataModel.fixture(totpKey: nil, username: nil), + AuthenticatorBridgeItemDataModel.fixture(favorite: true), + AuthenticatorBridgeItemDataModel.fixture(totpKey: "TOTP Key"), + AuthenticatorBridgeItemDataModel.fixture(username: "Username"), + AuthenticatorBridgeItemDataModel.fixture(totpKey: "TOTP Key", username: "Username"), AuthenticatorBridgeItemDataModel.fixture(totpKey: ""), AuthenticatorBridgeItemDataModel.fixture(username: ""), AuthenticatorBridgeItemDataModel.fixture(totpKey: "", username: ""), From f68fac0243ffae65bbb5e5c6931fcd1e8c988cde Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 17 Sep 2024 11:36:45 -0400 Subject: [PATCH 09/52] First step of refactor - move convenience methods out to new service. Keep DataStore generic --- .../AuthenticatorBridgeDataStore.swift | 58 +-------- .../AuthenticatorBridgeItemService.swift | 115 ++++++++++++++++++ .../AuthenticatorBridgeItemDataTests.swift | 15 ++- ...AuthenticatorBridgeItemServiceTests.swift} | 15 ++- 4 files changed, 141 insertions(+), 62 deletions(-) create mode 100644 AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift rename AuthenticatorBridgeKit/Tests/{AuthenticatorBridgeDataStoreTests.swift => AuthenticatorBridgeItemServiceTests.swift} (90%) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index 69c3e6538b..d006a29aed 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -39,7 +39,7 @@ public class AuthenticatorBridgeDataStore { /// /// - Parameters: /// - storeType: The type of store to create. - /// - appGroupIdentifier: The app group identifier for the shared resource. + /// - groupIdentifier: The app group identifier for the shared resource. /// - errorHandler: Callback if an error occurs on load of store. /// public init( @@ -82,61 +82,11 @@ public class AuthenticatorBridgeDataStore { // MARK: Methods - /// Removes all items that are owned by the specific userId - /// - /// - Parameter userId: the id of the user for which to delete all items. - /// - public func deleteAllForUserId(_ userId: String) async throws { - try await executeBatchDelete(AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId)) - } - - /// Fetches all items that are owned by the specific userId - /// - /// - Parameter userId: the id of the user for which to fetch items. - /// - public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { - let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) - let result = try backgroundContext.fetch(fetchRequest) - - return try result.map { data in - try data.model - } - } - - /// Inserts the list of items into the store for the given userId. - /// - /// - Parameters: - /// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store. - /// - userId: the id of the user for which to insert the items. - /// - public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], - forUserId userId: String) async throws { - try await executeBatchInsert( - AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) - ) - } - - /// Deletes all existing items for a given user and inserts new items for the list of items provided. - /// - /// - Parameters: - /// - items: The new items to be inserted into the store - /// - userId: The userId of the items to be removed and then replaces with items. - /// - public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], - forUserId userId: String) async throws { - let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) - try await executeBatchReplace( - deleteRequest: deleteRequest, - insertRequest: insertRequest - ) - } - /// Executes a batch delete request and merges the changes into the background and view contexts. /// /// - Parameter request: The batch delete request to perform. /// - private func executeBatchDelete(_ request: NSBatchDeleteRequest) async throws { + public func executeBatchDelete(_ request: NSBatchDeleteRequest) async throws { try await backgroundContext.perform { try self.backgroundContext.executeAndMergeChanges( batchDeleteRequest: request, @@ -149,7 +99,7 @@ public class AuthenticatorBridgeDataStore { /// /// - Parameter request: The batch insert request to perform. /// - private func executeBatchInsert(_ request: NSBatchInsertRequest) async throws { + public func executeBatchInsert(_ request: NSBatchInsertRequest) async throws { try await backgroundContext.perform { try self.backgroundContext.executeAndMergeChanges( batchInsertRequest: request, @@ -165,7 +115,7 @@ public class AuthenticatorBridgeDataStore { /// - deleteRequest: The batch delete request to perform. /// - insertRequest: The batch insert request to perform. /// - private func executeBatchReplace( + public func executeBatchReplace( deleteRequest: NSBatchDeleteRequest, insertRequest: NSBatchInsertRequest ) async throws { diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift new file mode 100644 index 0000000000..392a1700bf --- /dev/null +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -0,0 +1,115 @@ +import Foundation + +// MARK: - AuthenticatorBridgeItemService + +/// A service that provides a number of convenience methods for working with the shared +/// `AuthenticatorBridgeItemData` objects. +/// +public protocol AuthenticatorBridgeItemService { + /// Removes all items that are owned by the specific userId + /// + /// - Parameter userId: the id of the user for which to delete all items. + /// + func deleteAllForUserId(_ userId: String) async throws + + /// Fetches all items that are owned by the specific userId + /// + /// - Parameter userId: the id of the user for which to fetch items. + /// + func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] + + /// Inserts the list of items into the store for the given userId. + /// + /// - Parameters: + /// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store. + /// - userId: the id of the user for which to insert the items. + /// + func insertItems(_ items: [AuthenticatorBridgeItemDataModel], + forUserId userId: String) async throws + + /// Deletes all existing items for a given user and inserts new items for the list of items provided. + /// + /// - Parameters: + /// - items: The new items to be inserted into the store + /// - userId: The userId of the items to be removed and then replaces with items. + /// + func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], + forUserId userId: String) async throws +} + +/// A concrete implementation of the `AuthenticatorBridgeItemService` protocol. +/// +public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { + // MARK: Properties + + /// The CoreData store for working with shared data. + let dataStore: AuthenticatorBridgeDataStore + + /// The keychain repository for working with the shared key. + let sharedKeychainRepository: SharedKeychainRepository + + // MARK: Initialization + + /// Initialize a `DefaultAuthenticatorBridgeItemService` + /// + /// - Parameters: + /// - dataStore: The CoreData store for working with shared data + /// - sharedKeychainRepository: The keychain repository for working with the shared key. + /// + init(dataStore: AuthenticatorBridgeDataStore, sharedKeychainRepository: SharedKeychainRepository) { + self.dataStore = dataStore + self.sharedKeychainRepository = sharedKeychainRepository + } + + // MARK: Methods + + /// Removes all items that are owned by the specific userId + /// + /// - Parameter userId: the id of the user for which to delete all items. + /// + public func deleteAllForUserId(_ userId: String) async throws { + try await dataStore.executeBatchDelete(AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId)) + } + + /// Fetches all items that are owned by the specific userId + /// + /// - Parameter userId: the id of the user for which to fetch items. + /// + public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) + let result = try dataStore.backgroundContext.fetch(fetchRequest) + + return try result.map { data in + try data.model + } + } + + /// Inserts the list of items into the store for the given userId. + /// + /// - Parameters: + /// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store. + /// - userId: the id of the user for which to insert the items. + /// + public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], + forUserId userId: String) async throws { + try await dataStore.executeBatchInsert( + AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + ) + } + + /// Deletes all existing items for a given user and inserts new items for the list of items provided. + /// + /// - Parameters: + /// - items: The new items to be inserted into the store + /// - userId: The userId of the items to be removed and then replaces with items. + /// + public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], + forUserId userId: String) async throws { + let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + try await dataStore.executeBatchReplace( + deleteRequest: deleteRequest, + insertRequest: insertRequest + ) + } +} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index f2bb6d94a4..96064b1edf 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -9,6 +9,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { let accessGroup = "group.com.example.bitwarden-authenticator" var dataStore: AuthenticatorBridgeDataStore! var error: Error? + var itemService: AuthenticatorBridgeItemService! var subject: AuthenticatorBridgeItemData! // MARK: Setup & Teardown @@ -23,6 +24,10 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { groupIdentifier: accessGroup, errorHandler: errorHandler ) + itemService = DefaultAuthenticatorBridgeItemService( + dataStore: dataStore, + sharedKeychainRepository: MockSharedKeychainRepository() + ) } override func tearDown() { @@ -56,7 +61,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { /// func test_fetchByIdRequest_empty() async throws { let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() - try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + try await itemService.insertItems(expectedItems, forUserId: "userId") let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: "bad id", userId: "userId") let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) @@ -70,7 +75,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { func test_fetchByIdRequest_success() async throws { let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() let expectedItem = expectedItems[3] - try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + try await itemService.insertItems(expectedItems, forUserId: "userId") let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId") let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) @@ -87,7 +92,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { /// func test_fetchByUserIdRequest_empty() async throws { let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + try await itemService.insertItems(expectedItems, forUserId: "userId") let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest( userId: "nonexistent userId" @@ -104,11 +109,11 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { func test_fetchByUserIdRequest_success() async throws { // Insert items for "userId" let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await dataStore.replaceAllItems(with: expectedItems, forUserId: "userId") + try await itemService.insertItems(expectedItems, forUserId: "userId") // Separate Insert for "differentUserId" let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() - try await dataStore.replaceAllItems(with: [differentUserItem], forUserId: "differentUserId") + try await itemService.insertItems([differentUserItem], forUserId: "differentUserId") // Verify items returned for "userId" do not contain items from "differentUserId" let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: "userId") diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift similarity index 90% rename from AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift rename to AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 756ae67b49..3817ae2d90 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -3,12 +3,14 @@ import XCTest @testable import AuthenticatorBridgeKit -final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { +final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" - var subject: AuthenticatorBridgeDataStore! + var dataStore: AuthenticatorBridgeDataStore! var error: Error? + var keychainRepository: SharedKeychainRepository! + var subject: AuthenticatorBridgeItemService! // MARK: Setup & Teardown @@ -17,15 +19,22 @@ final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { let errorHandler: (Error) -> Void = { error in self.error = error } - subject = AuthenticatorBridgeDataStore( + dataStore = AuthenticatorBridgeDataStore( storeType: .memory, groupIdentifier: accessGroup, errorHandler: errorHandler ) + keychainRepository = MockSharedKeychainRepository() + subject = DefaultAuthenticatorBridgeItemService( + dataStore: dataStore, + sharedKeychainRepository: keychainRepository + ) } override func tearDown() { + dataStore = nil error = nil + keychainRepository = nil subject = nil super.tearDown() } From 15add85348cfe637c3212a004de11c6a6db0b6d3 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 17 Sep 2024 14:06:48 -0400 Subject: [PATCH 10/52] Pull in reusable CoreData code from main project. Refactor to adopt PM app CoreData semantics. Refactor conveience methods to new service class --- .../AuthenticatorBridgeDataStore.swift | 15 ++- .../AuthenticatorBridgeItemData.swift | 104 ++++-------------- .../AuthenticatorBridgeItemService.swift | 8 +- AuthenticatorBridgeKit/CodableModelData.swift | 40 +++++++ AuthenticatorBridgeKit/ErrorReporter.swift | 16 +++ .../Logger+AuthenticatorBridgeKit.swift | 14 +++ AuthenticatorBridgeKit/ManagedObject.swift | 78 +++++++++++++ .../ManagedUserObject.swift | 74 +++++++++++++ .../AuthenticatorBridgeItemDataTests.swift | 14 +-- .../AuthenticatorBridgeItemServiceTests.swift | 12 +- .../Tests/ManagedObjectTests.swift | 24 ++++ .../Tests/TestHelpers/MockErrorReporter.swift | 10 ++ 12 files changed, 300 insertions(+), 109 deletions(-) create mode 100644 AuthenticatorBridgeKit/CodableModelData.swift create mode 100644 AuthenticatorBridgeKit/ErrorReporter.swift create mode 100644 AuthenticatorBridgeKit/Logger+AuthenticatorBridgeKit.swift create mode 100644 AuthenticatorBridgeKit/ManagedObject.swift create mode 100644 AuthenticatorBridgeKit/ManagedUserObject.swift create mode 100644 AuthenticatorBridgeKit/Tests/ManagedObjectTests.swift create mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/MockErrorReporter.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index d006a29aed..b6e5382ec6 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -27,6 +27,9 @@ public class AuthenticatorBridgeDataStore { return context }() + /// The service used by the application to report non-fatal errors. + let errorReporter: ErrorReporter + /// The CoreData model name. private let modelName = "Bitwarden-Authenticator" @@ -38,15 +41,17 @@ public class AuthenticatorBridgeDataStore { /// Initialize a `AuthenticatorBridgeDataStore`. /// /// - Parameters: - /// - storeType: The type of store to create. + /// - errorReporter: The service used by the application to report non-fatal errors. /// - groupIdentifier: The app group identifier for the shared resource. - /// - errorHandler: Callback if an error occurs on load of store. + /// - storeType: The type of store to create. /// public init( - storeType: AuthenticatorBridgeStoreType = .persisted, + errorReporter: ErrorReporter, groupIdentifier: String, - errorHandler: @escaping (Error) -> Void + storeType: AuthenticatorBridgeStoreType = .persisted ) { + self.errorReporter = errorReporter + #if SWIFT_PACKAGE let bundle = Bundle.module #else @@ -73,7 +78,7 @@ public class AuthenticatorBridgeDataStore { persistentContainer.loadPersistentStores { _, error in if let error { - errorHandler(error) + errorReporter.log(error: error) } } diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift index 9bcbbcebf6..3b1d323620 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemData.swift @@ -3,21 +3,16 @@ import Foundation /// A data model for persisting authenticator items into the shared CoreData store. /// -public class AuthenticatorBridgeItemData: NSManagedObject { +public class AuthenticatorBridgeItemData: NSManagedObject, CodableModelData { + public typealias Model = AuthenticatorBridgeItemDataModel + // MARK: Properties /// The item's ID @NSManaged public var id: String - /// The decoded object that is stored in modelData. - public var model: AuthenticatorBridgeItemDataModel { - get throws { - try JSONDecoder().decode(AuthenticatorBridgeItemDataModel.self, from: modelData) - } - } - /// The data model encoded as encrypted JSON data - @NSManaged public var modelData: Data + @NSManaged public var modelData: Data? /// The ID of the user who owns the item @NSManaged public var userId: String @@ -37,87 +32,14 @@ public class AuthenticatorBridgeItemData: NSManagedObject { ) throws { self.init(context: context) id = authenticatorItem.id - modelData = try JSONEncoder().encode(authenticatorItem) + model = authenticatorItem self.userId = userId } } -public extension AuthenticatorBridgeItemData { - /// The name of the entity of the managed object, as defined in the data model. - static var entityName: String { - String(describing: self) - } - - /// A `NSBatchInsertRequest` that inserts objects for the specified user. - /// - /// - Parameters: - /// - objects: The list of objects to insert. - /// - userId: The user associated with the objects to insert. - /// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user. - /// - static func batchInsertRequest( - models: [AuthenticatorBridgeItemDataModel], - userId: String - ) throws -> NSBatchInsertRequest { - try NSBatchInsertRequest( - entityName: AuthenticatorBridgeItemData.entityName, - objects: models.map { model in - try [ - "id": model.id, - "modelData": JSONEncoder().encode(model), - "userId": userId, - ] - } - ) - } - - /// A `NSBatchDeleteRequest` that deletes all objects for the specified user. - /// - /// - Parameter userId: The user associated with the objects to delete. - /// - Returns: A `NSBatchDeleteRequest` that deletes all objects for the user. - /// - static func deleteByUserIdRequest(userId: String) -> NSBatchDeleteRequest { - let fetchRequest = NSFetchRequest( - entityName: AuthenticatorBridgeItemData.entityName - ) - fetchRequest.predicate = AuthenticatorBridgeItemData.userIdPredicate(userId: userId) - return NSBatchDeleteRequest(fetchRequest: fetchRequest) - } - - /// A `NSFetchRequest` that fetches a specific item owned by the specified user matching the provided Id. - /// - /// - Parameters: - /// - id: The Id of the object to fetch. - /// - userId: The user associated with the object to fetch. - /// - Returns: A `NSFetchRequest` that fetches the object owned by the user with the given id. - /// - static func fetchByIdRequest( - id: String, - userId: String - ) -> NSFetchRequest { - let fetchRequest = NSFetchRequest( - entityName: AuthenticatorBridgeItemData.entityName - ) - fetchRequest.predicate = AuthenticatorBridgeItemData.userIdAndIdPredicate( - userId: userId, - id: id - ) - return fetchRequest - } - - /// A `NSFetchRequest` that fetches all `AuthenticatorBridgeItemData` for the specified user. - /// - /// - Parameter userId: The user associated with the objects to fetch. - /// - Returns: A `NSFetchRequest` that fetches all objects for the user. - /// - static func fetchByUserIdRequest(userId: String) -> NSFetchRequest { - let fetchRequest = NSFetchRequest( - entityName: AuthenticatorBridgeItemData.entityName - ) - fetchRequest.predicate = AuthenticatorBridgeItemData.userIdPredicate(userId: userId) - return fetchRequest - } +// MARK: - ManagedUserObject +extension AuthenticatorBridgeItemData: ManagedUserObject { /// Create an NSPredicate based on both the userId and id properties. /// /// - Parameters: @@ -143,4 +65,16 @@ public extension AuthenticatorBridgeItemData { static func userIdPredicate(userId: String) -> NSPredicate { NSPredicate(format: "%K == %@", #keyPath(AuthenticatorBridgeItemData.userId), userId) } + + /// Updates the object with the properties from the `value` struct and the given `userId` + /// + /// - Parameters: + /// - value: the `AuthenticatorBridgeItemDataModel` to use in updating the object + /// - userId: userId to update this object with. + /// + func update(with value: AuthenticatorBridgeItemDataModel, userId: String) throws { + id = value.id + model = value + self.userId = userId + } } diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 392a1700bf..e47180c6e9 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -79,8 +79,8 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) let result = try dataStore.backgroundContext.fetch(fetchRequest) - return try result.map { data in - try data.model + return result.compactMap { data in + data.model } } @@ -93,7 +93,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { try await dataStore.executeBatchInsert( - AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) ) } @@ -106,7 +106,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(models: items, userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) try await dataStore.executeBatchReplace( deleteRequest: deleteRequest, insertRequest: insertRequest diff --git a/AuthenticatorBridgeKit/CodableModelData.swift b/AuthenticatorBridgeKit/CodableModelData.swift new file mode 100644 index 0000000000..ddb7c1a3ca --- /dev/null +++ b/AuthenticatorBridgeKit/CodableModelData.swift @@ -0,0 +1,40 @@ +import CoreData +import OSLog + +/// A protocol for a `NSManagedObject` which persists a data model as JSON encoded data. The model +/// can be set via the `model` property which encodes the model to the data property, which should +/// be a `@NSManaged` property of the `NSManagedObject`. When the managed object is populated from +/// the database, the `model` property can be read to decode the data. +/// +protocol CodableModelData: AnyObject, NSManagedObject { + associatedtype Model: Codable + + /// A `@NSManaged` property of the manage object for storing the encoded model as data. + var modelData: Data? { get set } +} + +extension CodableModelData { + /// Encodes or decodes the model to/from the data instance. + var model: Model? { + get { + guard let modelData else { return nil } + do { + return try JSONDecoder().decode(Model.self, from: modelData) + } catch { + Logger.bridgeKit.error("Error decoding \(String(describing: Model.self)): \(error)") + return nil + } + } + set { + guard let newValue else { + modelData = nil + return + } + do { + modelData = try JSONEncoder().encode(newValue) + } catch { + Logger.bridgeKit.error("Error encoding \(String(describing: Model.self)): \(error)") + } + } + } +} diff --git a/AuthenticatorBridgeKit/ErrorReporter.swift b/AuthenticatorBridgeKit/ErrorReporter.swift new file mode 100644 index 0000000000..5b6661cc40 --- /dev/null +++ b/AuthenticatorBridgeKit/ErrorReporter.swift @@ -0,0 +1,16 @@ +/// A protocol for a service that can report non-fatal errors for investigation. +/// +public protocol ErrorReporter: AnyObject { + // MARK: Properties + + /// Whether collecting non-fatal errors and crash reports is enabled. + var isEnabled: Bool { get set } + + // MARK: Methods + + /// Logs an error to be reported. + /// + /// - Parameter error: The error to log. + /// + func log(error: Error) +} diff --git a/AuthenticatorBridgeKit/Logger+AuthenticatorBridgeKit.swift b/AuthenticatorBridgeKit/Logger+AuthenticatorBridgeKit.swift new file mode 100644 index 0000000000..7191d7b176 --- /dev/null +++ b/AuthenticatorBridgeKit/Logger+AuthenticatorBridgeKit.swift @@ -0,0 +1,14 @@ +import OSLog + +public extension Logger { + // MARK: Type Properties + + /// Logger instance for the app's action extension. + static let bridgeKit = Logger(subsystem: subsystem, category: "AuthenticatorBridgeKit") + + // MARK: Private + + /// The Logger subsystem passed along with logs to the logging system to identify logs from this + /// application. + private static let subsystem = Bundle.main.bundleIdentifier! +} diff --git a/AuthenticatorBridgeKit/ManagedObject.swift b/AuthenticatorBridgeKit/ManagedObject.swift new file mode 100644 index 0000000000..07cea5fe0a --- /dev/null +++ b/AuthenticatorBridgeKit/ManagedObject.swift @@ -0,0 +1,78 @@ +import CoreData + +/// A protocol for an `NSManagedObject` data model that adds some convenience methods for working +/// with Core Data. +/// +protocol ManagedObject: AnyObject { + /// The name of the entity of the managed object, as defined in the data model. + static var entityName: String { get } +} + +extension ManagedObject where Self: NSManagedObject { + static var entityName: String { + String(describing: self) + } + + /// Returns a `NSBatchInsertRequest` for batch inserting an array of objects. + /// + /// - Parameters: + /// - objects: The objects (or objects that can be converted to managed objects) to insert. + /// - handler: A handler that is called for each object to set the properties on the + /// `NSManagedObject` to insert. + /// - Returns: A `NSBatchInsertRequest` for batch inserting an array of objects. + /// + static func batchInsertRequest( + objects: [T], + handler: @escaping (Self, T) throws -> Void + ) throws -> NSBatchInsertRequest { + var index = 0 + var errorToThrow: Error? + let insertRequest = NSBatchInsertRequest(entityName: entityName) { (managedObject: NSManagedObject) -> Bool in + guard index < objects.count else { return true } + defer { index += 1 } + + if let managedObject = (managedObject as? Self) { + do { + try handler(managedObject, objects[index]) + } catch { + // The error can't be thrown directly in this closure, so capture it, return + // from the closure, and then throw it. + errorToThrow = error + return true + } + } + + return false + } + + if let errorToThrow { + throw errorToThrow + } + + return insertRequest + } + + /// Returns a `NSFetchRequest` for fetching instances of the managed object. + /// + /// - Parameter predicate: An optional predicate to apply to the fetch request. + /// - Returns: A `NSFetchRequest` used to fetch instances of the managed object. + /// + static func fetchRequest(predicate: NSPredicate? = nil) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = predicate + return fetchRequest + } + + /// Returns a `NSFetchRequest` for fetching a generic `NSFetchRequestResult` instances of the + /// managed object. + /// + /// - Parameter predicate: An optional predicate to apply to the fetch request. + /// - Returns: A `NSFetchRequest` used to fetch generic `NSFetchRequestResult` instances of the + /// managed object. + /// + static func fetchResultRequest(predicate: NSPredicate? = nil) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = predicate + return fetchRequest + } +} diff --git a/AuthenticatorBridgeKit/ManagedUserObject.swift b/AuthenticatorBridgeKit/ManagedUserObject.swift new file mode 100644 index 0000000000..fed121f0ab --- /dev/null +++ b/AuthenticatorBridgeKit/ManagedUserObject.swift @@ -0,0 +1,74 @@ +import CoreData + +/// A protocol for a `ManagedObject` data model associated with a user that adds some convenience +/// methods for building `NSPersistentStoreRequest` for common CRUD operations. +/// +protocol ManagedUserObject: ManagedObject { + /// The value type (struct) associated with the managed object that is persisted in the database. + associatedtype ValueType + + /// Returns a `NSPredicate` used for filtering by a user's ID. + /// + /// - Parameter userId: The user ID associated with the managed object. + /// + static func userIdPredicate(userId: String) -> NSPredicate + + /// Returns a `NSPredicate` used for filtering by a user and managed object ID. + /// + /// - Parameter userId: The user ID associated with the managed object. + /// + static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate + + /// Updates the managed object from its associated value type object and user ID. + /// + /// - Parameters: + /// - value: The value type object used to update the managed object. + /// - userId: The user ID associated with the object. + /// + func update(with value: ValueType, userId: String) throws +} + +extension ManagedUserObject where Self: NSManagedObject { + /// A `NSBatchInsertRequest` that inserts objects for the specified user. + /// + /// - Parameters: + /// - objects: The list of objects to insert. + /// - userId: The user associated with the objects to insert. + /// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user. + /// + static func batchInsertRequest(objects: [ValueType], userId: String) throws -> NSBatchInsertRequest { + try batchInsertRequest(objects: objects) { object, value in + try object.update(with: value, userId: userId) + } + } + + /// A `NSBatchDeleteRequest` that deletes all objects for the specified user. + /// + /// - Parameter userId: The user associated with the objects to delete. + /// - Returns: A `NSBatchDeleteRequest` that deletes all objects for the user. + /// + static func deleteByUserIdRequest(userId: String) -> NSBatchDeleteRequest { + let fetchRequest = fetchResultRequest(predicate: userIdPredicate(userId: userId)) + return NSBatchDeleteRequest(fetchRequest: fetchRequest) + } + + /// A `NSFetchRequest` that fetches objects for the specified user matching an ID. + /// + /// - Parameters: + /// - id: The ID of the object to fetch. + /// - userId: The user associated with the object to fetch. + /// - Returns: A `NSFetchRequest` that fetches all objects for the user. + /// + static func fetchByIdRequest(id: String, userId: String) -> NSFetchRequest { + fetchRequest(predicate: userIdAndIdPredicate(userId: userId, id: id)) + } + + /// A `NSFetchRequest` that fetches all objects for the specified user. + /// + /// - Parameter userId: The user associated with the objects to delete. + /// - Returns: A `NSFetchRequest` that fetches all objects for the user. + /// + static func fetchByUserIdRequest(userId: String) -> NSFetchRequest { + fetchRequest(predicate: userIdPredicate(userId: userId)) + } +} diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index 96064b1edf..cb228cc6e0 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -8,7 +8,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { let accessGroup = "group.com.example.bitwarden-authenticator" var dataStore: AuthenticatorBridgeDataStore! - var error: Error? + var errorReporter: ErrorReporter! var itemService: AuthenticatorBridgeItemService! var subject: AuthenticatorBridgeItemData! @@ -16,13 +16,11 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { override func setUp() { super.setUp() - let errorHandler: (Error) -> Void = { error in - self.error = error - } + errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( - storeType: .memory, + errorReporter: errorReporter, groupIdentifier: accessGroup, - errorHandler: errorHandler + storeType: .memory ) itemService = DefaultAuthenticatorBridgeItemService( dataStore: dataStore, @@ -32,7 +30,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { override func tearDown() { dataStore = nil - error = nil + errorReporter = nil subject = nil super.tearDown() } @@ -54,7 +52,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { let modelData = try XCTUnwrap(subject.modelData) let model = try JSONDecoder().decode(AuthenticatorBridgeItemDataModel.self, from: modelData) - XCTAssertEqual(try? subject.model, model) + XCTAssertEqual(subject.model, model) } /// Verify that the fetchById request correctly returns an empty list when no item matches the given userId and id. diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 3817ae2d90..cdac503b78 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -8,7 +8,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase let accessGroup = "group.com.example.bitwarden-authenticator" var dataStore: AuthenticatorBridgeDataStore! - var error: Error? + var errorReporter: ErrorReporter! var keychainRepository: SharedKeychainRepository! var subject: AuthenticatorBridgeItemService! @@ -16,13 +16,11 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase override func setUp() { super.setUp() - let errorHandler: (Error) -> Void = { error in - self.error = error - } + errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( - storeType: .memory, + errorReporter: errorReporter, groupIdentifier: accessGroup, - errorHandler: errorHandler + storeType: .memory ) keychainRepository = MockSharedKeychainRepository() subject = DefaultAuthenticatorBridgeItemService( @@ -33,7 +31,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase override func tearDown() { dataStore = nil - error = nil + errorReporter = nil keychainRepository = nil subject = nil super.tearDown() diff --git a/AuthenticatorBridgeKit/Tests/ManagedObjectTests.swift b/AuthenticatorBridgeKit/Tests/ManagedObjectTests.swift new file mode 100644 index 0000000000..5d938e06b4 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/ManagedObjectTests.swift @@ -0,0 +1,24 @@ +import CoreData +import XCTest + +@testable import AuthenticatorBridgeKit + +class ManagedObjectTests: AuthenticatorBridgeKitTestCase { + // MARK: Tests + + /// `fetchRequest()` returns a `NSFetchRequest` for the entity. + func test_fetchRequest() { + let fetchRequest = TestManagedObject.fetchRequest() + XCTAssertEqual(fetchRequest.entityName, "TestManagedObject") + } + + /// `fetchResultRequest()` returns a `NSFetchRequest` for the entity. + func test_fetchResultRequest() { + let fetchRequest = TestManagedObject.fetchResultRequest() + XCTAssertEqual(fetchRequest.entityName, "TestManagedObject") + } +} + +private class TestManagedObject: NSManagedObject, ManagedObject { + static var entityName = "TestManagedObject" +} diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockErrorReporter.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockErrorReporter.swift new file mode 100644 index 0000000000..9e6c94ac59 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockErrorReporter.swift @@ -0,0 +1,10 @@ +@testable import AuthenticatorBridgeKit + +class MockErrorReporter: ErrorReporter { + var errors = [Error]() + var isEnabled = false + + func log(error: Error) { + errors.append(error) + } +} From 18c014cf1539fe91e19f636191a467acdd265a57 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 17 Sep 2024 15:39:37 -0400 Subject: [PATCH 11/52] Reuse model name for sqlite reference as well --- AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index b6e5382ec6..4a6a1eda1f 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -71,7 +71,7 @@ public class AuthenticatorBridgeDataStore { case .persisted: let storeURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)! - .appendingPathComponent("Bitwarden-Authenticator.sqlite") + .appendingPathComponent("\(modelName).sqlite") storeDescription = NSPersistentStoreDescription(url: storeURL) } persistentContainer.persistentStoreDescriptions = [storeDescription] From 3d0c1fb74b300c6c3594e2b0425f0cb235ca4c07 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 17 Sep 2024 17:02:14 -0400 Subject: [PATCH 12/52] Implement encryption in the new item service --- .../AuthenticatorBridgeItemService.swift | 21 ++++++++++++++----- .../AuthenticatorBridgeItemDataTests.swift | 2 ++ .../AuthenticatorBridgeItemServiceTests.swift | 12 +++++++++++ .../MockSharedCryptographyService.swift | 9 ++++++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index e47180c6e9..26dffafad1 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -41,6 +41,9 @@ public protocol AuthenticatorBridgeItemService { /// public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { // MARK: Properties + + /// Cryptography service for encrypting/decrypting items. + let cryptoService: SharedCryptographyService /// The CoreData store for working with shared data. let dataStore: AuthenticatorBridgeDataStore @@ -56,7 +59,10 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// - dataStore: The CoreData store for working with shared data /// - sharedKeychainRepository: The keychain repository for working with the shared key. /// - init(dataStore: AuthenticatorBridgeDataStore, sharedKeychainRepository: SharedKeychainRepository) { + init(cryptoService: SharedCryptographyService, + dataStore: AuthenticatorBridgeDataStore, + sharedKeychainRepository: SharedKeychainRepository) { + self.cryptoService = cryptoService self.dataStore = dataStore self.sharedKeychainRepository = sharedKeychainRepository } @@ -78,10 +84,10 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) let result = try dataStore.backgroundContext.fetch(fetchRequest) - - return result.compactMap { data in + let encryptedItems = result.compactMap { data in data.model } + return try await cryptoService.decryptAuthenticatorItems(encryptedItems) } /// Inserts the list of items into the store for the given userId. @@ -92,8 +98,9 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) try await dataStore.executeBatchInsert( - AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + AuthenticatorBridgeItemData.batchInsertRequest(objects: encryptedItems, userId: userId) ) } @@ -105,8 +112,12 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( + objects: encryptedItems, + userId: userId + ) try await dataStore.executeBatchReplace( deleteRequest: deleteRequest, insertRequest: insertRequest diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index cfb2e648db..ed549344f0 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -17,6 +17,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { override func setUp() { super.setUp() + cryptoService = MockSharedCryptographyService() errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( errorReporter: errorReporter, @@ -24,6 +25,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { storeType: .memory ) itemService = DefaultAuthenticatorBridgeItemService( + cryptoService: cryptoService, dataStore: dataStore, sharedKeychainRepository: MockSharedKeychainRepository() ) diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index cdac503b78..1175ca499c 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -7,6 +7,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" + var cryptoService: MockSharedCryptographyService! var dataStore: AuthenticatorBridgeDataStore! var errorReporter: ErrorReporter! var keychainRepository: SharedKeychainRepository! @@ -16,6 +17,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase override func setUp() { super.setUp() + cryptoService = MockSharedCryptographyService() errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( errorReporter: errorReporter, @@ -24,12 +26,14 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase ) keychainRepository = MockSharedKeychainRepository() subject = DefaultAuthenticatorBridgeItemService( + cryptoService: cryptoService, dataStore: dataStore, sharedKeychainRepository: keychainRepository ) } override func tearDown() { + cryptoService = nil dataStore = nil errorReporter = nil keychainRepository = nil @@ -83,6 +87,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase // Fetch should return only the expectedItem let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.decryptCalled, + "Items should have been decrypted when calling fetchAllForUser!") XCTAssertNotNil(result) XCTAssertEqual(result.count, expectedItems.count) XCTAssertEqual(result, expectedItems) @@ -100,6 +106,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase try await subject.insertItems(expectedItems, forUserId: "userId") let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } @@ -132,6 +140,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) XCTAssertFalse(result.contains { $0 == initialItems.first }) } @@ -146,6 +156,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } } diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift index f0367e071b..b850d0c1de 100644 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -4,15 +4,20 @@ import Foundation @testable import AuthenticatorBridgeKit class MockSharedCryptographyService: SharedCryptographyService { + var decryptCalled = false + var encryptCalled = false + func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataModel] { - items + decryptCalled = true + return items } func encryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataModel] { - items + encryptCalled = true + return items } } From af008bc6b04e88590d4b6485aaa5db0e4843c75b Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 18 Sep 2024 13:55:51 -0400 Subject: [PATCH 13/52] [BITAU-149] Add Setting to Turn On Authenticator Syncing for an Account --- .../Repositories/SettingsRepository.swift | 8 +++ .../Core/Platform/Services/StateService.swift | 66 +++++++++++++++++++ .../Services/Stores/AppSettingsStore.swift | 27 ++++++++ 3 files changed, 101 insertions(+) diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift index 29858a6983..ad379a51f8 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift @@ -46,6 +46,9 @@ protocol SettingsRepository: AnyObject { /// func getDisableAutoTotpCopy() async throws -> Bool + /// Get the current value of the sync to Authenticator setting. + func getSyncToAuthenticator() async throws -> Bool + /// A publisher for the last sync time. /// /// - Returns: A publisher for the last sync time. @@ -181,6 +184,11 @@ extension DefaultSettingsRepository: SettingsRepository { try await stateService.getDisableAutoTotpCopy() } + func getSyncToAuthenticator() async throws -> Bool { + false +// try await stateService.getConnectToWatch() + } + func lastSyncTimePublisher() async throws -> AsyncPublisher> { try await stateService.lastSyncTimePublisher().values } diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 5409945a92..47379373c1 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -254,6 +254,14 @@ protocol StateService: AnyObject { /// func getShowWebIcons() async -> Bool + /// Gets the sync to Authenticator value for an account. + /// + /// - Parameter userId: The user ID associated with the sync to Authenticator value. Defaults to the active + /// account if `nil` + /// - Returns: Whether to sync TOPT codes to the Authenticator app. + /// + func getSyncToAuthenticator(userId: String?) async throws -> Bool + /// Gets the session timeout action. /// /// - Parameter userId: The user ID for the account. @@ -544,6 +552,14 @@ protocol StateService: AnyObject { /// func setShowWebIcons(_ showWebIcons: Bool) async + /// Sets the sync to authenticator value for an account. + /// + /// - Parameters: + /// - connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - userId: The user ID of the account. Defaults to the active account if `nil`. + /// + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws + /// Sets the session timeout action. /// /// - Parameters: @@ -636,6 +652,12 @@ protocol StateService: AnyObject { /// - Returns: A publisher for whether or not to show the web icons. /// func showWebIconsPublisher() async -> AnyPublisher + + /// A publisher for the sync to authenticator value. + /// + /// - Returns: A publisher for the sync to authenticator value. + /// + func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> } extension StateService { @@ -797,6 +819,14 @@ extension StateService { try await getServerConfig(userId: nil) } + /// Gets the sync to authenticator value for the active account. + /// + /// - Returns: Whether to sync TOTP codes to the Authenticator app. + /// + func getSyncToAuthenticator() async throws -> Bool { + try await getSyncToAuthenticator(userId: nil) + } + /// Gets the session timeout action. /// /// - Returns: The action to perform when a session timeout occurs. @@ -991,6 +1021,14 @@ extension StateService { try await setServerConfig(config, userId: nil) } + /// Sets the sync to authenticator value for the active account. + /// + /// - Parameter connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// + func setSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { + try await setSyncToAuthenticator(syncToAuthenticator, userId: nil) + } + /// Sets the session timeout action. /// /// - Parameter action: The action to take when the user's session times out. @@ -1098,6 +1136,9 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le /// A subject containing whether to show the website icons. private var showWebIconsSubject: CurrentValueSubject + /// A subject containing the sync to authenticator value. + private var syncToAuthenticatorByUserIdSubject = CurrentValueSubject<[String: Bool], Never>([:]) + // MARK: Initialization /// Initialize a `DefaultStateService`. @@ -1307,6 +1348,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le !appSettingsStore.disableWebIcons } + func getSyncToAuthenticator(userId: String?) async throws -> Bool { + let userId = try userId ?? getActiveAccountUserId() + return appSettingsStore.syncToAuthenticator(userId: userId) + } + func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction { let userId = try userId ?? getActiveAccountUserId() guard let rawValue = appSettingsStore.timeoutAction(userId: userId), @@ -1554,6 +1600,12 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le showWebIconsSubject.send(showWebIcons) } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() + appSettingsStore.setSyncToAuthenticator(syncToAuthenticator, userId: userId) + syncToAuthenticatorByUserIdSubject.value[userId] = syncToAuthenticator + } + func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws { let userId = try userId ?? getActiveAccountUserId() appSettingsStore.setTimeoutAction(key: action, userId: userId) @@ -1647,6 +1699,20 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le showWebIconsSubject.eraseToAnyPublisher() } + func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> { + activeAccountIdPublisher().flatMap { userId in + self.syncToAuthenticatorByUserIdSubject.map { values in + let userValue = if let userId { + values[userId] ?? self.appSettingsStore.connectToWatch(userId: userId) + } else { + false + } + return (userId, userValue) + } + } + .eraseToAnyPublisher() + } + // MARK: Private /// Returns the user ID for the active account. diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index 6ee2f449e0..894229b408 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -358,6 +358,14 @@ protocol AppSettingsStore: AnyObject { /// func setShouldTrustDevice(shouldTrustDevice: Bool?, userId: String) + /// Sets the sync to Authenticator setting for the user. + /// + /// - Parameters: + /// - connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - userId: The user ID associated with the sync to Authenticator value. + /// + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) + /// Sets the user's timeout action. /// /// - Parameters: @@ -412,6 +420,14 @@ protocol AppSettingsStore: AnyObject { /// func shouldTrustDevice(userId: String) -> Bool? + /// Gets the sync to Authenticator setting for the user. + /// + /// - Parameter userId: The user ID associated with the sync to Authenticator value. + /// + /// - Returns: Whether to sync TOTP codes with the Authenticator app. + /// + func syncToAuthenticator(userId: String) -> Bool + /// Returns the action taken upon a session timeout. /// /// - Parameter userId: The user ID associated with the session timeout action. @@ -612,6 +628,7 @@ extension DefaultAppSettingsStore: AppSettingsStore { case rememberedOrgIdentifier case serverConfig(userId: String) case shouldTrustDevice(userId: String) + case syncToAuthenticator(userId: String) case state case twoFactorToken(email: String) case unsuccessfulUnlockAttempts(userId: String) @@ -694,6 +711,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "shouldTrustDevice_\(userId)" case .state: key = "state" + case let .syncToAuthenticator(userId): + key = "shouldSyncToAuthenticator_\(userId)" case let .twoFactorToken(email): key = "twoFactorToken_\(email)" case let .unsuccessfulUnlockAttempts(userId): @@ -960,6 +979,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { store(shouldTrustDevice, for: .shouldTrustDevice(userId: userId)) } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) { + store(syncToAuthenticator, for: .syncToAuthenticator(userId: userId)) + } + func setTimeoutAction(key: SessionTimeoutAction, userId: String) { store(key, for: .vaultTimeoutAction(userId: userId)) } @@ -980,6 +1003,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { store(minutes, for: .vaultTimeout(userId: userId)) } + func syncToAuthenticator(userId: String) -> Bool { + fetch(for: .syncToAuthenticator(userId: userId)) + } + func timeoutAction(userId: String) -> Int? { fetch(for: .vaultTimeoutAction(userId: userId)) } From ee63c9d2ec01984a48a72aa661b1384976742e62 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 18 Sep 2024 13:56:28 -0400 Subject: [PATCH 14/52] [BITAU-149] Add Setting to Turn On Authenticator Syncing for an Account --- .../Platform/Repositories/SettingsRepository.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift index ad379a51f8..312e235f59 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift @@ -47,6 +47,7 @@ protocol SettingsRepository: AnyObject { func getDisableAutoTotpCopy() async throws -> Bool /// Get the current value of the sync to Authenticator setting. + /// func getSyncToAuthenticator() async throws -> Bool /// A publisher for the last sync time. @@ -79,6 +80,12 @@ protocol SettingsRepository: AnyObject { /// func updateDisableAutoTotpCopy(_ disableAutoTotpCopy: Bool) async throws + /// Update the cached value of the sync to authenticator setting. + /// + /// - Parameter connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// + func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws + // MARK: Publishers /// The publisher to keep track of the list of the user's current folders. @@ -185,8 +192,7 @@ extension DefaultSettingsRepository: SettingsRepository { } func getSyncToAuthenticator() async throws -> Bool { - false -// try await stateService.getConnectToWatch() + try await stateService.getConnectToWatch() } func lastSyncTimePublisher() async throws -> AsyncPublisher> { @@ -209,6 +215,10 @@ extension DefaultSettingsRepository: SettingsRepository { try await stateService.setDisableAutoTotpCopy(disableAutoTotpCopy) } + func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { + try await stateService.setSyncToAuthenticator(syncToAuthenticator) + } + // MARK: Publishers func foldersListPublisher() async throws -> AsyncThrowingPublisher> { From 3e5dd067a8f48b505939b282d50a333dfdba5020 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 18 Sep 2024 16:55:00 -0400 Subject: [PATCH 15/52] [BITAU-148] First pass at adding the sync service to the PM app --- .../AuthenticatorBridgeItemService.swift | 8 +- .../Services/AuthenticatorSyncService.swift | 219 ++++++++++++++++++ .../AuthenticatorSyncServiceTests.swift | 67 ++++++ .../ErrorReporter/ErrorReporter.swift | 4 +- .../Platform/Services/ServiceContainer.swift | 42 ++++ .../MockAuthenticatorBridgeItemService.swift | 22 ++ .../MockAuthenticatorSyncService.swift | 3 + .../MockSharedKeychainRepository.swift | 31 +++ .../TestHelpers/ServiceContainer+Mocks.swift | 2 + 9 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift create mode 100644 BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift create mode 100644 BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift create mode 100644 BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift create mode 100644 BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 26dffafad1..07b37575c8 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -41,7 +41,7 @@ public protocol AuthenticatorBridgeItemService { /// public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { // MARK: Properties - + /// Cryptography service for encrypting/decrypting items. let cryptoService: SharedCryptographyService @@ -59,9 +59,9 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// - dataStore: The CoreData store for working with shared data /// - sharedKeychainRepository: The keychain repository for working with the shared key. /// - init(cryptoService: SharedCryptographyService, - dataStore: AuthenticatorBridgeDataStore, - sharedKeychainRepository: SharedKeychainRepository) { + public init(cryptoService: SharedCryptographyService, + dataStore: AuthenticatorBridgeDataStore, + sharedKeychainRepository: SharedKeychainRepository) { self.cryptoService = cryptoService self.dataStore = dataStore self.sharedKeychainRepository = sharedKeychainRepository diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift new file mode 100644 index 0000000000..a6281ad7a1 --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -0,0 +1,219 @@ +import AuthenticatorBridgeKit +import BitwardenSdk +import CryptoKit +import Foundation +import OSLog +import UIKit + +// MARK: - AuthenticatorSyncService + +/// The service used to share TOTP codes to and from the Authenticator app.. +/// +protocol AuthenticatorSyncService {} + +// MARK: - DefaultAuthenticatorSyncService + +/// The default `AuthenticatorSyncService` type for the application. +/// +class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { + // MARK: Private Properties + + /// The Application instance, used to determine if the app is in the foreground, etc + private let application: Application? + + /// The service for managing sharing items to/from the Authenticator app. + private let authBridgeItemService: AuthenticatorBridgeItemService + + /// The Tasks listening for Cipher updates (one for each user, indexed by the userId). + private var cipherPublisherTasks = [String: Task?]() + + /// The service used to manage syncing and updates to the user's ciphers. + private let cipherService: CipherService + + /// The service that handles common client functionality such as encryption and decryption. + private let clientService: ClientService + + /// The service to get server-specified configuration. + private let configService: ConfigService + + /// The service used by the application to report non-fatal errors. + private let errorReporter: ErrorReporter + + /// Notification Center Service to subscribe to foreground/background events. + private let notificationCenterService: NotificationCenterService + + /// The keychain repository for managing the key shared between the PM and Authenticator apps. + private let sharedKeychainRepository: SharedKeychainRepository + + /// The service used by the application to manage account state. + private let stateService: StateService + + /// The service used by the application to manage vault access. + private let vaultTimeoutService: VaultTimeoutService + + // MARK: Initialization + + /// Initialize a `DefaultAuthenticatorSyncService`. + /// + /// - Parameters: + /// - application: The Application instance, used to determine if the app is in the foreground, etc + /// - authBridgeItemService: The service for managing sharing items to/from the Authenticator app. + /// - cipherService: The service used to manage syncing and updates to the user's ciphers. + /// - clientService: The service that handles common client functionality such as encryption and decryption. + /// - configService: The service to get server-specified configuration. + /// - errorReporter: The service used by the application to report non-fatal errors.\ organizations. + /// - notificationCenterService: Notification Center Service to subscribe to foreground/background events. + /// - sharedKeychainRepository: The keychain repository for managing the key shared + /// between the PM and Authenticator apps. + /// - stateService: The service used by the application to manage account state. + /// - vaultTimeoutService: The service used by the application to manage vault access. + /// + init( + application: Application?, + authBridgeItemService: AuthenticatorBridgeItemService, + cipherService: CipherService, + clientService: ClientService, + configService: ConfigService, + errorReporter: ErrorReporter, + notificationCenterService: NotificationCenterService, + sharedKeychainRepository: SharedKeychainRepository, + stateService: StateService, + vaultTimeoutService: VaultTimeoutService + ) { + self.application = application + self.authBridgeItemService = authBridgeItemService + self.cipherService = cipherService + self.clientService = clientService + self.configService = configService + self.errorReporter = errorReporter + self.sharedKeychainRepository = sharedKeychainRepository + self.notificationCenterService = notificationCenterService + self.stateService = stateService + self.vaultTimeoutService = vaultTimeoutService + super.init() + + Task { + if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, + defaultValue: true) { + subscribeToAppState() + } + } + } + + // MARK: Private Methods + + /// Check to see if the shared Authenticator key exists. If it doesn't already exist, create it. + /// + private func createAuthenticatorKeyIfNeeded() async throws { + let storedKey = try? await sharedKeychainRepository.getAuthenticatorKey() + if storedKey == nil { + let key = SymmetricKey(size: .bits256) + let data = key.withUnsafeBytes { Data(Array($0)) } + try await sharedKeychainRepository.setAuthenticatorKey(data) + } + } + + /// Take a list of encrypted ciphers, decrypt them, filter for only active ciphers with a totp code, then + /// convert the list to AuthenticatorSyncItemDataModel to be stored and sync'd to the Authenticator app. + /// + /// - Parameters: + /// - ciphers: The encrypted `Cipher` objects. + /// - userId: The userId of the account to which these Ciphers belong. + /// + /// - Returns: The decrypted, filtered, and sorted `CipherDTO` objects. + /// + private func decryptTOTPs(_ ciphers: [Cipher], + userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + let decryptedCiphers = try await ciphers.asyncMap { cipher in + try await self.clientService.vault().ciphers().decrypt(cipher: cipher) + } + let account = try await stateService.getActiveAccount() + let username = account.profile.name ?? account.profile.email + + let ciphersToUse = decryptedCiphers.filter { cipher in + cipher.deletedDate == nil + && cipher.type == .login + && cipher.login?.totp != nil + } + + return ciphersToUse.map { cipher in + AuthenticatorBridgeItemDataModel( + favorite: false, + id: cipher.id ?? UUID().uuidString, + name: cipher.name, + totpKey: cipher.login?.totp, + username: username + ) + } + } + + /// Subscribe to NotificationCenter updates about if the app is in the foreground vs. background. + /// + private func subscribeToAppState() { + Task { + for await _ in notificationCenterService.willEnterForegroundPublisher() { + Logger.application.log("#### app entered foreground") + syncToAuthenticator() + } + } + } + + /// Create a task for the given userId to listen for Cipher updates and sync to the Authenticator store. + /// + /// - Parameter userId: The userId of the account to listen for. + /// + private func subscribeToCipherUpdates(userId: String) { + guard cipherPublisherTasks[userId] == nil else { return } + + cipherPublisherTasks[userId] = Task { + do { + for try await ciphers in try await self.cipherService.ciphersPublisher().values { + try await writeCiphers(ciphers: ciphers, userId: userId) + } + } catch { + errorReporter.log(error: error) + } + } + } + + /// Sync to Authenticator for all unlocked accounts. + /// + private func syncToAuthenticator() { + Task { + for account in try await stateService.getAccounts() { + let userId = account.profile.userId + do { + Logger.application.log("#### sync is on for userId: \(userId)") + guard let app = application as? UIApplication else { return } + if await app.applicationState == .background { + Logger.application.log("#### App in background. Wait for foreground.") + } else if vaultTimeoutService.isLocked(userId: userId) { + Logger.application.log( + "#### App in foreground and locked for \(userId). Waiting for unlock to occur." + ) + break + } else { + Logger.application.log("#### App in foreground and unlocked. Begin key creations.") + try await createAuthenticatorKeyIfNeeded() + Logger.application.log("#### Subscribing to cipher updates") + subscribeToCipherUpdates(userId: userId) + } + } catch { + Logger.application.log("#### Error in Auth Options publisher: \(error)") + } + } + } + } + + /// Takes in a list of encrypted Ciphers, decrypts them, and writes ones with TOTP codes to the shared store. + /// + /// - Parameters: + /// - ciphers: The array of Ciphers belonging to a user to decrypt and store if necessary. + /// - userId: The userId of the account to which the Ciphers belong. + /// + private func writeCiphers(ciphers: [Cipher], userId: String) async throws { + let items = try await decryptTOTPs(ciphers, userId: userId) + Logger.application.log("#### replacing data for \(userId)") + try await authBridgeItemService.replaceAllItems(with: items, forUserId: userId) + } +} diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift new file mode 100644 index 0000000000..99e5f7e604 --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -0,0 +1,67 @@ +import XCTest + +@testable import BitwardenShared + +final class AuthenticatorSyncServiceTests: BitwardenTestCase { + var application: MockApplication! + var authBridgeItemService: MockAuthenticatorBridgeItemService! + var cipherService: MockCipherService! + var clientService: MockClientService! + var configService: MockConfigService! + var errorReporter: MockErrorReporter! + var notificationCenterService: MockNotificationCenterService! + var sharedKeychainRepository: MockSharedKeychainRepository! + var stateService: MockStateService! + var subject: DefaultAuthenticatorSyncService! + var vaultTimeoutService: MockVaultTimeoutService! + + // MARK: Setup and Teardown + + override func setUp() { + super.setUp() + + application = MockApplication() + authBridgeItemService = MockAuthenticatorBridgeItemService() + cipherService = MockCipherService() + configService = MockConfigService() + clientService = MockClientService() + errorReporter = MockErrorReporter() + notificationCenterService = MockNotificationCenterService() + sharedKeychainRepository = MockSharedKeychainRepository() + stateService = MockStateService() + vaultTimeoutService = MockVaultTimeoutService() + subject = DefaultAuthenticatorSyncService( + application: application, + authBridgeItemService: authBridgeItemService, + cipherService: cipherService, + clientService: clientService, + configService: configService, + errorReporter: errorReporter, + notificationCenterService: notificationCenterService, + sharedKeychainRepository: sharedKeychainRepository, + stateService: stateService, + vaultTimeoutService: vaultTimeoutService + ) + } + + override func tearDown() { + super.tearDown() + + application = nil + authBridgeItemService = nil + cipherService = nil + configService = nil + clientService = nil + errorReporter = nil + notificationCenterService = nil + sharedKeychainRepository = nil + stateService = nil + subject = nil + vaultTimeoutService = nil + } + + // MARK: Tests + + /// `auth(for:)` returns a new `ClientAuthProtocol` for every user. + func test_auth() async throws {} +} diff --git a/BitwardenShared/Core/Platform/Services/ErrorReporter/ErrorReporter.swift b/BitwardenShared/Core/Platform/Services/ErrorReporter/ErrorReporter.swift index 5b6661cc40..ab839e422b 100644 --- a/BitwardenShared/Core/Platform/Services/ErrorReporter/ErrorReporter.swift +++ b/BitwardenShared/Core/Platform/Services/ErrorReporter/ErrorReporter.swift @@ -1,6 +1,8 @@ +import AuthenticatorBridgeKit + /// A protocol for a service that can report non-fatal errors for investigation. /// -public protocol ErrorReporter: AnyObject { +public protocol ErrorReporter: AnyObject, AuthenticatorBridgeKit.ErrorReporter { // MARK: Properties /// Whether collecting non-fatal errors and crash reports is enabled. diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 081d9973e8..7b4e206551 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -1,3 +1,4 @@ +import AuthenticatorBridgeKit import BitwardenSdk import UIKit @@ -35,6 +36,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// The service used by the application to handle authentication tasks. let authService: AuthService + /// The service used by the application to sync TOTP codes with the Authenticator app. + let authenticatorSyncService: AuthenticatorSyncService? + /// The service which manages the ciphers exposed to the system for AutoFill suggestions. let autofillCredentialService: AutofillCredentialService @@ -155,6 +159,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// - appSettingsStore: The service used by the application to persist app setting values. /// - authRepository: The repository used by the application to manage auth data for the UI layer. /// - authService: The service used by the application to handle authentication tasks. + /// - authenticatorSyncService: The service used by the application to sync TOTP codes with the Authenticator app. /// - autofillCredentialService: The service which manages the ciphers exposed to the system /// for AutoFill suggestions. /// - biometricsRepository: The repository to manage biometric unlock policies and access @@ -202,6 +207,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le appSettingsStore: AppSettingsStore, authRepository: AuthRepository, authService: AuthService, + authenticatorSyncService: AuthenticatorSyncService, autofillCredentialService: AutofillCredentialService, biometricsRepository: BiometricsRepository, biometricsService: BiometricsService, @@ -245,6 +251,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.appSettingsStore = appSettingsStore self.authRepository = authRepository self.authService = authService + self.authenticatorSyncService = authenticatorSyncService self.autofillCredentialService = autofillCredentialService self.biometricsRepository = biometricsRepository self.biometricsService = biometricsService @@ -599,6 +606,40 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le vaultTimeoutService: vaultTimeoutService ) + let authenticatorDataStore = AuthenticatorBridgeDataStore( + errorReporter: errorReporter, + groupIdentifier: Bundle.main.sharedAppGroupIdentifier, + storeType: .persisted + ) + + let sharedKeychainRepository = DefaultSharedKeychainRepository( + sharedAppGroupIdentifier: Bundle.main.sharedAppGroupIdentifier, + keychainService: keychainService + ) + + let sharedCryptographyService = DefaultAuthenticatorCryptographyService( + sharedKeychainRepository: sharedKeychainRepository + ) + + let authBridgeItemService = DefaultAuthenticatorBridgeItemService( + cryptoService: sharedCryptographyService, + dataStore: authenticatorDataStore, + sharedKeychainRepository: sharedKeychainRepository + ) + + let authenticatorSyncService = DefaultAuthenticatorSyncService( + application: application, + authBridgeItemService: authBridgeItemService, + cipherService: cipherService, + clientService: clientService, + configService: configService, + errorReporter: errorReporter, + notificationCenterService: notificationCenterService, + sharedKeychainRepository: sharedKeychainRepository, + stateService: stateService, + vaultTimeoutService: vaultTimeoutService + ) + self.init( apiService: apiService, appIdService: appIdService, @@ -606,6 +647,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le appSettingsStore: appSettingsStore, authRepository: authRepository, authService: authService, + authenticatorSyncService: authenticatorSyncService, autofillCredentialService: autofillCredentialService, biometricsRepository: biometricsRepository, biometricsService: biometricsService, diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift new file mode 100644 index 0000000000..c954b69f7b --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift @@ -0,0 +1,22 @@ +import AuthenticatorBridgeKit +import BitwardenShared + +class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { + var storedItems: [String: [AuthenticatorBridgeItemDataModel]] = [:] + + func deleteAllForUserId(_ userId: String) async throws { + storedItems[userId] = [] + } + + func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + storedItems[userId] ?? [] + } + + func insertItems(_ items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + storedItems[userId] = items + } + + func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + storedItems[userId] = items + } +} diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift new file mode 100644 index 0000000000..81f2c82d89 --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift @@ -0,0 +1,3 @@ +@testable import BitwardenShared + +class MockAuthenticatorSyncService: AuthenticatorSyncService {} diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift new file mode 100644 index 0000000000..689202e571 --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift @@ -0,0 +1,31 @@ +import CryptoKit +import Foundation + +@testable import AuthenticatorBridgeKit + +class MockSharedKeychainRepository { + var authenticatorKey: Data? +} + +extension MockSharedKeychainRepository: SharedKeychainRepository { + func generateKeyData() -> Data { + let key = SymmetricKey(size: .bits256) + return key.withUnsafeBytes { Data(Array($0)) } + } + + func deleteAuthenticatorKey() throws { + authenticatorKey = nil + } + + func getAuthenticatorKey() async throws -> Data { + if let authenticatorKey { + return authenticatorKey + } else { + throw AuthenticatorKeychainServiceError.keyNotFound(.authenticatorKey) + } + } + + func setAuthenticatorKey(_ value: Data) async throws { + authenticatorKey = value + } +} diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift index fae5d0729a..f0cdd3baf4 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift @@ -9,6 +9,7 @@ extension ServiceContainer { appSettingsStore: AppSettingsStore = MockAppSettingsStore(), authRepository: AuthRepository = MockAuthRepository(), authService: AuthService = MockAuthService(), + authenticatorSyncService: AuthenticatorSyncService = MockAuthenticatorSyncService(), autofillCredentialService: AutofillCredentialService = MockAutofillCredentialService(), biometricsRepository: BiometricsRepository = MockBiometricsRepository(), biometricsService: BiometricsService = MockBiometricsService(), @@ -57,6 +58,7 @@ extension ServiceContainer { appSettingsStore: appSettingsStore, authRepository: authRepository, authService: authService, + authenticatorSyncService: authenticatorSyncService, autofillCredentialService: autofillCredentialService, biometricsRepository: biometricsRepository, biometricsService: biometricsService, From 47a98d2c11b6d258b47a8e7a2f7689d07293ed85 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 18 Sep 2024 18:30:46 -0400 Subject: [PATCH 16/52] Added tests for all of the new Settings pieces --- .../AuthenticatorBridgeItemService.swift | 2 +- .../Repositories/SettingsRepository.swift | 2 +- .../SettingsRepositoryTests.swift | 27 +++++++ .../TestHelpers/MockSettingsRepository.swift | 12 +++ .../Core/Platform/Services/StateService.swift | 2 +- .../Platform/Services/StateServiceTests.swift | 79 +++++++++++++++++++ .../Stores/AppSettingsStoreTests.swift | 16 ++++ .../TestHelpers/MockAppSettingsStore.swift | 9 +++ .../TestHelpers/MockStateService.swift | 19 +++++ 9 files changed, 165 insertions(+), 3 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 26dffafad1..bc6fb2c162 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -41,7 +41,7 @@ public protocol AuthenticatorBridgeItemService { /// public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { // MARK: Properties - + /// Cryptography service for encrypting/decrypting items. let cryptoService: SharedCryptographyService diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift index 312e235f59..0fcac2537c 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift @@ -192,7 +192,7 @@ extension DefaultSettingsRepository: SettingsRepository { } func getSyncToAuthenticator() async throws -> Bool { - try await stateService.getConnectToWatch() + try await stateService.getSyncToAuthenticator() } func lastSyncTimePublisher() async throws -> AsyncPublisher> { diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift index c49ba9e00f..a9f0b3be11 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift @@ -137,6 +137,19 @@ class SettingsRepositoryTests: BitwardenTestCase { XCTAssertTrue(value) } + /// `getSyncToAuthenticator()` returns the expected value. + func test_getSyncToAuthenticator() async throws { + stateService.activeAccount = .fixture() + + // Defaults to false if no value is set. + var value = try await subject.getSyncToAuthenticator() + XCTAssertFalse(value) + + stateService.syncToAuthenticatorByUserId["1"] = true + value = try await subject.getSyncToAuthenticator() + XCTAssertTrue(value) + } + /// `fetchSync()` throws an error if syncing fails. func test_fetchSync_error() async throws { syncService.fetchSyncResult = .failure(BitwardenTestError.example) @@ -229,4 +242,18 @@ class SettingsRepositoryTests: BitwardenTestCase { try XCTAssertTrue(XCTUnwrap(stateService.disableAutoTotpCopyByUserId["1"])) } + + /// `updateSyncToAuthenticator()` updates the value in the state service. + func test_updateSyncToAuthenticator() async throws { + stateService.activeAccount = .fixture() + + // The value should start off with a default of false. + var value = try await stateService.getSyncToAuthenticator() + XCTAssertFalse(value) + + // Set the value and ensure it updates. + try await subject.updateSyncToAuthenticator(true) + value = try await stateService.getSyncToAuthenticator() + XCTAssertTrue(value) + } } diff --git a/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift index 4625630713..1325487268 100644 --- a/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift @@ -22,6 +22,8 @@ class MockSettingsRepository: SettingsRepository { var getDisableAutoTotpCopyResult: Result = .success(false) var lastSyncTimeError: Error? var lastSyncTimeSubject = CurrentValueSubject(nil) + var syncToAuthenticator = false + var syncToAuthenticatorResult: Result = .success(()) var updateDefaultUriMatchTypeValue: BitwardenShared.UriMatchType? var updateDefaultUriMatchTypeResult: Result = .success(()) var updateDisableAutoTotpCopyValue: Bool? @@ -77,6 +79,11 @@ class MockSettingsRepository: SettingsRepository { return lastSyncTimeSubject.eraseToAnyPublisher().values } + func getSyncToAuthenticator() async throws -> Bool { + try syncToAuthenticatorResult.get() + return syncToAuthenticator + } + func updateAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool) async throws { self.allowSyncOnRefresh = allowSyncOnRefresh try allowSyncOnRefreshResult.get() @@ -97,6 +104,11 @@ class MockSettingsRepository: SettingsRepository { try updateDisableAutoTotpCopyResult.get() } + func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { + self.syncToAuthenticator = syncToAuthenticator + try syncToAuthenticatorResult.get() + } + func validatePassword(_ password: String) async throws -> Bool { validatePasswordPasswords.append(password) return try validatePasswordResult.get() diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 47379373c1..0b0642587f 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -1703,7 +1703,7 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le activeAccountIdPublisher().flatMap { userId in self.syncToAuthenticatorByUserIdSubject.map { values in let userValue = if let userId { - values[userId] ?? self.appSettingsStore.connectToWatch(userId: userId) + values[userId] ?? self.appSettingsStore.syncToAuthenticator(userId: userId) } else { false } diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 047286aeac..20d5929be2 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -757,6 +757,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertFalse(value) } + /// `getSyncToAuthenticator()` returns the sync to authenticator value for the active account. + func test_getSyncToAuthenticator() async throws { + await subject.addAccount(.fixture()) + appSettingsStore.syncToAuthenticatorByUserId["1"] = true + let value = try await subject.getSyncToAuthenticator() + XCTAssertTrue(value) + } + /// `.getTimeoutAction(userId:)` returns the session timeout action. func test_getTimeoutAction() async throws { try await subject.setTimeoutAction(action: .logout, userId: "1") @@ -1652,6 +1660,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertTrue(appSettingsStore.disableWebIcons) } + /// `setSyncToAuthenticator(_:userId:)` sets the sync to authenticator value for a user. + func test_setSyncToAuthenticator() async throws { + await subject.addAccount(.fixture()) + + try await subject.setSyncToAuthenticator(true) + XCTAssertTrue(appSettingsStore.syncToAuthenticator(userId: "1")) + } + /// `setTwoFactorToken(_:email:)` sets the two-factor code for the email. func test_setTwoFactorToken() async { await subject.setTwoFactorToken("yay_you_win!", email: "winner@email.com") @@ -1720,6 +1736,64 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertEqual(appSettingsStore.usesKeyConnector["1"], true) } + /// `syncToAuthenticatorPublisher()` returns a publisher for the user's sync to authenticator settings. + func test_syncToAuthenticatorPublisher() async throws { + await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) + + var publishedValues = [SyncToAuthenticatorValue]() + let publisher = await subject.syncToAuthenticatorPublisher() + .sink(receiveValue: { userId, shouldSync in + publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + }) + defer { publisher.cancel() } + + try await subject.setSyncToAuthenticator(true) + + XCTAssertEqual( + publishedValues, + [ + SyncToAuthenticatorValue(userId: "1", shouldSync: false), + SyncToAuthenticatorValue(userId: "1", shouldSync: true), + ] + ) + } + + /// `syncToAuthenticatorPublisher()` gets the initial stored value if a cached value doesn't exist. + func test_syncToAuthenticatorPublisher_fetchesInitialValue() async throws { + await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) + + appSettingsStore.syncToAuthenticatorByUserId["1"] = true + + var publishedValues = [SyncToAuthenticatorValue]() + let publisher = await subject.syncToAuthenticatorPublisher() + .sink(receiveValue: { userId, shouldSync in + publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + }) + defer { publisher.cancel() } + + try await subject.setSyncToAuthenticator(false) + + XCTAssertEqual( + publishedValues, + [ + SyncToAuthenticatorValue(userId: "1", shouldSync: true), + SyncToAuthenticatorValue(userId: "1", shouldSync: false), + ] + ) + } + + /// `syncToAuthenticatorPublisher()` returns false if the user is not logged in. + func test_syncToAuthenticatorPublisher_notLoggedIn() async throws { + var publishedValues = [SyncToAuthenticatorValue]() + let publisher = await subject.syncToAuthenticatorPublisher() + .sink(receiveValue: { userId, shouldSync in + publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + }) + defer { publisher.cancel() } + + XCTAssertEqual(publishedValues, [SyncToAuthenticatorValue(userId: nil, shouldSync: false)]) + } + /// `.setActiveAccount(userId:)` sets the action that occurs when there's a session timeout. func test_setTimeoutAction() async throws { let account = Account.fixture() @@ -1809,4 +1883,9 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body private struct ConnectToWatchValue: Equatable { let userId: String? let shouldConnect: Bool +} + +private struct SyncToAuthenticatorValue: Equatable { + let userId: String? + let shouldSync: Bool } // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index e02bac120d..a472a10043 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -702,6 +702,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `syncToAuthenticator(userId:)` returns false if there isn't a previously stored value. + func test_syncToAuthenticator_isInitiallyFalse() { + XCTAssertFalse(subject.syncToAuthenticator(userId: "0")) + } + + /// `syncToAuthenticator(userId:)` can be used to get the sync to authenticator value for a user. + func test_syncToAuthenticator_withValue() { + subject.setSyncToAuthenticator(true, userId: "1") + subject.setSyncToAuthenticator(false, userId: "2") + + XCTAssertTrue(subject.syncToAuthenticator(userId: "1")) + XCTAssertFalse(subject.syncToAuthenticator(userId: "2")) + XCTAssertTrue(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_1")) + XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_2")) + } + /// `twoFactorToken(email:)` returns `nil` if there isn't a previously stored value. func test_twoFactorToken_isInitiallyNil() { XCTAssertNil(subject.twoFactorToken(email: "anything@email.com")) diff --git a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index 8c58dbec83..d3fd9b077d 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -39,6 +39,7 @@ class MockAppSettingsStore: AppSettingsStore { var pinProtectedUserKey = [String: String]() var serverConfig = [String: ServerConfig]() var shouldTrustDevice = [String: Bool?]() + var syncToAuthenticatorByUserId = [String: Bool]() var timeoutAction = [String: Int]() var twoFactorTokens = [String: String]() var usesKeyConnector = [String: Bool]() @@ -210,6 +211,10 @@ class MockAppSettingsStore: AppSettingsStore { self.shouldTrustDevice[userId] = shouldTrustDevice } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) { + syncToAuthenticatorByUserId[userId] = syncToAuthenticator + } + func setTimeoutAction(key: SessionTimeoutAction, userId: String) { timeoutAction[userId] = key.rawValue } @@ -242,6 +247,10 @@ class MockAppSettingsStore: AppSettingsStore { shouldTrustDevice[userId] ?? false } + func syncToAuthenticator(userId: String) -> Bool { + syncToAuthenticatorByUserId[userId] ?? false + } + func timeoutAction(userId: String) -> Int? { timeoutAction[userId] } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift index ee130b2733..542f771c24 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -67,6 +67,9 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt var setBiometricAuthenticationEnabledResult: Result = .success(()) var setBiometricIntegrityStateError: Error? var shouldTrustDevice = [String: Bool?]() + var syncToAuthenticatorByUserId = [String: Bool]() + var syncToAuthenticatorResult: Result = .success(()) + var syncToAuthenticatorSubject = CurrentValueSubject<(String?, Bool), Never>((nil, false)) var twoFactorTokens = [String: String]() var unsuccessfulUnlockAttempts = [String: Int]() var updateProfileResponse: ProfileResponseModel? @@ -272,6 +275,12 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt showWebIcons } + func getSyncToAuthenticator(userId: String?) async throws -> Bool { + try syncToAuthenticatorResult.get() + let userId = try unwrapUserId(userId) + return syncToAuthenticatorByUserId[userId] ?? false + } + func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction { let userId = try unwrapUserId(userId) return timeoutAction[userId] ?? .lock @@ -496,6 +505,12 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt self.showWebIcons = showWebIcons } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws { + try syncToAuthenticatorResult.get() + let userId = try unwrapUserId(userId) + syncToAuthenticatorByUserId[userId] = syncToAuthenticator + } + func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws { let userId = try unwrapUserId(userId) timeoutAction[userId] = action @@ -587,6 +602,10 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt func showWebIconsPublisher() async -> AnyPublisher { showWebIconsSubject.eraseToAnyPublisher() } + + func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> { + syncToAuthenticatorSubject.eraseToAnyPublisher() + } } // MARK: Biometrics From f4fbc1667ca5eb76371c17c9e0f08c5461de6672 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 19 Sep 2024 10:19:43 -0400 Subject: [PATCH 17/52] Checking in progress on tests and subscribing to sync setting updates --- .../Services/AuthenticatorSyncService.swift | 76 ++++++++++++++----- .../AuthenticatorSyncServiceTests.swift | 35 ++++++++- .../MockNotificationCenterService.swift | 8 +- 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index a6281ad7a1..d2282dc318 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -47,6 +47,10 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// The service used by the application to manage account state. private let stateService: StateService + + /// a Task that subscribes to the sync setting publisher for accounts. This allows us to take action once + /// a user opts-in to Authenticator sync. + public var syncSettingSubscriberTask: Task? /// The service used by the application to manage vault access. private let vaultTimeoutService: VaultTimeoutService @@ -94,7 +98,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { Task { if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, - defaultValue: true) { + defaultValue: false) { subscribeToAppState() } } @@ -179,30 +183,68 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// Sync to Authenticator for all unlocked accounts. /// private func syncToAuthenticator() { - Task { - for account in try await stateService.getAccounts() { - let userId = account.profile.userId + syncSettingSubscriberTask = Task { + for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { + guard let userId else { continue } + do { - Logger.application.log("#### sync is on for userId: \(userId)") - guard let app = application as? UIApplication else { return } - if await app.applicationState == .background { - Logger.application.log("#### App in background. Wait for foreground.") - } else if vaultTimeoutService.isLocked(userId: userId) { - Logger.application.log( - "#### App in foreground and locked for \(userId). Waiting for unlock to occur." - ) - break + Logger.application.log("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") + if shouldSync { + Logger.application.log("#### sync is on for userId: \(userId)") + guard let app = application as? UIApplication else { return } + if await app.applicationState == .background { + Logger.application.log("#### App in background. Subscribing to cipher updates from push notifications.") + subscribeToCipherUpdates(userId: userId) + } else if vaultTimeoutService.isLocked(userId: userId) { + Logger.application.log("#### App in foreground and locked for \(userId). Waiting for unlock to occur.") +// subscribeToVaultPublisher() + break + } else { + Logger.application.log("#### App in foreground and unlocked. Begin key creations.") + try await createAuthenticatorKeyIfNeeded() +// try await createAuthenticatorVaultKey(userId: userId) + Logger.application.log("#### Subscribing to cipher updates") + subscribeToCipherUpdates(userId: userId) + } } else { - Logger.application.log("#### App in foreground and unlocked. Begin key creations.") - try await createAuthenticatorKeyIfNeeded() - Logger.application.log("#### Subscribing to cipher updates") - subscribeToCipherUpdates(userId: userId) + Logger.application.log("#### sync is off for userId: \(userId)") + Logger.application.log("#### clearing data for userId: \(userId)") +// try await deleteFromAuthenticatorStore(userId: userId) +// try await deleteAuthenticatorKeyIfLast() +// try await keychainRepository.deleteAuthenticatorVaultKey(userId: userId) + Logger.application.log("#### Canceling cipher update subscription") + cipherPublisherTasks[userId]??.cancel() + cipherPublisherTasks[userId] = nil } } catch { Logger.application.log("#### Error in Auth Options publisher: \(error)") } } } +// Task { +// for account in try await stateService.getAccounts() { +// let userId = account.profile.userId +// do { +// Logger.application.log("#### sync is on for userId: \(userId)") +// guard let app = application as? UIApplication else { return } +// if await app.applicationState == .background { +// Logger.application.log("#### App in background. Wait for foreground.") +// } else if vaultTimeoutService.isLocked(userId: userId) { +// Logger.application.log( +// "#### App in foreground and locked for \(userId). Waiting for unlock to occur." +// ) +// break +// } else { +// Logger.application.log("#### App in foreground and unlocked. Begin key creations.") +// try await createAuthenticatorKeyIfNeeded() +// Logger.application.log("#### Subscribing to cipher updates") +// subscribeToCipherUpdates(userId: userId) +// } +// } catch { +// Logger.application.log("#### Error in Auth Options publisher: \(error)") +// } +// } +// } } /// Takes in a list of encrypted Ciphers, decrypts them, and writes ones with TOTP codes to the shared store. diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 99e5f7e604..93b52c4df6 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -30,6 +30,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { sharedKeychainRepository = MockSharedKeychainRepository() stateService = MockStateService() vaultTimeoutService = MockVaultTimeoutService() + configService.featureFlagsBool[.enableAuthenticatorSync] = true subject = DefaultAuthenticatorSyncService( application: application, authBridgeItemService: authBridgeItemService, @@ -62,6 +63,36 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // MARK: Tests - /// `auth(for:)` returns a new `ClientAuthProtocol` for every user. - func test_auth() async throws {} + /// Initializing the `AuthenticatorSyncService` when the `enableAuthenticatorSync` feature flag + /// is turned off should do nothing. + /// + func test_init_featureFlagOff() async throws { + subject = nil + notificationCenterService.willEnterForegroundSubscribers = 0 + configService.featureFlagsBool[.enableAuthenticatorSync] = false + subject = DefaultAuthenticatorSyncService( + application: application, + authBridgeItemService: authBridgeItemService, + cipherService: cipherService, + clientService: clientService, + configService: configService, + errorReporter: errorReporter, + notificationCenterService: notificationCenterService, + sharedKeychainRepository: sharedKeychainRepository, + stateService: stateService, + vaultTimeoutService: vaultTimeoutService + ) + XCTAssertEqual(notificationCenterService.willEnterForegroundSubscribers, 0) + notificationCenterService.willEnterForegroundSubject.send() + // TODO: Test to make sure this does nothing + } + + /// Initializing the `AuthenticatorSyncService` when the `enableAuthenticatorSync` feature flag + /// is turned on should do subscribe to foreground notifications. + /// + func test_init_featureFlagOn() async throws { + XCTAssertEqual(notificationCenterService.willEnterForegroundSubscribers, 1) + notificationCenterService.willEnterForegroundSubject.send() + // TODO: Test to make sure this does stuff + } } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift index d62a760f4d..3f2b3269f5 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift @@ -5,13 +5,17 @@ import Foundation class MockNotificationCenterService: NotificationCenterService { var didEnterBackgroundSubject = CurrentValueSubject(()) + var didEnterBackgroundSubscribers = 0 var willEnterForegroundSubject = CurrentValueSubject(()) + var willEnterForegroundSubscribers = 0 func didEnterBackgroundPublisher() -> AsyncPublisher> { - didEnterBackgroundSubject.eraseToAnyPublisher().values + didEnterBackgroundSubscribers += 1 + return didEnterBackgroundSubject.eraseToAnyPublisher().values } func willEnterForegroundPublisher() -> AsyncPublisher> { - willEnterForegroundSubject.eraseToAnyPublisher().values + willEnterForegroundSubscribers += 1 + return willEnterForegroundSubject.eraseToAnyPublisher().values } } From 3ede9382dd7bba7e9c20af7570862e25140d2439 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 17 Sep 2024 17:02:14 -0400 Subject: [PATCH 18/52] Implement encryption in the new item service --- .../AuthenticatorBridgeItemService.swift | 21 ++++++++++++++----- .../AuthenticatorBridgeItemDataTests.swift | 2 ++ .../AuthenticatorBridgeItemServiceTests.swift | 12 +++++++++++ .../MockSharedCryptographyService.swift | 9 ++++++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index e47180c6e9..26dffafad1 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -41,6 +41,9 @@ public protocol AuthenticatorBridgeItemService { /// public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { // MARK: Properties + + /// Cryptography service for encrypting/decrypting items. + let cryptoService: SharedCryptographyService /// The CoreData store for working with shared data. let dataStore: AuthenticatorBridgeDataStore @@ -56,7 +59,10 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// - dataStore: The CoreData store for working with shared data /// - sharedKeychainRepository: The keychain repository for working with the shared key. /// - init(dataStore: AuthenticatorBridgeDataStore, sharedKeychainRepository: SharedKeychainRepository) { + init(cryptoService: SharedCryptographyService, + dataStore: AuthenticatorBridgeDataStore, + sharedKeychainRepository: SharedKeychainRepository) { + self.cryptoService = cryptoService self.dataStore = dataStore self.sharedKeychainRepository = sharedKeychainRepository } @@ -78,10 +84,10 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) let result = try dataStore.backgroundContext.fetch(fetchRequest) - - return result.compactMap { data in + let encryptedItems = result.compactMap { data in data.model } + return try await cryptoService.decryptAuthenticatorItems(encryptedItems) } /// Inserts the list of items into the store for the given userId. @@ -92,8 +98,9 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) try await dataStore.executeBatchInsert( - AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + AuthenticatorBridgeItemData.batchInsertRequest(objects: encryptedItems, userId: userId) ) } @@ -105,8 +112,12 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) - let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + let insertRequest = try AuthenticatorBridgeItemData.batchInsertRequest( + objects: encryptedItems, + userId: userId + ) try await dataStore.executeBatchReplace( deleteRequest: deleteRequest, insertRequest: insertRequest diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index cb228cc6e0..24dfe6b0b1 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -16,6 +16,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { override func setUp() { super.setUp() + cryptoService = MockSharedCryptographyService() errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( errorReporter: errorReporter, @@ -23,6 +24,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { storeType: .memory ) itemService = DefaultAuthenticatorBridgeItemService( + cryptoService: cryptoService, dataStore: dataStore, sharedKeychainRepository: MockSharedKeychainRepository() ) diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index cdac503b78..1175ca499c 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -7,6 +7,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" + var cryptoService: MockSharedCryptographyService! var dataStore: AuthenticatorBridgeDataStore! var errorReporter: ErrorReporter! var keychainRepository: SharedKeychainRepository! @@ -16,6 +17,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase override func setUp() { super.setUp() + cryptoService = MockSharedCryptographyService() errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( errorReporter: errorReporter, @@ -24,12 +26,14 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase ) keychainRepository = MockSharedKeychainRepository() subject = DefaultAuthenticatorBridgeItemService( + cryptoService: cryptoService, dataStore: dataStore, sharedKeychainRepository: keychainRepository ) } override func tearDown() { + cryptoService = nil dataStore = nil errorReporter = nil keychainRepository = nil @@ -83,6 +87,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase // Fetch should return only the expectedItem let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.decryptCalled, + "Items should have been decrypted when calling fetchAllForUser!") XCTAssertNotNil(result) XCTAssertEqual(result.count, expectedItems.count) XCTAssertEqual(result, expectedItems) @@ -100,6 +106,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase try await subject.insertItems(expectedItems, forUserId: "userId") let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } @@ -132,6 +140,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) XCTAssertFalse(result.contains { $0 == initialItems.first }) } @@ -146,6 +156,8 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase let result = try await subject.fetchAllForUserId("userId") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } } diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift index f0367e071b..b850d0c1de 100644 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -4,15 +4,20 @@ import Foundation @testable import AuthenticatorBridgeKit class MockSharedCryptographyService: SharedCryptographyService { + var decryptCalled = false + var encryptCalled = false + func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataModel] { - items + decryptCalled = true + return items } func encryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataModel] { - items + encryptCalled = true + return items } } From d83f2e2fb4baf95e3d5b8073000457a1e3022995 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 19 Sep 2024 11:05:46 -0400 Subject: [PATCH 19/52] Fix missing declaration --- .../Tests/AuthenticatorBridgeItemDataTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index 24dfe6b0b1..ed549344f0 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -7,6 +7,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { // MARK: Properties let accessGroup = "group.com.example.bitwarden-authenticator" + var cryptoService: MockSharedCryptographyService! var dataStore: AuthenticatorBridgeDataStore! var errorReporter: ErrorReporter! var itemService: AuthenticatorBridgeItemService! @@ -31,6 +32,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { } override func tearDown() { + cryptoService = nil dataStore = nil errorReporter = nil subject = nil From 0c3167e74e9dd58c7f593f15df143d95432679a2 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 19 Sep 2024 11:15:23 -0400 Subject: [PATCH 20/52] Cleaned up merge issues --- .../AuthenticatorBridgeItemService.swift | 2 +- .../AuthenticatorBridgeDataStoreTests.swift | 148 ------------------ 2 files changed, 1 insertion(+), 149 deletions(-) delete mode 100644 AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 26dffafad1..bc6fb2c162 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -41,7 +41,7 @@ public protocol AuthenticatorBridgeItemService { /// public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { // MARK: Properties - + /// Cryptography service for encrypting/decrypting items. let cryptoService: SharedCryptographyService diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift deleted file mode 100644 index e3d233b1ef..0000000000 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation -import XCTest - -@testable import AuthenticatorBridgeKit - -final class AuthenticatorBridgeDataStoreTests: AuthenticatorBridgeKitTestCase { - // MARK: Properties - - let accessGroup = "group.com.example.bitwarden-authenticator" - var cryptoService: MockSharedCryptographyService! - var subject: AuthenticatorBridgeDataStore! - var error: Error? - - // MARK: Setup & Teardown - - override func setUp() { - super.setUp() - let cryptoService = MockSharedCryptographyService() - let errorHandler: (Error) -> Void = { error in - self.error = error - } - subject = AuthenticatorBridgeDataStore( - storeType: .memory, - groupIdentifier: accessGroup, - cryptoService: cryptoService, - errorHandler: errorHandler - ) - } - - override func tearDown() { - cryptoService = nil - error = nil - subject = nil - super.tearDown() - } - - // MARK: Tests - - /// Verify that the `deleteAllForUserId` method successfully deletes all of the data for a given - /// userId from the store. Verify that it does NOT delete the data for a different userId - /// - func test_deleteAllForUserId_success() async throws { - let items = AuthenticatorBridgeItemDataModel.fixtures() - - // First Insert for "userId" - try await subject.insertItems(items, forUserId: "userId") - - // Separate Insert for "differentUserId" - try await subject.insertItems(AuthenticatorBridgeItemDataModel.fixtures(), - forUserId: "differentUserId") - - // Remove the items for "differentUserId" - try await subject.deleteAllForUserId("differentUserId") - - // Verify items are removed for "differentUserId" - let deletedFetchResult = try await subject.fetchAllForUserId("differentUserId") - - XCTAssertNotNil(deletedFetchResult) - XCTAssertEqual(deletedFetchResult.count, 0) - - // Verify items are still present for "userId" - let result = try await subject.fetchAllForUserId("userId") - - XCTAssertNotNil(result) - XCTAssertEqual(result.count, items.count) - } - - /// Verify that the `fetchAllForUserId` method successfully fetches the data for the given user id, and does not - /// include data for a different user id. - /// - func test_fetchAllForUserId_success() async throws { - // Insert items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.insertItems(expectedItems, forUserId: "userId") - - // Separate Insert for "differentUserId" - let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() - try await subject.insertItems([differentUserItem], forUserId: "differentUserId") - - // Fetch should return only the expectedItem - let result = try await subject.fetchAllForUserId("userId") - - XCTAssertNotNil(result) - XCTAssertEqual(result.count, expectedItems.count) - XCTAssertEqual(result, expectedItems) - - // None of the items for userId should contain the item inserted for differentUserId - let emptyResult = result.filter { $0.id == differentUserItem.id } - XCTAssertEqual(emptyResult.count, 0) - } - - /// Verify that the `insertItems(_:forUserId:)` method successfully inserts the list of items - /// for the given user id. - /// - func test_insertItemsForUserId_success() async throws { - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.insertItems(expectedItems, forUserId: "userId") - let result = try await subject.fetchAllForUserId("userId") - - XCTAssertEqual(result, expectedItems) - } - - /// Verify the `replaceAllItems` correctly deletes all of the items in the store previously when given - /// an empty list of items to insert for the given userId. - /// - func test_replaceAllItems_emptyInsertDeletesExisting() async throws { - // Insert initial items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.insertItems(expectedItems, forUserId: "userId") - - // Replace with empty list, deleting all - try await subject.replaceAllItems(with: [], forUserId: "userId") - - let result = try await subject.fetchAllForUserId("userId") - XCTAssertEqual(result, []) - } - - /// Verify the `replaceAllItems` correctly replaces all of the items in the store previously with the new - /// list of items for the given userId - /// - func test_replaceAllItems_replacesExisting() async throws { - // Insert initial items for "userId" - let initialItems = [AuthenticatorBridgeItemDataModel.fixture()] - try await subject.insertItems(initialItems, forUserId: "userId") - - // Replace items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") - - let result = try await subject.fetchAllForUserId("userId") - - XCTAssertEqual(result, expectedItems) - XCTAssertFalse(result.contains { $0 == initialItems.first }) - } - - /// Verify the `replaceAllItems` correctly inserts items when a userId doesn't contain any - /// items in the store previously. - /// - func test_replaceAllItems_startingFromEmpty() async throws { - // Insert items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } - try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") - - let result = try await subject.fetchAllForUserId("userId") - - XCTAssertEqual(result, expectedItems) - } -} From 6bca7ab41cf26885c2a62db805d0fa55c4500f90 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 19 Sep 2024 11:24:59 -0400 Subject: [PATCH 21/52] Added doc comment for new param --- AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index bc6fb2c162..0b344677ee 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -56,6 +56,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// Initialize a `DefaultAuthenticatorBridgeItemService` /// /// - Parameters: + /// - cryptoService: Cryptography service for encrypting/decrypting items. /// - dataStore: The CoreData store for working with shared data /// - sharedKeychainRepository: The keychain repository for working with the shared key. /// From 3ecc76a90456a02e67d0135fff258fa13e6a98fb Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 19 Sep 2024 16:36:37 -0400 Subject: [PATCH 22/52] Further tests and progress --- .../Core/Platform/Services/Application.swift | 3 + .../Services/AuthenticatorSyncService.swift | 98 +++++++++---------- .../AuthenticatorSyncServiceTests.swift | 25 ++++- .../TestHelpers/MockApplication.swift | 1 + 4 files changed, 73 insertions(+), 54 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/Application.swift b/BitwardenShared/Core/Platform/Services/Application.swift index 72fbeb583d..4ab56a858b 100644 --- a/BitwardenShared/Core/Platform/Services/Application.swift +++ b/BitwardenShared/Core/Platform/Services/Application.swift @@ -3,6 +3,9 @@ import UIKit /// A protocol for the application instance (i.e. `UIApplication`). /// public protocol Application { + /// The current state of the application (i.e. foreground, inactive, background) + var applicationState: UIApplication.State {get} + /// Marks the start of a task with a custom name that should continue if the app enters the background. /// See note in `UIApplication+Application.swift` /// TODO: PM-11189 diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index d2282dc318..421c68cccd 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -47,7 +47,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// The service used by the application to manage account state. private let stateService: StateService - + /// a Task that subscribes to the sync setting publisher for accounts. This allows us to take action once /// a user opts-in to Authenticator sync. public var syncSettingSubscriberTask: Task? @@ -151,13 +151,50 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } + /// This function handles the initial syncing with the Authenticator app as well as listening for updates + /// when the user adds new items. This is called when the sync is turned on. + /// + /// - Parameter userId: The userId of the user who has turned on sync. + /// + private func handleSyncOnForUserId(_ userId: String) async throws { + Logger.application.log("#### sync is on for userId: \(userId)") + if application?.applicationState == .background { + Logger.application.log("#### App in background. Subscribing to cipher updates from push notifications.") + subscribeToCipherUpdates(userId: userId) + } else if vaultTimeoutService.isLocked(userId: userId) { + Logger.application.log("#### App in foreground and locked for \(userId). Waiting for unlock to occur.") + // subscribeToVaultPublisher() + } else { + Logger.application.log("#### App in foreground and unlocked. Begin key creations.") + try await createAuthenticatorKeyIfNeeded() + // try await createAuthenticatorVaultKey(userId: userId) + Logger.application.log("#### Subscribing to cipher updates") + subscribeToCipherUpdates(userId: userId) + } + } + + /// This function handles stopping sync and cleaning up all sync-related items when a user has turned sync Off. + /// + /// - Parameter userId: The userId of the user who has turned off sync. + /// + private func handleSyncOffForUserId(_ userId: String) async throws { + Logger.application.log("#### sync is off for userId: \(userId)") + Logger.application.log("#### clearing data for userId: \(userId)") + // try await deleteFromAuthenticatorStore(userId: userId) + // try await deleteAuthenticatorKeyIfLast() + // try await keychainRepository.deleteAuthenticatorVaultKey(userId: userId) + Logger.application.log("#### Canceling cipher update subscription") + cipherPublisherTasks[userId]??.cancel() + cipherPublisherTasks[userId] = nil + } + /// Subscribe to NotificationCenter updates about if the app is in the foreground vs. background. /// private func subscribeToAppState() { Task { for await _ in notificationCenterService.willEnterForegroundPublisher() { Logger.application.log("#### app entered foreground") - syncToAuthenticator() + subscribeToSyncToAuthenticatorSetting() } } } @@ -180,71 +217,26 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } - /// Sync to Authenticator for all unlocked accounts. + /// Subscribe to the Sync to Authenticator setting to handle when the user grants (or revokes) + /// permission to sync items to the Authenticator app. /// - private func syncToAuthenticator() { + private func subscribeToSyncToAuthenticatorSetting() { syncSettingSubscriberTask = Task { for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { guard let userId else { continue } + Logger.application.log("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") do { - Logger.application.log("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") if shouldSync { - Logger.application.log("#### sync is on for userId: \(userId)") - guard let app = application as? UIApplication else { return } - if await app.applicationState == .background { - Logger.application.log("#### App in background. Subscribing to cipher updates from push notifications.") - subscribeToCipherUpdates(userId: userId) - } else if vaultTimeoutService.isLocked(userId: userId) { - Logger.application.log("#### App in foreground and locked for \(userId). Waiting for unlock to occur.") -// subscribeToVaultPublisher() - break - } else { - Logger.application.log("#### App in foreground and unlocked. Begin key creations.") - try await createAuthenticatorKeyIfNeeded() -// try await createAuthenticatorVaultKey(userId: userId) - Logger.application.log("#### Subscribing to cipher updates") - subscribeToCipherUpdates(userId: userId) - } + try await handleSyncOnForUserId(userId) } else { - Logger.application.log("#### sync is off for userId: \(userId)") - Logger.application.log("#### clearing data for userId: \(userId)") -// try await deleteFromAuthenticatorStore(userId: userId) -// try await deleteAuthenticatorKeyIfLast() -// try await keychainRepository.deleteAuthenticatorVaultKey(userId: userId) - Logger.application.log("#### Canceling cipher update subscription") - cipherPublisherTasks[userId]??.cancel() - cipherPublisherTasks[userId] = nil + try await handleSyncOffForUserId(userId) } } catch { Logger.application.log("#### Error in Auth Options publisher: \(error)") } } } -// Task { -// for account in try await stateService.getAccounts() { -// let userId = account.profile.userId -// do { -// Logger.application.log("#### sync is on for userId: \(userId)") -// guard let app = application as? UIApplication else { return } -// if await app.applicationState == .background { -// Logger.application.log("#### App in background. Wait for foreground.") -// } else if vaultTimeoutService.isLocked(userId: userId) { -// Logger.application.log( -// "#### App in foreground and locked for \(userId). Waiting for unlock to occur." -// ) -// break -// } else { -// Logger.application.log("#### App in foreground and unlocked. Begin key creations.") -// try await createAuthenticatorKeyIfNeeded() -// Logger.application.log("#### Subscribing to cipher updates") -// subscribeToCipherUpdates(userId: userId) -// } -// } catch { -// Logger.application.log("#### Error in Auth Options publisher: \(error)") -// } -// } -// } } /// Takes in a list of encrypted Ciphers, decrypts them, and writes ones with TOTP codes to the shared store. diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 93b52c4df6..7dbb81eb7f 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -1,3 +1,4 @@ +import AuthenticatorBridgeKit import XCTest @testable import BitwardenShared @@ -82,8 +83,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService: stateService, vaultTimeoutService: vaultTimeoutService ) - XCTAssertEqual(notificationCenterService.willEnterForegroundSubscribers, 0) notificationCenterService.willEnterForegroundSubject.send() + XCTAssertEqual(notificationCenterService.willEnterForegroundSubscribers, 0) // TODO: Test to make sure this does nothing } @@ -95,4 +96,26 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { notificationCenterService.willEnterForegroundSubject.send() // TODO: Test to make sure this does stuff } + + /// When the app enters the foreground and the user has subscribed to sync, the + /// `createAuthenticatorKeyIfNeeded` method successfully creates the sync key + /// if it is not already present + /// + func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { + try sharedKeychainRepository.deleteAuthenticatorKey() + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + await stateService.addAccount(.fixture(profile: .fixture(userId: "1"))) + + try await stateService.setSyncToAuthenticator(true) + + waitFor(sharedKeychainRepository.authenticatorKey != nil) + } + + /// When the app enters the foreground and the user has subscribed to sync, the + /// `createAuthenticatorKeyIfNeeded` method successfully retrieves the key in + /// SharedKeyRepository and doesn't recreate it. + /// + func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { + } } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift index d79b46f5bb..82c6b45dfe 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift @@ -3,6 +3,7 @@ import UIKit @testable import BitwardenShared class MockApplication: Application { + var applicationState: UIApplication.State = .active var beginBackgroundTaskName: String? var beginBackgroundTaskHandler: (() -> Void)? var beginBackgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid From c165ba713a09e4db90a86e5d5a6d81979ed9c135 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 20 Sep 2024 08:51:42 -0400 Subject: [PATCH 23/52] Added new AuthenticatorBridgeItemDataView to differentiate unencrypted vs encrypted model data. Added additional crypto checks to unit tests --- .../AuthenticatorBridgeItemDataModel.swift | 20 +--------- .../AuthenticatorBridgeItemDataView.swift | 40 +++++++++++++++++++ .../AuthenticatorBridgeItemService.swift | 12 +++--- .../SharedCryptographyService.swift | 10 ++--- .../AuthenticatorBridgeItemDataTests.swift | 18 ++++++--- .../AuthenticatorBridgeItemServiceTests.swift | 18 ++++----- .../SharedCryptographyServiceTests.swift | 4 +- ...nticatorBridgeItemDataModel+Fixtures.swift | 34 ---------------- ...enticatorBridgeItemDataView+Fixtures.swift | 34 ++++++++++++++++ .../MockSharedCryptographyService.swift | 24 +++++++++-- 10 files changed, 130 insertions(+), 84 deletions(-) create mode 100644 AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift delete mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift create mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataView+Fixtures.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift index 38eb095e60..7232cb350f 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift @@ -1,6 +1,7 @@ import Foundation -/// A struct for storing information about items that are shared between the Bitwarden and Authenticator apps. +/// A struct for storing **encrypted** information about items that are shared between the Bitwarden +/// and Authenticator apps. /// public struct AuthenticatorBridgeItemDataModel: Codable, Equatable { // MARK: Properties @@ -19,21 +20,4 @@ public struct AuthenticatorBridgeItemDataModel: Codable, Equatable { /// The username of the Bitwarden account that owns this iteam. public let username: String? - - /// Initialize an `AuthenticatorBridgeItemDataModel` with the values provided. - /// - /// - Parameters: - /// - favorite: Bool indicating if this item is a favorite. - /// - id: The unique id of the item. - /// - name: The name of the item. - /// - totpKey: The TOTP key used to generate codes. - /// - username: The username of the Bitwarden account that owns this iteam. - /// - public init(favorite: Bool, id: String, name: String, totpKey: String?, username: String?) { - self.favorite = favorite - self.id = id - self.name = name - self.totpKey = totpKey - self.username = username - } } diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift new file mode 100644 index 0000000000..250edd3852 --- /dev/null +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift @@ -0,0 +1,40 @@ +import Foundation + +/// A struct for storing **unencrypted** information about items that are shared between the Bitwarden +/// and Authenticator apps. +/// +public struct AuthenticatorBridgeItemDataView: Codable, Equatable { + // MARK: Properties + + /// Bool indicating if this item is a favorite. + public let favorite: Bool + + /// The unique id of the item. + public let id: String + + /// The name of the item. + public let name: String + + /// The TOTP key used to generate codes. + public let totpKey: String? + + /// The username of the Bitwarden account that owns this iteam. + public let username: String? + + /// Initialize an `AuthenticatorBridgeItemDataModel` with the values provided. + /// + /// - Parameters: + /// - favorite: Bool indicating if this item is a favorite. + /// - id: The unique id of the item. + /// - name: The name of the item. + /// - totpKey: The TOTP key used to generate codes. + /// - username: The username of the Bitwarden account that owns this iteam. + /// + public init(favorite: Bool, id: String, name: String, totpKey: String?, username: String?) { + self.favorite = favorite + self.id = id + self.name = name + self.totpKey = totpKey + self.username = username + } +} diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 0b344677ee..e429f9777e 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -16,7 +16,7 @@ public protocol AuthenticatorBridgeItemService { /// /// - Parameter userId: the id of the user for which to fetch items. /// - func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] + func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] /// Inserts the list of items into the store for the given userId. /// @@ -24,7 +24,7 @@ public protocol AuthenticatorBridgeItemService { /// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store. /// - userId: the id of the user for which to insert the items. /// - func insertItems(_ items: [AuthenticatorBridgeItemDataModel], + func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws /// Deletes all existing items for a given user and inserts new items for the list of items provided. @@ -33,7 +33,7 @@ public protocol AuthenticatorBridgeItemService { /// - items: The new items to be inserted into the store /// - userId: The userId of the items to be removed and then replaces with items. /// - func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], + func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws } @@ -82,7 +82,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// /// - Parameter userId: the id of the user for which to fetch items. /// - public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] { let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: userId) let result = try dataStore.backgroundContext.fetch(fetchRequest) let encryptedItems = result.compactMap { data in @@ -97,7 +97,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// - items: The list of `AuthenticatorBridgeItemDataModel` to be inserted into the store. /// - userId: the id of the user for which to insert the items. /// - public func insertItems(_ items: [AuthenticatorBridgeItemDataModel], + public func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws { let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) try await dataStore.executeBatchInsert( @@ -111,7 +111,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi /// - items: The new items to be inserted into the store /// - userId: The userId of the items to be removed and then replaces with items. /// - public func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], + public func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws { let encryptedItems = try await cryptoService.encryptAuthenticatorItems(items) let deleteRequest = AuthenticatorBridgeItemData.deleteByUserIdRequest(userId: userId) diff --git a/AuthenticatorBridgeKit/SharedCryptographyService.swift b/AuthenticatorBridgeKit/SharedCryptographyService.swift index 7f1aff293f..de5eba78d0 100644 --- a/AuthenticatorBridgeKit/SharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/SharedCryptographyService.swift @@ -17,7 +17,7 @@ public protocol SharedCryptographyService: AnyObject { /// func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataView] /// Takes an array of `AuthenticatorBridgeItemDataModel` with decrypted data and /// returns the list with each member encrypted. @@ -28,7 +28,7 @@ public protocol SharedCryptographyService: AnyObject { /// key is not in the shared repository. /// func encryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] + _ items: [AuthenticatorBridgeItemDataView] ) async throws -> [AuthenticatorBridgeItemDataModel] } @@ -56,12 +56,12 @@ public class DefaultAuthenticatorCryptographyService: SharedCryptographyService public func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] { + ) async throws -> [AuthenticatorBridgeItemDataView] { let key = try await sharedKeychainRepository.getAuthenticatorKey() let symmetricKey = SymmetricKey(data: key) return items.map { item in - AuthenticatorBridgeItemDataModel( + AuthenticatorBridgeItemDataView( favorite: item.favorite, id: item.id, name: (try? decrypt(item.name, withKey: symmetricKey)) ?? "", @@ -72,7 +72,7 @@ public class DefaultAuthenticatorCryptographyService: SharedCryptographyService } public func encryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] + _ items: [AuthenticatorBridgeItemDataView] ) async throws -> [AuthenticatorBridgeItemDataModel] { let key = try await sharedKeychainRepository.getAuthenticatorKey() let symmetricKey = SymmetricKey(data: key) diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index ed549344f0..c9a5d189f5 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -62,7 +62,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { /// Verify that the fetchById request correctly returns an empty list when no item matches the given userId and id. /// func test_fetchByIdRequest_empty() async throws { - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() + let expectedItems = AuthenticatorBridgeItemDataView.fixtures() try await itemService.insertItems(expectedItems, forUserId: "userId") let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: "bad id", userId: "userId") @@ -75,9 +75,10 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { /// Verify that the fetchById request correctly finds the item with the given userId and id. /// func test_fetchByIdRequest_success() async throws { - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures() + let expectedItems = AuthenticatorBridgeItemDataView.fixtures() let expectedItem = expectedItems[3] try await itemService.insertItems(expectedItems, forUserId: "userId") + XCTAssertTrue(cryptoService.encryptCalled) let fetchRequest = AuthenticatorBridgeItemData.fetchByIdRequest(id: expectedItem.id, userId: "userId") let result = try dataStore.persistentContainer.viewContext.fetch(fetchRequest) @@ -85,7 +86,8 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { XCTAssertNotNil(result) XCTAssertEqual(result.count, 1) - let item = try XCTUnwrap(result.first?.model) + let decrypted = try await cryptoService.decryptAuthenticatorItems(result.compactMap(\.model)) + let item = try XCTUnwrap(decrypted.first) XCTAssertEqual(item, expectedItem) } @@ -93,8 +95,9 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { /// items for the given userId /// func test_fetchByUserIdRequest_empty() async throws { - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await itemService.insertItems(expectedItems, forUserId: "userId") + XCTAssertTrue(cryptoService.encryptCalled) let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest( userId: "nonexistent userId" @@ -110,12 +113,15 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { /// func test_fetchByUserIdRequest_success() async throws { // Insert items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await itemService.insertItems(expectedItems, forUserId: "userId") + XCTAssertTrue(cryptoService.encryptCalled) // Separate Insert for "differentUserId" - let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() + cryptoService.encryptCalled = false + let differentUserItem = AuthenticatorBridgeItemDataView.fixture() try await itemService.insertItems([differentUserItem], forUserId: "differentUserId") + XCTAssertTrue(cryptoService.encryptCalled) // Verify items returned for "userId" do not contain items from "differentUserId" let fetchRequest = AuthenticatorBridgeItemData.fetchByUserIdRequest(userId: "userId") diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 1175ca499c..59db1c7a16 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -47,13 +47,13 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// userId from the store. Verify that it does NOT delete the data for a different userId /// func test_deleteAllForUserId_success() async throws { - let items = AuthenticatorBridgeItemDataModel.fixtures() + let items = AuthenticatorBridgeItemDataView.fixtures() // First Insert for "userId" try await subject.insertItems(items, forUserId: "userId") // Separate Insert for "differentUserId" - try await subject.insertItems(AuthenticatorBridgeItemDataModel.fixtures(), + try await subject.insertItems(AuthenticatorBridgeItemDataView.fixtures(), forUserId: "differentUserId") // Remove the items for "differentUserId" @@ -77,11 +77,11 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// func test_fetchAllForUserId_success() async throws { // Insert items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await subject.insertItems(expectedItems, forUserId: "userId") // Separate Insert for "differentUserId" - let differentUserItem = AuthenticatorBridgeItemDataModel.fixture() + let differentUserItem = AuthenticatorBridgeItemDataView.fixture() try await subject.insertItems([differentUserItem], forUserId: "differentUserId") // Fetch should return only the expectedItem @@ -102,7 +102,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// for the given user id. /// func test_insertItemsForUserId_success() async throws { - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await subject.insertItems(expectedItems, forUserId: "userId") let result = try await subject.fetchAllForUserId("userId") @@ -116,7 +116,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// func test_replaceAllItems_emptyInsertDeletesExisting() async throws { // Insert initial items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await subject.insertItems(expectedItems, forUserId: "userId") // Replace with empty list, deleting all @@ -131,11 +131,11 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// func test_replaceAllItems_replacesExisting() async throws { // Insert initial items for "userId" - let initialItems = [AuthenticatorBridgeItemDataModel.fixture()] + let initialItems = [AuthenticatorBridgeItemDataView.fixture()] try await subject.insertItems(initialItems, forUserId: "userId") // Replace items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") let result = try await subject.fetchAllForUserId("userId") @@ -151,7 +151,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// func test_replaceAllItems_startingFromEmpty() async throws { // Insert items for "userId" - let expectedItems = AuthenticatorBridgeItemDataModel.fixtures().sorted { $0.id < $1.id } + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await subject.replaceAllItems(with: expectedItems, forUserId: "userId") let result = try await subject.fetchAllForUserId("userId") diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift index c8ce092cf9..4d415de90a 100644 --- a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift @@ -7,7 +7,7 @@ import XCTest final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { // MARK: Properties - let items: [AuthenticatorBridgeItemDataModel] = AuthenticatorBridgeItemDataModel.fixtures() + let items: [AuthenticatorBridgeItemDataView] = AuthenticatorBridgeItemDataView.fixtures() var sharedKeychainRepository: MockSharedKeychainRepository! var subject: SharedCryptographyService! @@ -48,7 +48,7 @@ final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { try sharedKeychainRepository.deleteAuthenticatorKey() await assertAsyncThrows(error: error) { - _ = try await subject.decryptAuthenticatorItems(items) + _ = try await subject.decryptAuthenticatorItems([]) } } diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift deleted file mode 100644 index 82c2bbc5e9..0000000000 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -@testable import AuthenticatorBridgeKit - -extension AuthenticatorBridgeItemDataModel { - static func fixture( - favorite: Bool = false, - id: String = UUID().uuidString, - name: String = "Name", - totpKey: String? = nil, - username: String? = nil - ) -> AuthenticatorBridgeItemDataModel { - AuthenticatorBridgeItemDataModel( - favorite: favorite, - id: id, - name: name, - totpKey: totpKey, - username: username - ) - } - - static func fixtures() -> [AuthenticatorBridgeItemDataModel] { - [ - AuthenticatorBridgeItemDataModel.fixture(), - AuthenticatorBridgeItemDataModel.fixture(favorite: true), - AuthenticatorBridgeItemDataModel.fixture(totpKey: "TOTP Key"), - AuthenticatorBridgeItemDataModel.fixture(username: "Username"), - AuthenticatorBridgeItemDataModel.fixture(totpKey: "TOTP Key", username: "Username"), - AuthenticatorBridgeItemDataModel.fixture(totpKey: ""), - AuthenticatorBridgeItemDataModel.fixture(username: ""), - AuthenticatorBridgeItemDataModel.fixture(totpKey: "", username: ""), - ] - } -} diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataView+Fixtures.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataView+Fixtures.swift new file mode 100644 index 0000000000..ec516431a4 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataView+Fixtures.swift @@ -0,0 +1,34 @@ +import Foundation + +@testable import AuthenticatorBridgeKit + +extension AuthenticatorBridgeItemDataView { + static func fixture( + favorite: Bool = false, + id: String = UUID().uuidString, + name: String = "Name", + totpKey: String? = nil, + username: String? = nil + ) -> AuthenticatorBridgeItemDataView { + AuthenticatorBridgeItemDataView( + favorite: favorite, + id: id, + name: name, + totpKey: totpKey, + username: username + ) + } + + static func fixtures() -> [AuthenticatorBridgeItemDataView] { + [ + AuthenticatorBridgeItemDataView.fixture(), + AuthenticatorBridgeItemDataView.fixture(favorite: true), + AuthenticatorBridgeItemDataView.fixture(totpKey: "TOTP Key"), + AuthenticatorBridgeItemDataView.fixture(username: "Username"), + AuthenticatorBridgeItemDataView.fixture(totpKey: "TOTP Key", username: "Username"), + AuthenticatorBridgeItemDataView.fixture(totpKey: ""), + AuthenticatorBridgeItemDataView.fixture(username: ""), + AuthenticatorBridgeItemDataView.fixture(totpKey: "", username: ""), + ] + } +} diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift index b850d0c1de..6d6a08d5e3 100644 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -9,15 +9,31 @@ class MockSharedCryptographyService: SharedCryptographyService { func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] - ) async throws -> [AuthenticatorBridgeItemDataModel] { + ) async throws -> [AuthenticatorBridgeItemDataView] { decryptCalled = true - return items + return items.map { model in + AuthenticatorBridgeItemDataView( + favorite: model.favorite, + id: model.id, + name: model.name, + totpKey: model.totpKey, + username: model.username + ) + } } func encryptAuthenticatorItems( - _ items: [AuthenticatorBridgeItemDataModel] + _ items: [AuthenticatorBridgeItemDataView] ) async throws -> [AuthenticatorBridgeItemDataModel] { encryptCalled = true - return items + return items.map { view in + AuthenticatorBridgeItemDataModel( + favorite: view.favorite, + id: view.id, + name: view.name, + totpKey: view.totpKey, + username: view.username + ) + } } } From 7122d7d33ae9186417ac719fbf442cd87a317754 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 20 Sep 2024 09:40:08 -0400 Subject: [PATCH 24/52] Fix typo in doc comment --- AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift index 250edd3852..0dd6d3521c 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift @@ -21,14 +21,14 @@ public struct AuthenticatorBridgeItemDataView: Codable, Equatable { /// The username of the Bitwarden account that owns this iteam. public let username: String? - /// Initialize an `AuthenticatorBridgeItemDataModel` with the values provided. + /// Initialize an `AuthenticatorBridgeItemDataView` with the values provided. /// /// - Parameters: /// - favorite: Bool indicating if this item is a favorite. /// - id: The unique id of the item. /// - name: The name of the item. /// - totpKey: The TOTP key used to generate codes. - /// - username: The username of the Bitwarden account that owns this iteam. + /// - username: The username of the Bitwarden account that owns this item. /// public init(favorite: Bool, id: String, name: String, totpKey: String?, username: String?) { self.favorite = favorite From e129b11a8f2c97d9354810cd11d7fbb4da770753 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 20 Sep 2024 10:37:38 -0400 Subject: [PATCH 25/52] [BITAU-149] Add Setting to Turn On Authenticator Syncing for an Account --- .../Repositories/SettingsRepository.swift | 18 +++++ .../SettingsRepositoryTests.swift | 27 +++++++ .../TestHelpers/MockSettingsRepository.swift | 12 +++ .../Core/Platform/Services/StateService.swift | 66 ++++++++++++++++ .../Platform/Services/StateServiceTests.swift | 79 +++++++++++++++++++ .../Services/Stores/AppSettingsStore.swift | 27 +++++++ .../Stores/AppSettingsStoreTests.swift | 16 ++++ .../TestHelpers/MockAppSettingsStore.swift | 9 +++ .../TestHelpers/MockStateService.swift | 19 +++++ 9 files changed, 273 insertions(+) diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift index 29858a6983..0fcac2537c 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift @@ -46,6 +46,10 @@ protocol SettingsRepository: AnyObject { /// func getDisableAutoTotpCopy() async throws -> Bool + /// Get the current value of the sync to Authenticator setting. + /// + func getSyncToAuthenticator() async throws -> Bool + /// A publisher for the last sync time. /// /// - Returns: A publisher for the last sync time. @@ -76,6 +80,12 @@ protocol SettingsRepository: AnyObject { /// func updateDisableAutoTotpCopy(_ disableAutoTotpCopy: Bool) async throws + /// Update the cached value of the sync to authenticator setting. + /// + /// - Parameter connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// + func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws + // MARK: Publishers /// The publisher to keep track of the list of the user's current folders. @@ -181,6 +191,10 @@ extension DefaultSettingsRepository: SettingsRepository { try await stateService.getDisableAutoTotpCopy() } + func getSyncToAuthenticator() async throws -> Bool { + try await stateService.getSyncToAuthenticator() + } + func lastSyncTimePublisher() async throws -> AsyncPublisher> { try await stateService.lastSyncTimePublisher().values } @@ -201,6 +215,10 @@ extension DefaultSettingsRepository: SettingsRepository { try await stateService.setDisableAutoTotpCopy(disableAutoTotpCopy) } + func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { + try await stateService.setSyncToAuthenticator(syncToAuthenticator) + } + // MARK: Publishers func foldersListPublisher() async throws -> AsyncThrowingPublisher> { diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift index c49ba9e00f..a9f0b3be11 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepositoryTests.swift @@ -137,6 +137,19 @@ class SettingsRepositoryTests: BitwardenTestCase { XCTAssertTrue(value) } + /// `getSyncToAuthenticator()` returns the expected value. + func test_getSyncToAuthenticator() async throws { + stateService.activeAccount = .fixture() + + // Defaults to false if no value is set. + var value = try await subject.getSyncToAuthenticator() + XCTAssertFalse(value) + + stateService.syncToAuthenticatorByUserId["1"] = true + value = try await subject.getSyncToAuthenticator() + XCTAssertTrue(value) + } + /// `fetchSync()` throws an error if syncing fails. func test_fetchSync_error() async throws { syncService.fetchSyncResult = .failure(BitwardenTestError.example) @@ -229,4 +242,18 @@ class SettingsRepositoryTests: BitwardenTestCase { try XCTAssertTrue(XCTUnwrap(stateService.disableAutoTotpCopyByUserId["1"])) } + + /// `updateSyncToAuthenticator()` updates the value in the state service. + func test_updateSyncToAuthenticator() async throws { + stateService.activeAccount = .fixture() + + // The value should start off with a default of false. + var value = try await stateService.getSyncToAuthenticator() + XCTAssertFalse(value) + + // Set the value and ensure it updates. + try await subject.updateSyncToAuthenticator(true) + value = try await stateService.getSyncToAuthenticator() + XCTAssertTrue(value) + } } diff --git a/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift index 4625630713..1325487268 100644 --- a/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/TestHelpers/MockSettingsRepository.swift @@ -22,6 +22,8 @@ class MockSettingsRepository: SettingsRepository { var getDisableAutoTotpCopyResult: Result = .success(false) var lastSyncTimeError: Error? var lastSyncTimeSubject = CurrentValueSubject(nil) + var syncToAuthenticator = false + var syncToAuthenticatorResult: Result = .success(()) var updateDefaultUriMatchTypeValue: BitwardenShared.UriMatchType? var updateDefaultUriMatchTypeResult: Result = .success(()) var updateDisableAutoTotpCopyValue: Bool? @@ -77,6 +79,11 @@ class MockSettingsRepository: SettingsRepository { return lastSyncTimeSubject.eraseToAnyPublisher().values } + func getSyncToAuthenticator() async throws -> Bool { + try syncToAuthenticatorResult.get() + return syncToAuthenticator + } + func updateAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool) async throws { self.allowSyncOnRefresh = allowSyncOnRefresh try allowSyncOnRefreshResult.get() @@ -97,6 +104,11 @@ class MockSettingsRepository: SettingsRepository { try updateDisableAutoTotpCopyResult.get() } + func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { + self.syncToAuthenticator = syncToAuthenticator + try syncToAuthenticatorResult.get() + } + func validatePassword(_ password: String) async throws -> Bool { validatePasswordPasswords.append(password) return try validatePasswordResult.get() diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 6774b9dab9..3ebc9790d4 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -268,6 +268,14 @@ protocol StateService: AnyObject { /// func getShowWebIcons() async -> Bool + /// Gets the sync to Authenticator value for an account. + /// + /// - Parameter userId: The user ID associated with the sync to Authenticator value. Defaults to the active + /// account if `nil` + /// - Returns: Whether to sync TOPT codes to the Authenticator app. + /// + func getSyncToAuthenticator(userId: String?) async throws -> Bool + /// Gets the session timeout action. /// /// - Parameter userId: The user ID for the account. @@ -573,6 +581,14 @@ protocol StateService: AnyObject { /// func setShowWebIcons(_ showWebIcons: Bool) async + /// Sets the sync to authenticator value for an account. + /// + /// - Parameters: + /// - connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - userId: The user ID of the account. Defaults to the active account if `nil`. + /// + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws + /// Sets the session timeout action. /// /// - Parameters: @@ -665,6 +681,12 @@ protocol StateService: AnyObject { /// - Returns: A publisher for whether or not to show the web icons. /// func showWebIconsPublisher() async -> AnyPublisher + + /// A publisher for the sync to authenticator value. + /// + /// - Returns: A publisher for the sync to authenticator value. + /// + func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> } extension StateService { @@ -834,6 +856,14 @@ extension StateService { try await getServerConfig(userId: nil) } + /// Gets the sync to authenticator value for the active account. + /// + /// - Returns: Whether to sync TOTP codes to the Authenticator app. + /// + func getSyncToAuthenticator() async throws -> Bool { + try await getSyncToAuthenticator(userId: nil) + } + /// Gets the session timeout action. /// /// - Returns: The action to perform when a session timeout occurs. @@ -1036,6 +1066,14 @@ extension StateService { try await setServerConfig(config, userId: nil) } + /// Sets the sync to authenticator value for the active account. + /// + /// - Parameter connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// + func setSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { + try await setSyncToAuthenticator(syncToAuthenticator, userId: nil) + } + /// Sets the session timeout action. /// /// - Parameter action: The action to take when the user's session times out. @@ -1143,6 +1181,9 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le /// A subject containing whether to show the website icons. private var showWebIconsSubject: CurrentValueSubject + /// A subject containing the sync to authenticator value. + private var syncToAuthenticatorByUserIdSubject = CurrentValueSubject<[String: Bool], Never>([:]) + // MARK: Initialization /// Initialize a `DefaultStateService`. @@ -1361,6 +1402,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le !appSettingsStore.disableWebIcons } + func getSyncToAuthenticator(userId: String?) async throws -> Bool { + let userId = try userId ?? getActiveAccountUserId() + return appSettingsStore.syncToAuthenticator(userId: userId) + } + func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction { let userId = try userId ?? getActiveAccountUserId() guard let rawValue = appSettingsStore.timeoutAction(userId: userId), @@ -1620,6 +1666,12 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le showWebIconsSubject.send(showWebIcons) } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() + appSettingsStore.setSyncToAuthenticator(syncToAuthenticator, userId: userId) + syncToAuthenticatorByUserIdSubject.value[userId] = syncToAuthenticator + } + func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws { let userId = try userId ?? getActiveAccountUserId() appSettingsStore.setTimeoutAction(key: action, userId: userId) @@ -1713,6 +1765,20 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le showWebIconsSubject.eraseToAnyPublisher() } + func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> { + activeAccountIdPublisher().flatMap { userId in + self.syncToAuthenticatorByUserIdSubject.map { values in + let userValue = if let userId { + values[userId] ?? self.appSettingsStore.syncToAuthenticator(userId: userId) + } else { + false + } + return (userId, userValue) + } + } + .eraseToAnyPublisher() + } + // MARK: Private /// Returns the user ID for the active account. diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 647611cd3f..3e9dec2b1b 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -791,6 +791,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertFalse(value) } + /// `getSyncToAuthenticator()` returns the sync to authenticator value for the active account. + func test_getSyncToAuthenticator() async throws { + await subject.addAccount(.fixture()) + appSettingsStore.syncToAuthenticatorByUserId["1"] = true + let value = try await subject.getSyncToAuthenticator() + XCTAssertTrue(value) + } + /// `.getTimeoutAction(userId:)` returns the session timeout action. func test_getTimeoutAction() async throws { try await subject.setTimeoutAction(action: .logout, userId: "1") @@ -1705,6 +1713,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertTrue(appSettingsStore.disableWebIcons) } + /// `setSyncToAuthenticator(_:userId:)` sets the sync to authenticator value for a user. + func test_setSyncToAuthenticator() async throws { + await subject.addAccount(.fixture()) + + try await subject.setSyncToAuthenticator(true) + XCTAssertTrue(appSettingsStore.syncToAuthenticator(userId: "1")) + } + /// `setTwoFactorToken(_:email:)` sets the two-factor code for the email. func test_setTwoFactorToken() async { await subject.setTwoFactorToken("yay_you_win!", email: "winner@email.com") @@ -1773,6 +1789,64 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body XCTAssertEqual(appSettingsStore.usesKeyConnector["1"], true) } + /// `syncToAuthenticatorPublisher()` returns a publisher for the user's sync to authenticator settings. + func test_syncToAuthenticatorPublisher() async throws { + await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) + + var publishedValues = [SyncToAuthenticatorValue]() + let publisher = await subject.syncToAuthenticatorPublisher() + .sink(receiveValue: { userId, shouldSync in + publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + }) + defer { publisher.cancel() } + + try await subject.setSyncToAuthenticator(true) + + XCTAssertEqual( + publishedValues, + [ + SyncToAuthenticatorValue(userId: "1", shouldSync: false), + SyncToAuthenticatorValue(userId: "1", shouldSync: true), + ] + ) + } + + /// `syncToAuthenticatorPublisher()` gets the initial stored value if a cached value doesn't exist. + func test_syncToAuthenticatorPublisher_fetchesInitialValue() async throws { + await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) + + appSettingsStore.syncToAuthenticatorByUserId["1"] = true + + var publishedValues = [SyncToAuthenticatorValue]() + let publisher = await subject.syncToAuthenticatorPublisher() + .sink(receiveValue: { userId, shouldSync in + publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + }) + defer { publisher.cancel() } + + try await subject.setSyncToAuthenticator(false) + + XCTAssertEqual( + publishedValues, + [ + SyncToAuthenticatorValue(userId: "1", shouldSync: true), + SyncToAuthenticatorValue(userId: "1", shouldSync: false), + ] + ) + } + + /// `syncToAuthenticatorPublisher()` returns false if the user is not logged in. + func test_syncToAuthenticatorPublisher_notLoggedIn() async throws { + var publishedValues = [SyncToAuthenticatorValue]() + let publisher = await subject.syncToAuthenticatorPublisher() + .sink(receiveValue: { userId, shouldSync in + publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + }) + defer { publisher.cancel() } + + XCTAssertEqual(publishedValues, [SyncToAuthenticatorValue(userId: nil, shouldSync: false)]) + } + /// `.setActiveAccount(userId:)` sets the action that occurs when there's a session timeout. func test_setTimeoutAction() async throws { let account = Account.fixture() @@ -1862,4 +1936,9 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body private struct ConnectToWatchValue: Equatable { let userId: String? let shouldConnect: Bool +} + +private struct SyncToAuthenticatorValue: Equatable { + let userId: String? + let shouldSync: Bool } // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index c80533effe..6730c3bacf 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -389,6 +389,14 @@ protocol AppSettingsStore: AnyObject { /// func setShouldTrustDevice(shouldTrustDevice: Bool?, userId: String) + /// Sets the sync to Authenticator setting for the user. + /// + /// - Parameters: + /// - connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - userId: The user ID associated with the sync to Authenticator value. + /// + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) + /// Sets the user's timeout action. /// /// - Parameters: @@ -443,6 +451,14 @@ protocol AppSettingsStore: AnyObject { /// func shouldTrustDevice(userId: String) -> Bool? + /// Gets the sync to Authenticator setting for the user. + /// + /// - Parameter userId: The user ID associated with the sync to Authenticator value. + /// + /// - Returns: Whether to sync TOTP codes with the Authenticator app. + /// + func syncToAuthenticator(userId: String) -> Bool + /// Returns the action taken upon a session timeout. /// /// - Parameter userId: The user ID associated with the session timeout action. @@ -645,6 +661,7 @@ extension DefaultAppSettingsStore: AppSettingsStore { case rememberedOrgIdentifier case serverConfig(userId: String) case shouldTrustDevice(userId: String) + case syncToAuthenticator(userId: String) case state case twoFactorToken(email: String) case unsuccessfulUnlockAttempts(userId: String) @@ -731,6 +748,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "shouldTrustDevice_\(userId)" case .state: key = "state" + case let .syncToAuthenticator(userId): + key = "shouldSyncToAuthenticator_\(userId)" case let .twoFactorToken(email): key = "twoFactorToken_\(email)" case let .unsuccessfulUnlockAttempts(userId): @@ -1015,6 +1034,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { store(shouldTrustDevice, for: .shouldTrustDevice(userId: userId)) } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) { + store(syncToAuthenticator, for: .syncToAuthenticator(userId: userId)) + } + func setTimeoutAction(key: SessionTimeoutAction, userId: String) { store(key, for: .vaultTimeoutAction(userId: userId)) } @@ -1035,6 +1058,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { store(minutes, for: .vaultTimeout(userId: userId)) } + func syncToAuthenticator(userId: String) -> Bool { + fetch(for: .syncToAuthenticator(userId: userId)) + } + func timeoutAction(userId: String) -> Int? { fetch(for: .vaultTimeoutAction(userId: userId)) } diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index d7804b1750..cd45dd46da 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -754,6 +754,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_ ) } + /// `syncToAuthenticator(userId:)` returns false if there isn't a previously stored value. + func test_syncToAuthenticator_isInitiallyFalse() { + XCTAssertFalse(subject.syncToAuthenticator(userId: "0")) + } + + /// `syncToAuthenticator(userId:)` can be used to get the sync to authenticator value for a user. + func test_syncToAuthenticator_withValue() { + subject.setSyncToAuthenticator(true, userId: "1") + subject.setSyncToAuthenticator(false, userId: "2") + + XCTAssertTrue(subject.syncToAuthenticator(userId: "1")) + XCTAssertFalse(subject.syncToAuthenticator(userId: "2")) + XCTAssertTrue(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_1")) + XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_2")) + } + /// `twoFactorToken(email:)` returns `nil` if there isn't a previously stored value. func test_twoFactorToken_isInitiallyNil() { XCTAssertNil(subject.twoFactorToken(email: "anything@email.com")) diff --git a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index 857a4db2ab..4ad4bf03eb 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -41,6 +41,7 @@ class MockAppSettingsStore: AppSettingsStore { var accountCreationEnvironmentUrls = [String: EnvironmentUrlData]() var serverConfig = [String: ServerConfig]() var shouldTrustDevice = [String: Bool?]() + var syncToAuthenticatorByUserId = [String: Bool]() var timeoutAction = [String: Int]() var twoFactorTokens = [String: String]() var usesKeyConnector = [String: Bool]() @@ -228,6 +229,10 @@ class MockAppSettingsStore: AppSettingsStore { self.shouldTrustDevice[userId] = shouldTrustDevice } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) { + syncToAuthenticatorByUserId[userId] = syncToAuthenticator + } + func setTimeoutAction(key: SessionTimeoutAction, userId: String) { timeoutAction[userId] = key.rawValue } @@ -260,6 +265,10 @@ class MockAppSettingsStore: AppSettingsStore { shouldTrustDevice[userId] ?? false } + func syncToAuthenticator(userId: String) -> Bool { + syncToAuthenticatorByUserId[userId] ?? false + } + func timeoutAction(userId: String) -> Int? { timeoutAction[userId] } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift index 9e74c6afe3..79d5ceb208 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -69,6 +69,9 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt var setBiometricAuthenticationEnabledResult: Result = .success(()) var setBiometricIntegrityStateError: Error? var shouldTrustDevice = [String: Bool?]() + var syncToAuthenticatorByUserId = [String: Bool]() + var syncToAuthenticatorResult: Result = .success(()) + var syncToAuthenticatorSubject = CurrentValueSubject<(String?, Bool), Never>((nil, false)) var twoFactorTokens = [String: String]() var unsuccessfulUnlockAttempts = [String: Int]() var updateProfileResponse: ProfileResponseModel? @@ -283,6 +286,12 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt showWebIcons } + func getSyncToAuthenticator(userId: String?) async throws -> Bool { + try syncToAuthenticatorResult.get() + let userId = try unwrapUserId(userId) + return syncToAuthenticatorByUserId[userId] ?? false + } + func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction { let userId = try unwrapUserId(userId) return timeoutAction[userId] ?? .lock @@ -516,6 +525,12 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt self.showWebIcons = showWebIcons } + func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws { + try syncToAuthenticatorResult.get() + let userId = try unwrapUserId(userId) + syncToAuthenticatorByUserId[userId] = syncToAuthenticator + } + func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws { let userId = try unwrapUserId(userId) timeoutAction[userId] = action @@ -607,6 +622,10 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt func showWebIconsPublisher() async -> AnyPublisher { showWebIconsSubject.eraseToAnyPublisher() } + + func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> { + syncToAuthenticatorSubject.eraseToAnyPublisher() + } } // MARK: Biometrics From d815124b3ba5d2bec8f54ef637e4c1c6e9e90251 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 20 Sep 2024 11:49:20 -0400 Subject: [PATCH 26/52] Updated doc comments --- .../Core/Platform/Repositories/SettingsRepository.swift | 2 +- BitwardenShared/Core/Platform/Services/StateService.swift | 4 ++-- .../Core/Platform/Services/Stores/AppSettingsStore.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift index 0fcac2537c..4347cf4ed8 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift @@ -82,7 +82,7 @@ protocol SettingsRepository: AnyObject { /// Update the cached value of the sync to authenticator setting. /// - /// - Parameter connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - Parameter syncToAuthenticator: Whether to sync TOTP codes to the Authenticator app. /// func updateSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 3ebc9790d4..5988901dcb 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -584,7 +584,7 @@ protocol StateService: AnyObject { /// Sets the sync to authenticator value for an account. /// /// - Parameters: - /// - connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - syncToAuthenticator: Whether to sync TOTP codes to the Authenticator app. /// - userId: The user ID of the account. Defaults to the active account if `nil`. /// func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String?) async throws @@ -1068,7 +1068,7 @@ extension StateService { /// Sets the sync to authenticator value for the active account. /// - /// - Parameter connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - Parameter syncToAuthenticator: Whether to sync TOTP codes to the Authenticator app. /// func setSyncToAuthenticator(_ syncToAuthenticator: Bool) async throws { try await setSyncToAuthenticator(syncToAuthenticator, userId: nil) diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index 6730c3bacf..06c07b82b7 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -392,7 +392,7 @@ protocol AppSettingsStore: AnyObject { /// Sets the sync to Authenticator setting for the user. /// /// - Parameters: - /// - connectToWatch: Whether to sync TOTP codes to the Authenticator app. + /// - syncToAuthenticator: Whether to sync TOTP codes to the Authenticator app. /// - userId: The user ID associated with the sync to Authenticator value. /// func setSyncToAuthenticator(_ syncToAuthenticator: Bool, userId: String) From 1462e77809d5a6bba69f67011390a3da6eff0005 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 23 Sep 2024 11:43:43 -0400 Subject: [PATCH 27/52] Added tests for most of the cases in the sync service --- .../Core/Platform/Services/Application.swift | 2 +- .../Services/AuthenticatorSyncService.swift | 24 +-- .../AuthenticatorSyncServiceTests.swift | 204 ++++++++++++++---- .../MockAuthenticatorBridgeItemService.swift | 10 +- .../MockNotificationCenterService.swift | 8 +- 5 files changed, 184 insertions(+), 64 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/Application.swift b/BitwardenShared/Core/Platform/Services/Application.swift index 4ab56a858b..b3e244e674 100644 --- a/BitwardenShared/Core/Platform/Services/Application.swift +++ b/BitwardenShared/Core/Platform/Services/Application.swift @@ -4,7 +4,7 @@ import UIKit /// public protocol Application { /// The current state of the application (i.e. foreground, inactive, background) - var applicationState: UIApplication.State {get} + var applicationState: UIApplication.State { get } /// Marks the start of a task with a custom name that should continue if the app enters the background. /// See note in `UIApplication+Application.swift` diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 421c68cccd..0e70800135 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -50,7 +50,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// a Task that subscribes to the sync setting publisher for accounts. This allows us to take action once /// a user opts-in to Authenticator sync. - public var syncSettingSubscriberTask: Task? + private var syncSettingSubscriberTask: Task? /// The service used by the application to manage vault access. private let vaultTimeoutService: VaultTimeoutService @@ -127,7 +127,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - Returns: The decrypted, filtered, and sorted `CipherDTO` objects. /// private func decryptTOTPs(_ ciphers: [Cipher], - userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + userId: String) async throws -> [AuthenticatorBridgeItemDataView] { let decryptedCiphers = try await ciphers.asyncMap { cipher in try await self.clientService.vault().ciphers().decrypt(cipher: cipher) } @@ -141,7 +141,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } return ciphersToUse.map { cipher in - AuthenticatorBridgeItemDataModel( + AuthenticatorBridgeItemDataView( favorite: false, id: cipher.id ?? UUID().uuidString, name: cipher.name, @@ -158,16 +158,10 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// private func handleSyncOnForUserId(_ userId: String) async throws { Logger.application.log("#### sync is on for userId: \(userId)") - if application?.applicationState == .background { - Logger.application.log("#### App in background. Subscribing to cipher updates from push notifications.") - subscribeToCipherUpdates(userId: userId) - } else if vaultTimeoutService.isLocked(userId: userId) { - Logger.application.log("#### App in foreground and locked for \(userId). Waiting for unlock to occur.") - // subscribeToVaultPublisher() - } else { + + if application?.applicationState == .active, !vaultTimeoutService.isLocked(userId: userId) { Logger.application.log("#### App in foreground and unlocked. Begin key creations.") try await createAuthenticatorKeyIfNeeded() - // try await createAuthenticatorVaultKey(userId: userId) Logger.application.log("#### Subscribing to cipher updates") subscribeToCipherUpdates(userId: userId) } @@ -177,12 +171,8 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// /// - Parameter userId: The userId of the user who has turned off sync. /// - private func handleSyncOffForUserId(_ userId: String) async throws { + private func handleSyncOffForUserId(_ userId: String) { Logger.application.log("#### sync is off for userId: \(userId)") - Logger.application.log("#### clearing data for userId: \(userId)") - // try await deleteFromAuthenticatorStore(userId: userId) - // try await deleteAuthenticatorKeyIfLast() - // try await keychainRepository.deleteAuthenticatorVaultKey(userId: userId) Logger.application.log("#### Canceling cipher update subscription") cipherPublisherTasks[userId]??.cancel() cipherPublisherTasks[userId] = nil @@ -230,7 +220,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { if shouldSync { try await handleSyncOnForUserId(userId) } else { - try await handleSyncOffForUserId(userId) + handleSyncOffForUserId(userId) } } catch { Logger.application.log("#### Error in Auth Options publisher: \(error)") diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 7dbb81eb7f..179fcba119 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -1,4 +1,5 @@ import AuthenticatorBridgeKit +import Combine import XCTest @testable import BitwardenShared @@ -31,6 +32,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { sharedKeychainRepository = MockSharedKeychainRepository() stateService = MockStateService() vaultTimeoutService = MockVaultTimeoutService() + configService.featureFlagsBool[.enableAuthenticatorSync] = true subject = DefaultAuthenticatorSyncService( application: application, @@ -64,50 +66,18 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // MARK: Tests - /// Initializing the `AuthenticatorSyncService` when the `enableAuthenticatorSync` feature flag - /// is turned off should do nothing. - /// - func test_init_featureFlagOff() async throws { - subject = nil - notificationCenterService.willEnterForegroundSubscribers = 0 - configService.featureFlagsBool[.enableAuthenticatorSync] = false - subject = DefaultAuthenticatorSyncService( - application: application, - authBridgeItemService: authBridgeItemService, - cipherService: cipherService, - clientService: clientService, - configService: configService, - errorReporter: errorReporter, - notificationCenterService: notificationCenterService, - sharedKeychainRepository: sharedKeychainRepository, - stateService: stateService, - vaultTimeoutService: vaultTimeoutService - ) - notificationCenterService.willEnterForegroundSubject.send() - XCTAssertEqual(notificationCenterService.willEnterForegroundSubscribers, 0) - // TODO: Test to make sure this does nothing - } - - /// Initializing the `AuthenticatorSyncService` when the `enableAuthenticatorSync` feature flag - /// is turned on should do subscribe to foreground notifications. - /// - func test_init_featureFlagOn() async throws { - XCTAssertEqual(notificationCenterService.willEnterForegroundSubscribers, 1) - notificationCenterService.willEnterForegroundSubject.send() - // TODO: Test to make sure this does stuff - } - /// When the app enters the foreground and the user has subscribed to sync, the /// `createAuthenticatorKeyIfNeeded` method successfully creates the sync key /// if it is not already present /// func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { try sharedKeychainRepository.deleteAuthenticatorKey() + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", true)) + application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() - await stateService.addAccount(.fixture(profile: .fixture(userId: "1"))) - - try await stateService.setSyncToAuthenticator(true) waitFor(sharedKeychainRepository.authenticatorKey != nil) } @@ -117,5 +87,167 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// SharedKeyRepository and doesn't recreate it. /// func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { + let key = sharedKeychainRepository.generateKeyData() + try await sharedKeychainRepository.setAuthenticatorKey(key) + + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", true)) + + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + + waitFor(sharedKeychainRepository.authenticatorKey != nil) + XCTAssertEqual(sharedKeychainRepository.authenticatorKey, key) + } + + /// Verifies that when Ciphers are published. the service filters out one that have a deletedDate in the past. + /// + func test_decryptTOTPs_filtersOutDeleted() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", true)) + + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + cipherService.ciphersSubject.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + .fixture( + deletedDate: Date(timeIntervalSinceNow: -10000), + id: "Deleted", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.replaceAllCalled) + + let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.id, "1234") + } + + /// Verifies that when Ciphers are published. the service ignores any Ciphers with logins that don't contain a + /// TOTP key. + /// + func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", true)) + + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + cipherService.ciphersSubject.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + .fixture( + id: "No TOTP", + login: .fixture( + username: "user@bitwarden.com" + ) + ), + ]) + + waitFor(authBridgeItemService.replaceAllCalled) + + let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.id, "1234") + } + + /// Verifies that the AuthSyncService responds to new Ciphers published and provides a generated UUID if the + /// Cipher has no id itself. + /// + func test_decryptTOTPs_providesIdIfNil() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", true)) + + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + cipherService.ciphersSubject.send([ + .fixture( + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.replaceAllCalled) + + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertNotNil(item.id) + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + } + + /// Verifies that the AuthSyncService responds to new Ciphers published by converting them into ItemViews and + /// passes them to the ItemService for storage. + /// + func test_decryptTOTPs_success() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", true)) + + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + cipherService.ciphersSubject.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.replaceAllCalled) + + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertEqual(item.id, "1234") + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + } + + /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. + /// + func test_handleSyncOff() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject = + CurrentValueSubject<(String?, Bool), Never>(("1", false)) + + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + cipherService.ciphersSubject.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertFalse(authBridgeItemService.replaceAllCalled) } } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift index c954b69f7b..0850129c98 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift @@ -2,21 +2,23 @@ import AuthenticatorBridgeKit import BitwardenShared class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { - var storedItems: [String: [AuthenticatorBridgeItemDataModel]] = [:] + var replaceAllCalled = false + var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:] func deleteAllForUserId(_ userId: String) async throws { storedItems[userId] = [] } - func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataModel] { + func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] { storedItems[userId] ?? [] } - func insertItems(_ items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws { storedItems[userId] = items } - func replaceAllItems(with items: [AuthenticatorBridgeItemDataModel], forUserId userId: String) async throws { + func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws { storedItems[userId] = items + replaceAllCalled = true } } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift index 3f2b3269f5..d62a760f4d 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockNotificationCenterService.swift @@ -5,17 +5,13 @@ import Foundation class MockNotificationCenterService: NotificationCenterService { var didEnterBackgroundSubject = CurrentValueSubject(()) - var didEnterBackgroundSubscribers = 0 var willEnterForegroundSubject = CurrentValueSubject(()) - var willEnterForegroundSubscribers = 0 func didEnterBackgroundPublisher() -> AsyncPublisher> { - didEnterBackgroundSubscribers += 1 - return didEnterBackgroundSubject.eraseToAnyPublisher().values + didEnterBackgroundSubject.eraseToAnyPublisher().values } func willEnterForegroundPublisher() -> AsyncPublisher> { - willEnterForegroundSubscribers += 1 - return willEnterForegroundSubject.eraseToAnyPublisher().values + willEnterForegroundSubject.eraseToAnyPublisher().values } } From 19f971fb7b8af3e199c7f6e4086ea8f8aa1a53a6 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 23 Sep 2024 13:24:24 -0400 Subject: [PATCH 28/52] Updated tests for 100% coverage --- .../Services/AuthenticatorSyncService.swift | 20 +++---- .../AuthenticatorSyncServiceTests.swift | 53 +++++++++++-------- .../MockSharedKeychainRepository.swift | 5 ++ 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 0e70800135..e0db9dc781 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -156,12 +156,16 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// /// - Parameter userId: The userId of the user who has turned on sync. /// - private func handleSyncOnForUserId(_ userId: String) async throws { + private func handleSyncOnForUserId(_ userId: String) async { Logger.application.log("#### sync is on for userId: \(userId)") if application?.applicationState == .active, !vaultTimeoutService.isLocked(userId: userId) { Logger.application.log("#### App in foreground and unlocked. Begin key creations.") - try await createAuthenticatorKeyIfNeeded() + do { + try await createAuthenticatorKeyIfNeeded() + } catch { + errorReporter.log(error: error) + } Logger.application.log("#### Subscribing to cipher updates") subscribeToCipherUpdates(userId: userId) } @@ -216,14 +220,10 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { guard let userId else { continue } Logger.application.log("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") - do { - if shouldSync { - try await handleSyncOnForUserId(userId) - } else { - handleSyncOffForUserId(userId) - } - } catch { - Logger.application.log("#### Error in Auth Options publisher: \(error)") + if shouldSync { + await handleSyncOnForUserId(userId) + } else { + handleSyncOffForUserId(userId) } } } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 179fcba119..8e85d8add8 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -73,9 +73,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", true)) - + stateService.syncToAuthenticatorSubject.send(("1", true)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() @@ -91,9 +89,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { try await sharedKeychainRepository.setAuthenticatorKey(key) stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", true)) - + stateService.syncToAuthenticatorSubject.send(("1", true)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() @@ -105,9 +101,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_filtersOutDeleted() async throws { stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", true)) - + stateService.syncToAuthenticatorSubject.send(("1", true)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ @@ -140,9 +134,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", true)) - + stateService.syncToAuthenticatorSubject.send(("1", true)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ @@ -173,9 +165,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_providesIdIfNil() async throws { stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", true)) - + stateService.syncToAuthenticatorSubject.send(("1", true)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ @@ -202,9 +192,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_success() async throws { stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", true)) - + stateService.syncToAuthenticatorSubject.send(("1", true)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ @@ -227,13 +215,24 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertEqual(item.username, "user@bitwarden.com") } + /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. + /// + func test_handleSyncOn_error() async throws { + sharedKeychainRepository.errorToThrow = BitwardenTestError.example + + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject.send(("1", true)) + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + + waitFor(!errorReporter.errors.isEmpty) + } + /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. /// func test_handleSyncOff() async throws { stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject = - CurrentValueSubject<(String?, Bool), Never>(("1", false)) - + stateService.syncToAuthenticatorSubject.send(("1", false)) application.applicationState = .active notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ @@ -250,4 +249,16 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertFalse(authBridgeItemService.replaceAllCalled) } + + /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service.. + /// + func test_subscribeToCipherUpdates_error() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject.send(("1", true)) + application.applicationState = .active + notificationCenterService.willEnterForegroundSubject.send() + cipherService.ciphersSubject.send(completion: .failure(BitwardenTestError.example)) + + waitFor(!errorReporter.errors.isEmpty) + } } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift index 689202e571..84825003a9 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockSharedKeychainRepository.swift @@ -5,6 +5,7 @@ import Foundation class MockSharedKeychainRepository { var authenticatorKey: Data? + var errorToThrow: Error? } extension MockSharedKeychainRepository: SharedKeychainRepository { @@ -18,6 +19,8 @@ extension MockSharedKeychainRepository: SharedKeychainRepository { } func getAuthenticatorKey() async throws -> Data { + guard errorToThrow == nil else { throw errorToThrow! } + if let authenticatorKey { return authenticatorKey } else { @@ -26,6 +29,8 @@ extension MockSharedKeychainRepository: SharedKeychainRepository { } func setAuthenticatorKey(_ value: Data) async throws { + guard errorToThrow == nil else { throw errorToThrow! } + authenticatorKey = value } } From 4012c324f003931b8dbeeb6db1fc6816594268d2 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 23 Sep 2024 13:36:58 -0400 Subject: [PATCH 29/52] Doc comment cleanup --- .../Platform/Services/AuthenticatorSyncServiceTests.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 8e85d8add8..fd4491b0f4 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -97,7 +97,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertEqual(sharedKeychainRepository.authenticatorKey, key) } - /// Verifies that when Ciphers are published. the service filters out one that have a deletedDate in the past. + /// When Ciphers are published. the service filters out ones that have a deletedDate in the past. /// func test_decryptTOTPs_filtersOutDeleted() async throws { stateService.activeAccount = .fixture() @@ -129,8 +129,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertEqual(items.first?.id, "1234") } - /// Verifies that when Ciphers are published. the service ignores any Ciphers with logins that don't contain a - /// TOTP key. + /// When Ciphers are published. the service ignores any Ciphers with logins that don't contain a TOTP key. /// func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { stateService.activeAccount = .fixture() @@ -215,7 +214,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertEqual(item.username, "user@bitwarden.com") } - /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. + /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. /// func test_handleSyncOn_error() async throws { sharedKeychainRepository.errorToThrow = BitwardenTestError.example From 66a74d2aaf5887f0cdbb012dac9796116b5cde1c Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 23 Sep 2024 17:17:43 -0400 Subject: [PATCH 30/52] Update to use MainActor so that Application returns the correct value at run time --- .../Core/Platform/Services/Application.swift | 2 +- .../Services/AuthenticatorSyncService.swift | 2 +- .../AuthenticatorSyncServiceTests.swift | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/Application.swift b/BitwardenShared/Core/Platform/Services/Application.swift index b3e244e674..0e87fa7479 100644 --- a/BitwardenShared/Core/Platform/Services/Application.swift +++ b/BitwardenShared/Core/Platform/Services/Application.swift @@ -4,7 +4,7 @@ import UIKit /// public protocol Application { /// The current state of the application (i.e. foreground, inactive, background) - var applicationState: UIApplication.State { get } + @MainActor var applicationState: UIApplication.State { get } /// Marks the start of a task with a custom name that should continue if the app enters the background. /// See note in `UIApplication+Application.swift` diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index e0db9dc781..0ca925a9c3 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -159,7 +159,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private func handleSyncOnForUserId(_ userId: String) async { Logger.application.log("#### sync is on for userId: \(userId)") - if application?.applicationState == .active, !vaultTimeoutService.isLocked(userId: userId) { + if await application?.applicationState == .active, !vaultTimeoutService.isLocked(userId: userId) { Logger.application.log("#### App in foreground and unlocked. Begin key creations.") do { try await createAuthenticatorKeyIfNeeded() diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index fd4491b0f4..e9923a73bf 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -74,7 +74,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) @@ -90,7 +90,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) @@ -102,7 +102,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_filtersOutDeleted() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -134,7 +134,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -165,7 +165,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_providesIdIfNil() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -192,7 +192,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_success() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -221,7 +221,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() waitFor(!errorReporter.errors.isEmpty) @@ -232,7 +232,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_handleSyncOff() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", false)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -254,7 +254,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_subscribeToCipherUpdates_error() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - application.applicationState = .active + await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send(completion: .failure(BitwardenTestError.example)) From b9f0646fa46947d4f15b8c124f02ca1c22d3f71a Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 23 Sep 2024 17:39:43 -0400 Subject: [PATCH 31/52] Pulling application out of the SyncService entirely. Was too unreliable to use at run time --- .../Core/Platform/Services/Application.swift | 3 --- .../Services/AuthenticatorSyncService.swift | 8 +------- .../Services/AuthenticatorSyncServiceTests.swift | 13 ------------- .../Core/Platform/Services/ServiceContainer.swift | 1 - .../Services/TestHelpers/MockApplication.swift | 1 - 5 files changed, 1 insertion(+), 25 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/Application.swift b/BitwardenShared/Core/Platform/Services/Application.swift index 0e87fa7479..72fbeb583d 100644 --- a/BitwardenShared/Core/Platform/Services/Application.swift +++ b/BitwardenShared/Core/Platform/Services/Application.swift @@ -3,9 +3,6 @@ import UIKit /// A protocol for the application instance (i.e. `UIApplication`). /// public protocol Application { - /// The current state of the application (i.e. foreground, inactive, background) - @MainActor var applicationState: UIApplication.State { get } - /// Marks the start of a task with a custom name that should continue if the app enters the background. /// See note in `UIApplication+Application.swift` /// TODO: PM-11189 diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 0ca925a9c3..9df09494a4 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -18,9 +18,6 @@ protocol AuthenticatorSyncService {} class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { // MARK: Private Properties - /// The Application instance, used to determine if the app is in the foreground, etc - private let application: Application? - /// The service for managing sharing items to/from the Authenticator app. private let authBridgeItemService: AuthenticatorBridgeItemService @@ -60,7 +57,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// Initialize a `DefaultAuthenticatorSyncService`. /// /// - Parameters: - /// - application: The Application instance, used to determine if the app is in the foreground, etc /// - authBridgeItemService: The service for managing sharing items to/from the Authenticator app. /// - cipherService: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. @@ -73,7 +69,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - vaultTimeoutService: The service used by the application to manage vault access. /// init( - application: Application?, authBridgeItemService: AuthenticatorBridgeItemService, cipherService: CipherService, clientService: ClientService, @@ -84,7 +79,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { stateService: StateService, vaultTimeoutService: VaultTimeoutService ) { - self.application = application self.authBridgeItemService = authBridgeItemService self.cipherService = cipherService self.clientService = clientService @@ -159,7 +153,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private func handleSyncOnForUserId(_ userId: String) async { Logger.application.log("#### sync is on for userId: \(userId)") - if await application?.applicationState == .active, !vaultTimeoutService.isLocked(userId: userId) { + if !vaultTimeoutService.isLocked(userId: userId) { Logger.application.log("#### App in foreground and unlocked. Begin key creations.") do { try await createAuthenticatorKeyIfNeeded() diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index e9923a73bf..bc498a1e62 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -5,7 +5,6 @@ import XCTest @testable import BitwardenShared final class AuthenticatorSyncServiceTests: BitwardenTestCase { - var application: MockApplication! var authBridgeItemService: MockAuthenticatorBridgeItemService! var cipherService: MockCipherService! var clientService: MockClientService! @@ -22,7 +21,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { override func setUp() { super.setUp() - application = MockApplication() authBridgeItemService = MockAuthenticatorBridgeItemService() cipherService = MockCipherService() configService = MockConfigService() @@ -35,7 +33,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { configService.featureFlagsBool[.enableAuthenticatorSync] = true subject = DefaultAuthenticatorSyncService( - application: application, authBridgeItemService: authBridgeItemService, cipherService: cipherService, clientService: clientService, @@ -51,7 +48,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { override func tearDown() { super.tearDown() - application = nil authBridgeItemService = nil cipherService = nil configService = nil @@ -74,7 +70,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) @@ -90,7 +85,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) @@ -102,7 +96,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_filtersOutDeleted() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -134,7 +127,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -165,7 +157,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_providesIdIfNil() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -192,7 +183,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_decryptTOTPs_success() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -221,7 +211,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() waitFor(!errorReporter.errors.isEmpty) @@ -232,7 +221,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_handleSyncOff() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", false)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( @@ -254,7 +242,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { func test_subscribeToCipherUpdates_error() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - await MainActor.run { application.applicationState = .active } notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send(completion: .failure(BitwardenTestError.example)) diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 7b4e206551..e32fb8db3d 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -628,7 +628,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le ) let authenticatorSyncService = DefaultAuthenticatorSyncService( - application: application, authBridgeItemService: authBridgeItemService, cipherService: cipherService, clientService: clientService, diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift index 82c6b45dfe..d79b46f5bb 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockApplication.swift @@ -3,7 +3,6 @@ import UIKit @testable import BitwardenShared class MockApplication: Application { - var applicationState: UIApplication.State = .active var beginBackgroundTaskName: String? var beginBackgroundTaskHandler: (() -> Void)? var beginBackgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid From 13162c08ccafc19f71ecffba655d97301a101417 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 24 Sep 2024 08:19:44 -0400 Subject: [PATCH 32/52] Respond to PR feedback --- .../Core/Platform/Services/StateService.swift | 7 ++-- .../Platform/Services/StateServiceTests.swift | 42 +++++++------------ 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 5988901dcb..cf0ab6037e 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -1768,11 +1768,10 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le func syncToAuthenticatorPublisher() async -> AnyPublisher<(String?, Bool), Never> { activeAccountIdPublisher().flatMap { userId in self.syncToAuthenticatorByUserIdSubject.map { values in - let userValue = if let userId { - values[userId] ?? self.appSettingsStore.syncToAuthenticator(userId: userId) - } else { - false + guard let userId else { + return (nil, false) } + let userValue = values[userId] ?? self.appSettingsStore.syncToAuthenticator(userId: userId) return (userId, userValue) } } diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 3e9dec2b1b..3a1143a5ad 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -1793,22 +1793,19 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body func test_syncToAuthenticatorPublisher() async throws { await subject.addAccount(.fixture(profile: .fixture(userId: "1"))) - var publishedValues = [SyncToAuthenticatorValue]() + var publishedValues = [(userId: String?, shouldSync: Bool)]() let publisher = await subject.syncToAuthenticatorPublisher() .sink(receiveValue: { userId, shouldSync in - publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + publishedValues.append((userId: userId, shouldSync: shouldSync)) }) defer { publisher.cancel() } try await subject.setSyncToAuthenticator(true) - XCTAssertEqual( - publishedValues, - [ - SyncToAuthenticatorValue(userId: "1", shouldSync: false), - SyncToAuthenticatorValue(userId: "1", shouldSync: true), - ] - ) + XCTAssertEqual(publishedValues[0].userId, "1") + XCTAssertEqual(publishedValues[0].shouldSync, false) + XCTAssertEqual(publishedValues[1].userId, "1") + XCTAssertEqual(publishedValues[1].shouldSync, true) } /// `syncToAuthenticatorPublisher()` gets the initial stored value if a cached value doesn't exist. @@ -1817,34 +1814,32 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body appSettingsStore.syncToAuthenticatorByUserId["1"] = true - var publishedValues = [SyncToAuthenticatorValue]() + var publishedValues = [(userId: String?, shouldSync: Bool)]() let publisher = await subject.syncToAuthenticatorPublisher() .sink(receiveValue: { userId, shouldSync in - publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + publishedValues.append((userId: userId, shouldSync: shouldSync)) }) defer { publisher.cancel() } try await subject.setSyncToAuthenticator(false) - XCTAssertEqual( - publishedValues, - [ - SyncToAuthenticatorValue(userId: "1", shouldSync: true), - SyncToAuthenticatorValue(userId: "1", shouldSync: false), - ] - ) + XCTAssertEqual(publishedValues[0].userId, "1") + XCTAssertEqual(publishedValues[0].shouldSync, true) + XCTAssertEqual(publishedValues[1].userId, "1") + XCTAssertEqual(publishedValues[1].shouldSync, false) } /// `syncToAuthenticatorPublisher()` returns false if the user is not logged in. func test_syncToAuthenticatorPublisher_notLoggedIn() async throws { - var publishedValues = [SyncToAuthenticatorValue]() + var publishedValues = [(userId: String?, shouldSync: Bool)]() let publisher = await subject.syncToAuthenticatorPublisher() .sink(receiveValue: { userId, shouldSync in - publishedValues.append(SyncToAuthenticatorValue(userId: userId, shouldSync: shouldSync)) + publishedValues.append((userId: userId, shouldSync: shouldSync)) }) defer { publisher.cancel() } - XCTAssertEqual(publishedValues, [SyncToAuthenticatorValue(userId: nil, shouldSync: false)]) + XCTAssertNil(publishedValues[0].userId) + XCTAssertEqual(publishedValues[0].shouldSync, false) } /// `.setActiveAccount(userId:)` sets the action that occurs when there's a session timeout. @@ -1936,9 +1931,4 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body private struct ConnectToWatchValue: Equatable { let userId: String? let shouldConnect: Bool -} - -private struct SyncToAuthenticatorValue: Equatable { - let userId: String? - let shouldSync: Bool } // swiftlint:disable:this file_length From ed19b6c33a9563512902fb36b82469dc296e88c5 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 24 Sep 2024 11:45:42 -0400 Subject: [PATCH 33/52] Updated to use assert true with == operator --- .../Platform/Services/StateServiceTests.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 3a1143a5ad..61af8534f8 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -1802,10 +1802,8 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body try await subject.setSyncToAuthenticator(true) - XCTAssertEqual(publishedValues[0].userId, "1") - XCTAssertEqual(publishedValues[0].shouldSync, false) - XCTAssertEqual(publishedValues[1].userId, "1") - XCTAssertEqual(publishedValues[1].shouldSync, true) + XCTAssertTrue(publishedValues[0] == (userId: "1", shouldSync: false)) + XCTAssertTrue(publishedValues[1] == (userId: "1", shouldSync: true)) } /// `syncToAuthenticatorPublisher()` gets the initial stored value if a cached value doesn't exist. @@ -1823,10 +1821,8 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body try await subject.setSyncToAuthenticator(false) - XCTAssertEqual(publishedValues[0].userId, "1") - XCTAssertEqual(publishedValues[0].shouldSync, true) - XCTAssertEqual(publishedValues[1].userId, "1") - XCTAssertEqual(publishedValues[1].shouldSync, false) + XCTAssertTrue(publishedValues[0] == (userId: "1", shouldSync: true)) + XCTAssertTrue(publishedValues[1] == (userId: "1", shouldSync: false)) } /// `syncToAuthenticatorPublisher()` returns false if the user is not logged in. @@ -1838,8 +1834,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body }) defer { publisher.cancel() } - XCTAssertNil(publishedValues[0].userId) - XCTAssertEqual(publishedValues[0].shouldSync, false) + XCTAssertTrue(publishedValues[0] == (userId: nil, shouldSync: false)) } /// `.setActiveAccount(userId:)` sets the action that occurs when there's a session timeout. From cc7997742087d274cf02c86b94fc212454561049 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 24 Sep 2024 15:18:18 -0400 Subject: [PATCH 34/52] Added cancel before creating new settings task --- .../Core/Platform/Services/AuthenticatorSyncService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 9df09494a4..e7e0cf9b34 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -209,6 +209,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// permission to sync items to the Authenticator app. /// private func subscribeToSyncToAuthenticatorSetting() { + syncSettingSubscriberTask?.cancel() syncSettingSubscriberTask = Task { for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { guard let userId else { continue } From c27e92a10421e7da80c6eb0b39edb4fa8ae4c939 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 24 Sep 2024 16:21:53 -0400 Subject: [PATCH 35/52] Added start() method to allow ServiceContainer a way to explicitly start the Service --- .../Services/AuthenticatorSyncService.swift | 11 ++++++- .../AuthenticatorSyncServiceTests.swift | 33 ++++++++++++++++++- .../Platform/Services/ServiceContainer.swift | 1 + .../MockAuthenticatorSyncService.swift | 4 ++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index e7e0cf9b34..3df3b04b24 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -9,7 +9,12 @@ import UIKit /// The service used to share TOTP codes to and from the Authenticator app.. /// -protocol AuthenticatorSyncService {} +protocol AuthenticatorSyncService { + /// This starts the service listening for updates and writing to the shared store. This method + /// must be called for the service to do any syncing. + /// + func start() +} // MARK: - DefaultAuthenticatorSyncService @@ -89,7 +94,11 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { self.stateService = stateService self.vaultTimeoutService = vaultTimeoutService super.init() + } + + // MARK: Public Methods + public func start() { Task { if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, defaultValue: false) { diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index bc498a1e62..ea4de058ea 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -31,7 +31,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService = MockStateService() vaultTimeoutService = MockVaultTimeoutService() - configService.featureFlagsBool[.enableAuthenticatorSync] = true subject = DefaultAuthenticatorSyncService( authBridgeItemService: authBridgeItemService, cipherService: cipherService, @@ -67,6 +66,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// if it is not already present /// func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) @@ -80,6 +81,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// SharedKeyRepository and doesn't recreate it. /// func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() let key = sharedKeychainRepository.generateKeyData() try await sharedKeychainRepository.setAuthenticatorKey(key) @@ -94,6 +97,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// When Ciphers are published. the service filters out ones that have a deletedDate in the past. /// func test_decryptTOTPs_filtersOutDeleted() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() @@ -125,6 +130,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// When Ciphers are published. the service ignores any Ciphers with logins that don't contain a TOTP key. /// func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() @@ -155,6 +162,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Cipher has no id itself. /// func test_decryptTOTPs_providesIdIfNil() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() @@ -181,6 +190,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// passes them to the ItemService for storage. /// func test_decryptTOTPs_success() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() @@ -207,6 +218,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. /// func test_handleSyncOn_error() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() sharedKeychainRepository.errorToThrow = BitwardenTestError.example stateService.activeAccount = .fixture() @@ -219,6 +232,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. /// func test_handleSyncOff() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", false)) notificationCenterService.willEnterForegroundSubject.send() @@ -237,9 +252,25 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { XCTAssertFalse(authBridgeItemService.replaceAllCalled) } + /// Starting the service when the feature flag is off should do nothing - no subscriptions or responses. + /// + func test_start_featureFlagOff() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = false + subject.start() + try sharedKeychainRepository.deleteAuthenticatorKey() + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject.send(("1", true)) + notificationCenterService.willEnterForegroundSubject.send() + + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertNil(sharedKeychainRepository.authenticatorKey) + } + /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service.. /// func test_subscribeToCipherUpdates_error() async throws { + configService.featureFlagsBool[.enableAuthenticatorSync] = true + subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index e32fb8db3d..1775ff1455 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -638,6 +638,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le stateService: stateService, vaultTimeoutService: vaultTimeoutService ) + authenticatorSyncService.start() self.init( apiService: apiService, diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift index 81f2c82d89..f0b41c9d38 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorSyncService.swift @@ -1,3 +1,5 @@ @testable import BitwardenShared -class MockAuthenticatorSyncService: AuthenticatorSyncService {} +class MockAuthenticatorSyncService: AuthenticatorSyncService { + func start() {} +} From aac820ec1e8873cc9a1058aee6316ccf481520da Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 25 Sep 2024 12:27:37 -0400 Subject: [PATCH 36/52] Add sharedItemsPublisher to BridgeItemService --- .../AuthenticatorBridgeItemService.swift | 25 ++- .../FetchedResultsPublisher.swift | 145 ++++++++++++++++++ AuthenticatorBridgeKit/Publisher+Async.swift | 62 ++++++++ .../SharedCryptographyService.swift | 38 ++++- .../SharedKeychainRepository.swift | 2 +- .../AuthenticatorBridgeItemDataTests.swift | 2 +- .../AuthenticatorBridgeItemServiceTests.swift | 53 +++++++ .../SharedCryptographyServiceTests.swift | 6 +- .../MockSharedCryptographyService.swift | 20 ++- .../MockAuthenticatorBridgeItemService.swift | 11 ++ 10 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 AuthenticatorBridgeKit/FetchedResultsPublisher.swift create mode 100644 AuthenticatorBridgeKit/Publisher+Async.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 97bc9754e0..63342ee7c3 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -1,3 +1,4 @@ +import Combine import Foundation // MARK: - AuthenticatorBridgeItemService @@ -35,6 +36,13 @@ public protocol AuthenticatorBridgeItemService { /// func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws + + /// A Publisher that returns all of the items in the shared store. + /// + /// - Returns: Publisher that will publish the initial list of all items and any future data changes. + /// + func sharedItemsPublisher() async throws -> + AsyncThrowingPublisher> } /// A concrete implementation of the `AuthenticatorBridgeItemService` protocol. @@ -88,7 +96,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi let encryptedItems = result.compactMap { data in data.model } - return try await cryptoService.decryptAuthenticatorItems(encryptedItems) + return try await cryptoService.decryptAuthenticatorItemModels(encryptedItems) } /// Inserts the list of items into the store for the given userId. @@ -124,4 +132,19 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi insertRequest: insertRequest ) } + + public func sharedItemsPublisher() async throws -> + AsyncThrowingPublisher> { + let fetchRequest = AuthenticatorBridgeItemData.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorBridgeItemData.userId, ascending: true)] + return FetchedResultsPublisher( + context: dataStore.persistentContainer.viewContext, + request: fetchRequest + ) + .asyncTryMap { items in + try await self.cryptoService.decryptAuthenticatorItemDatas(items) + } + .eraseToAnyPublisher() + .values + } } diff --git a/AuthenticatorBridgeKit/FetchedResultsPublisher.swift b/AuthenticatorBridgeKit/FetchedResultsPublisher.swift new file mode 100644 index 0000000000..d955421d72 --- /dev/null +++ b/AuthenticatorBridgeKit/FetchedResultsPublisher.swift @@ -0,0 +1,145 @@ +import Combine +import CoreData + +// MARK: - FetchedResultsPublisher + +/// A Combine publisher that publishes the initial result set and any future data changes for a +/// Core Data fetch request. +/// +/// Adapted from https://gist.github.com/darrarski/28d2f5a28ef2c5669d199069c30d3d52 +/// +class FetchedResultsPublisher: Publisher where ResultType: NSFetchRequestResult { + // MARK: Types + + typealias Output = [ResultType] + + typealias Failure = Error + + // MARK: Properties + + /// The managed object context that the fetch request is executed against. + let context: NSManagedObjectContext + + /// The fetch request used to get the objects. + let request: NSFetchRequest + + // MARK: Initialization + + /// Initialize a `FetchedResultsPublisher`. + /// + /// - Parameters: + /// - context: The managed object context that the fetch request is executed against. + /// - request: The fetch request used to get the objects. + /// + init(context: NSManagedObjectContext, request: NSFetchRequest) { + self.context = context + self.request = request + } + + // MARK: Publisher + + func receive(subscriber: S) where S: Subscriber, S.Failure == Failure, S.Input == Output { + subscriber.receive(subscription: FetchedResultsSubscription( + context: context, + request: request, + subscriber: subscriber + )) + } +} + +// MARK: - FetchedResultsSubscription + +/// A `Subscription` to a `FetchedResultsPublisher` which fetches results from Core Data via a +/// `NSFetchedResultsController` and notifies the subscriber of any changes to the data. +/// +private final class FetchedResultsSubscription: NSObject, Subscription, + NSFetchedResultsControllerDelegate + where SubscriberType: Subscriber, + SubscriberType.Input == [ResultType], + SubscriberType.Failure == Error, + ResultType: NSFetchRequestResult { + // MARK: Properties + + /// The fetched results controller to manage the results of a Core Data fetch request. + private var controller: NSFetchedResultsController? + + /// The current demand from the subscriber. + private var demand: Subscribers.Demand = .none + + /// Whether the subscription has changes to send to the subscriber. + private var hasChangesToSend = false + + /// The subscriber to the subscription that is notified of the fetched results. + private var subscriber: SubscriberType? + + // MARK: Initialization + + /// Initialize a `FetchedResultsSubscription`. + /// + /// - Parameters: + /// - context: The managed object context that the fetch request is executed against. + /// - request: The fetch request used to get the objects. + /// - subscriber: The subscriber to the subscription that is notified of the fetched results. + /// + init( + context: NSManagedObjectContext, + request: NSFetchRequest, + subscriber: SubscriberType + ) { + controller = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + self.subscriber = subscriber + + super.init() + + controller?.delegate = self + + do { + try controller?.performFetch() + if controller?.fetchedObjects != nil { + hasChangesToSend = true + fulfillDemand() + } + } catch { + subscriber.receive(completion: .failure(error)) + } + } + + // MARK: Subscription + + func request(_ demand: Subscribers.Demand) { + self.demand += demand + fulfillDemand() + } + + // MARK: Cancellable + + func cancel() { + controller = nil + subscriber = nil + } + + // MARK: NSFetchedResultsControllerDelegate + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + hasChangesToSend = true + fulfillDemand() + } + + // MARK: Private + + private func fulfillDemand() { + guard demand > 0, hasChangesToSend, + let subscriber, + let fetchedObjects = controller?.fetchedObjects + else { return } + + hasChangesToSend = false + demand -= 1 + demand += subscriber.receive(fetchedObjects) + } +} diff --git a/AuthenticatorBridgeKit/Publisher+Async.swift b/AuthenticatorBridgeKit/Publisher+Async.swift new file mode 100644 index 0000000000..fe6a747b69 --- /dev/null +++ b/AuthenticatorBridgeKit/Publisher+Async.swift @@ -0,0 +1,62 @@ +import Combine + +extension Publisher { + /// Maps the output of a publisher to a different type, discarding any `nil` values. + /// + /// - Parameters: + /// - maxPublishers: The maximum number of concurrent publisher subscriptions. + /// - transform: The transform to apply to each output. + /// - Returns: A publisher containing any non-`nil` mapped values. + /// + func asyncCompactMap( + maxPublishers: Subscribers.Demand = .max(1), + _ transform: @escaping (Output) async -> T? + ) -> Publishers.CompactMap, Self>, T> { + asyncMap(maxPublishers: maxPublishers, transform) + .compactMap { $0 } + } + + /// Maps the output of a publisher to a different type. + /// + /// - Parameters: + /// - maxPublishers: The maximum number of concurrent publisher subscriptions. + /// - transform: The transform to apply to each output. + /// - Returns: A publisher containing the mapped values. + /// + func asyncMap( + maxPublishers: Subscribers.Demand = .max(1), + _ transform: @escaping (Output) async -> T + ) -> Publishers.FlatMap, Self> { + flatMap(maxPublishers: maxPublishers) { value in + Future { promise in + Task { + let output = await transform(value) + promise(.success(output)) + } + } + } + } +} + +extension Publisher where Failure == Error { + /// Maps the output of a publisher to a different type which could throw an error. + /// + /// - Parameters: + /// - maxPublishers: The maximum number of concurrent publisher subscriptions. + /// - transform: The transform to apply to each output. + /// - Returns: A publisher containing the mapped values. + /// + func asyncTryMap( + maxPublishers: Subscribers.Demand = .max(1), + _ transform: @escaping (Output) async throws -> T + ) -> Publishers.FlatMap, Self> { + flatMap(maxPublishers: maxPublishers) { value in + Future { promise in + Task { + let output = try await transform(value) + promise(.success(output)) + } + } + } + } +} diff --git a/AuthenticatorBridgeKit/SharedCryptographyService.swift b/AuthenticatorBridgeKit/SharedCryptographyService.swift index de5eba78d0..6703175efd 100644 --- a/AuthenticatorBridgeKit/SharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/SharedCryptographyService.swift @@ -7,6 +7,21 @@ import Foundation /// Bitwarden app and the Authenticator app. /// public protocol SharedCryptographyService: AnyObject { + /// Takes an array of `AuthenticatorBridgeItemData` with encrypted data and + /// returns the list with each member decrypted. + /// + /// Note: if any of the items cannot convert its modelData to a model, it will be dropped + /// from the resulting array. + /// + /// - Parameter items: The encrypted array of items to be decrypted + /// - Returns: the array of items with their data decrypted + /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator + /// key is not in the shared repository. + /// + func decryptAuthenticatorItemDatas( + _ items: [AuthenticatorBridgeItemData] + ) async throws -> [AuthenticatorBridgeItemDataView] + /// Takes an array of `AuthenticatorBridgeItemDataModel` with encrypted data and /// returns the list with each member decrypted. /// @@ -15,7 +30,7 @@ public protocol SharedCryptographyService: AnyObject { /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator /// key is not in the shared repository. /// - func decryptAuthenticatorItems( + func decryptAuthenticatorItemModels( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataView] @@ -54,7 +69,26 @@ public class DefaultAuthenticatorCryptographyService: SharedCryptographyService // MARK: Methods - public func decryptAuthenticatorItems( + public func decryptAuthenticatorItemDatas( + _ items: [AuthenticatorBridgeItemData] + ) async throws -> [AuthenticatorBridgeItemDataView] { + let key = try await sharedKeychainRepository.getAuthenticatorKey() + let symmetricKey = SymmetricKey(data: key) + + return items.compactMap { data in + guard let item = data.model else { return nil } + + return AuthenticatorBridgeItemDataView( + favorite: item.favorite, + id: item.id, + name: (try? decrypt(item.name, withKey: symmetricKey)) ?? "", + totpKey: try? decrypt(item.totpKey, withKey: symmetricKey), + username: try? decrypt(item.username, withKey: symmetricKey) + ) + } + } + + public func decryptAuthenticatorItemModels( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataView] { let key = try await sharedKeychainRepository.getAuthenticatorKey() diff --git a/AuthenticatorBridgeKit/SharedKeychainRepository.swift b/AuthenticatorBridgeKit/SharedKeychainRepository.swift index 73c96d6de9..b360feba05 100644 --- a/AuthenticatorBridgeKit/SharedKeychainRepository.swift +++ b/AuthenticatorBridgeKit/SharedKeychainRepository.swift @@ -42,7 +42,7 @@ public protocol SharedKeychainRepository: AnyObject { // MARK: - DefaultKeychainRepository -/// A concreate implementation of the `SharedKeychainRepository` protocol. +/// A concrete implementation of the `SharedKeychainRepository` protocol. /// public class DefaultSharedKeychainRepository: SharedKeychainRepository { // MARK: Properties diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index c9a5d189f5..60c99324c1 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -86,7 +86,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { XCTAssertNotNil(result) XCTAssertEqual(result.count, 1) - let decrypted = try await cryptoService.decryptAuthenticatorItems(result.compactMap(\.model)) + let decrypted = try await cryptoService.decryptAuthenticatorItemModels(result.compactMap(\.model)) let item = try XCTUnwrap(decrypted.first) XCTAssertEqual(item, expectedItem) } diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 59db1c7a16..18c3eacaad 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -160,4 +160,57 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } + + /// Verify that the shared items publisher publishes items for all users at once. + /// + func test_sharedItemsPublisher_containsAllUsers() async throws { + let initialItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } + let otherUserItems = [AuthenticatorBridgeItemDataView.fixture(name: "New Item")] + try await subject.insertItems(initialItems, forUserId: "userId") + try await subject.replaceAllItems(with: otherUserItems, forUserId: "differentUser") + + var results: [[AuthenticatorBridgeItemDataView]] = [] + for try await value in try await subject.sharedItemsPublisher().prefix(1) { + results.append(value) + } + + let combined = (otherUserItems + initialItems) + XCTAssertEqual(results[0], combined) + } + + /// Verify that the shared items publisher publishes all the items inserted initially. + /// + func test_sharedItemsPublisher_success() async throws { + let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } + try await subject.insertItems(expectedItems, forUserId: "userId") + + var results: [AuthenticatorBridgeItemDataView] = [] + for try await value in try await subject.sharedItemsPublisher().prefix(1) { + results = value + } + + XCTAssertEqual(results, expectedItems) + } + + /// Verify that the shared items publisher publishes items that are inserted/replaced later. + /// + func test_sharedItemsPublisher_withUpdates() async throws { + let initialItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } + try await subject.insertItems(initialItems, forUserId: "userId") + + var results: [[AuthenticatorBridgeItemDataView]] = [] + for try await value in try await subject.sharedItemsPublisher().prefix(1) { + results.append(value) + } + + let replacedItems = [AuthenticatorBridgeItemDataView.fixture(name: "New Item")] + try await subject.replaceAllItems(with: replacedItems, forUserId: "userId") + + for try await value in try await subject.sharedItemsPublisher().prefix(1) { + results.append(value) + } + + XCTAssertEqual(results[0], initialItems) + XCTAssertEqual(results[1], replacedItems) + } } diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift index 4d415de90a..da40fd062b 100644 --- a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift @@ -30,12 +30,12 @@ final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { // MARK: Tests - /// Verify that `SharedCryptographyService.decryptAuthenticatorItems(:)` correctly + /// Verify that `SharedCryptographyService.decryptAuthenticatorItemModels(:)` correctly /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. /// func test_decryptAuthenticatorItems_success() async throws { let encryptedItems = try await subject.encryptAuthenticatorItems(items) - let decryptedItems = try await subject.decryptAuthenticatorItems(encryptedItems) + let decryptedItems = try await subject.decryptAuthenticatorItemModels(encryptedItems) XCTAssertEqual(items, decryptedItems) } @@ -48,7 +48,7 @@ final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { try sharedKeychainRepository.deleteAuthenticatorKey() await assertAsyncThrows(error: error) { - _ = try await subject.decryptAuthenticatorItems([]) + _ = try await subject.decryptAuthenticatorItemModels([]) } } diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift index 6d6a08d5e3..58dbce2c02 100644 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -7,7 +7,25 @@ class MockSharedCryptographyService: SharedCryptographyService { var decryptCalled = false var encryptCalled = false - func decryptAuthenticatorItems( + func decryptAuthenticatorItemDatas( + _ items: [AuthenticatorBridgeKit.AuthenticatorBridgeItemData] + ) async throws -> [AuthenticatorBridgeKit.AuthenticatorBridgeItemDataView] { + decryptCalled = true + + return items.compactMap { item in + guard let model = item.model else { return nil } + + return AuthenticatorBridgeItemDataView( + favorite: model.favorite, + id: model.id, + name: model.name, + totpKey: model.totpKey, + username: model.username + ) + } + } + + func decryptAuthenticatorItemModels( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataView] { decryptCalled = true diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift index 0850129c98..ea4719d3a6 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift @@ -1,8 +1,11 @@ import AuthenticatorBridgeKit import BitwardenShared +import Combine class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { var replaceAllCalled = false + var sharedItemsPublisherError: Error? + var sharedItemsSubject = CurrentValueSubject<[AuthenticatorBridgeItemDataView], Error>([]) var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:] func deleteAllForUserId(_ userId: String) async throws { @@ -21,4 +24,12 @@ class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { storedItems[userId] = items replaceAllCalled = true } + + func sharedItemsPublisher() async throws -> + AsyncThrowingPublisher> { + if let sharedItemsPublisherError { + throw sharedItemsPublisherError + } + return sharedItemsSubject.eraseToAnyPublisher().values + } } From 8f2bdf8bc24468a931791f188d724ab4e69f1e2c Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 25 Sep 2024 14:36:16 -0400 Subject: [PATCH 37/52] Added more tests, fixed issue with Future extension not being present, added new isSyncOn function for quickly determining in Authenticator app the status of sync --- .../AuthenticatorBridgeItemService.swift | 12 ++++ .../Future+Extensions.swift | 21 +++++++ AuthenticatorBridgeKit/Publisher+Async.swift | 7 +-- .../AuthenticatorBridgeItemServiceTests.swift | 40 ++++++++++++- .../Tests/PublisherAsyncTests.swift | 60 +++++++++++++++++++ .../MockAuthenticatorBridgeItemService.swift | 5 ++ 6 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 AuthenticatorBridgeKit/Future+Extensions.swift create mode 100644 AuthenticatorBridgeKit/Tests/PublisherAsyncTests.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 63342ee7c3..bc680d87a4 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -28,6 +28,13 @@ public protocol AuthenticatorBridgeItemService { func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws + /// Returns true if sync has been enabled for one or more accounts in the Bitwarden PM app, false + /// if there are no accounts with sync currently turned on. + /// + /// - Returns: true if there is one or more accounts with sync turned on. False otherwise. + /// + func isSyncOn() async throws -> Bool + /// Deletes all existing items for a given user and inserts new items for the list of items provided. /// /// - Parameters: @@ -99,6 +106,11 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi return try await cryptoService.decryptAuthenticatorItemModels(encryptedItems) } + public func isSyncOn() async throws -> Bool { + let key = try? await sharedKeychainRepository.getAuthenticatorKey() + return key != nil + } + /// Inserts the list of items into the store for the given userId. /// /// - Parameters: diff --git a/AuthenticatorBridgeKit/Future+Extensions.swift b/AuthenticatorBridgeKit/Future+Extensions.swift new file mode 100644 index 0000000000..8447c7d7be --- /dev/null +++ b/AuthenticatorBridgeKit/Future+Extensions.swift @@ -0,0 +1,21 @@ +import Combine + +extension Future { + /// Initialize a `Future` with an async throwing closure. + /// + /// - Parameter attemptToFulfill: A closure that the publisher invokes when it emits a value or + /// an error occurs. + /// + convenience init(_ attemptToFulfill: @Sendable @escaping () async throws -> Output) where Failure == Error { + self.init { promise in + Task { + do { + let result = try await attemptToFulfill() + promise(.success(result)) + } catch { + promise(.failure(error)) + } + } + } + } +} diff --git a/AuthenticatorBridgeKit/Publisher+Async.swift b/AuthenticatorBridgeKit/Publisher+Async.swift index fe6a747b69..5b9d47a073 100644 --- a/AuthenticatorBridgeKit/Publisher+Async.swift +++ b/AuthenticatorBridgeKit/Publisher+Async.swift @@ -51,11 +51,8 @@ extension Publisher where Failure == Error { _ transform: @escaping (Output) async throws -> T ) -> Publishers.FlatMap, Self> { flatMap(maxPublishers: maxPublishers) { value in - Future { promise in - Task { - let output = try await transform(value) - promise(.success(output)) - } + Future { + try await transform(value) } } } diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 18c3eacaad..4791409fe0 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -10,7 +10,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase var cryptoService: MockSharedCryptographyService! var dataStore: AuthenticatorBridgeDataStore! var errorReporter: ErrorReporter! - var keychainRepository: SharedKeychainRepository! + var keychainRepository: MockSharedKeychainRepository! var subject: AuthenticatorBridgeItemService! // MARK: Setup & Teardown @@ -111,6 +111,23 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase XCTAssertEqual(result, expectedItems) } + /// Verify that `isSyncOn` returns false when the key is not present in the keychain. + /// + func test_isSyncOn_false() async throws { + try keychainRepository.deleteAuthenticatorKey() + let sync = try await subject.isSyncOn() + XCTAssertFalse(sync) + } + + /// Verify that `isSyncOn` returns true when the key is present in the keychain. + /// + func test_isSyncOn_true() async throws { + let key = keychainRepository.generateKeyData() + try await keychainRepository.setAuthenticatorKey(key) + let sync = try await subject.isSyncOn() + XCTAssertTrue(sync) + } + /// Verify the `replaceAllItems` correctly deletes all of the items in the store previously when given /// an empty list of items to insert for the given userId. /// @@ -192,6 +209,27 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase XCTAssertEqual(results, expectedItems) } + /// Verify that the shared items publisher publishes new lists when items are deleted.. + /// + func test_sharedItemsPublisher_withDeletes() async throws { + let initialItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } + try await subject.insertItems(initialItems, forUserId: "userId") + + var results: [[AuthenticatorBridgeItemDataView]] = [] + for try await value in try await subject.sharedItemsPublisher().prefix(1) { + results.append(value) + } + + try await subject.replaceAllItems(with: [], forUserId: "userId") + + for try await value in try await subject.sharedItemsPublisher().prefix(1) { + results.append(value) + } + + XCTAssertEqual(results[0], initialItems) + XCTAssertEqual(results[1], []) + } + /// Verify that the shared items publisher publishes items that are inserted/replaced later. /// func test_sharedItemsPublisher_withUpdates() async throws { diff --git a/AuthenticatorBridgeKit/Tests/PublisherAsyncTests.swift b/AuthenticatorBridgeKit/Tests/PublisherAsyncTests.swift new file mode 100644 index 0000000000..e630a05109 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/PublisherAsyncTests.swift @@ -0,0 +1,60 @@ +import Combine +import XCTest + +@testable import AuthenticatorBridgeKit + +class PublisherAsyncTests: AuthenticatorBridgeKitTestCase { + // MARK: Properties + + var cancellable: AnyCancellable? + + // MARK: Setup & Teardown + + override func tearDown() { + super.tearDown() + + cancellable = nil + } + + // MARK: Tests + + /// `asyncCompactMap(_:)` maps the output of a publisher, discarding any `nil` values. + func test_asyncCompactMap() { + var receivedValues = [Int]() + + let expectation = expectation(description: #function) + let sequence = [1, 2, 3, 4, 5] + cancellable = sequence + .publisher + .asyncCompactMap { $0 % 2 == 0 ? $0 : nil } + .collect() + .sink { values in + receivedValues = values + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + XCTAssertEqual(receivedValues, [2, 4]) + } + + /// `asyncMap(_:)` maps the output of a publisher. + func test_asyncMap() { + var receivedValues = [Int]() + + let expectation = expectation(description: #function) + let sequence = [1, 2, 3, 4, 5] + cancellable = sequence + .publisher + .asyncMap { $0 * 2 } + .collect() + .sink { values in + receivedValues = values + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + + XCTAssertEqual(receivedValues, [2, 4, 6, 8, 10]) + } +} diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift index ea4719d3a6..a5c9b96622 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift @@ -7,6 +7,7 @@ class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { var sharedItemsPublisherError: Error? var sharedItemsSubject = CurrentValueSubject<[AuthenticatorBridgeItemDataView], Error>([]) var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:] + var syncOn = false func deleteAllForUserId(_ userId: String) async throws { storedItems[userId] = [] @@ -20,6 +21,10 @@ class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { storedItems[userId] = items } + func isSyncOn() async throws -> Bool { + syncOn + } + func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws { storedItems[userId] = items replaceAllCalled = true From 74c1de8854096449a7ed7cd577c61c617b3cd433 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 25 Sep 2024 16:15:59 -0400 Subject: [PATCH 38/52] Reverted to XCTAssertEqual --- .../Platform/Services/StateServiceTests.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift index 61af8534f8..54d4e00f7e 100644 --- a/BitwardenShared/Core/Platform/Services/StateServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/StateServiceTests.swift @@ -1802,8 +1802,10 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body try await subject.setSyncToAuthenticator(true) - XCTAssertTrue(publishedValues[0] == (userId: "1", shouldSync: false)) - XCTAssertTrue(publishedValues[1] == (userId: "1", shouldSync: true)) + XCTAssertEqual(publishedValues[0].userId, "1") + XCTAssertEqual(publishedValues[0].shouldSync, false) + XCTAssertEqual(publishedValues[1].userId, "1") + XCTAssertEqual(publishedValues[1].shouldSync, true) } /// `syncToAuthenticatorPublisher()` gets the initial stored value if a cached value doesn't exist. @@ -1821,8 +1823,10 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body try await subject.setSyncToAuthenticator(false) - XCTAssertTrue(publishedValues[0] == (userId: "1", shouldSync: true)) - XCTAssertTrue(publishedValues[1] == (userId: "1", shouldSync: false)) + XCTAssertEqual(publishedValues[0].userId, "1") + XCTAssertEqual(publishedValues[0].shouldSync, true) + XCTAssertEqual(publishedValues[1].userId, "1") + XCTAssertEqual(publishedValues[1].shouldSync, false) } /// `syncToAuthenticatorPublisher()` returns false if the user is not logged in. @@ -1834,7 +1838,8 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body }) defer { publisher.cancel() } - XCTAssertTrue(publishedValues[0] == (userId: nil, shouldSync: false)) + XCTAssertNil(publishedValues[0].userId) + XCTAssertFalse(publishedValues[0].shouldSync) } /// `.setActiveAccount(userId:)` sets the action that occurs when there's a session timeout. From 75021cc1564be7f2617268f6f3a99317dddd1b71 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 26 Sep 2024 10:58:21 -0400 Subject: [PATCH 39/52] Changed to remove AsyncThrowingPublisher wrapper, which makes things easier in Authenticator to combine --- .../AuthenticatorBridgeItemService.swift | 5 +- .../AuthenticatorBridgeItemServiceTests.swift | 64 ++++++++++++------- .../MockAuthenticatorBridgeItemService.swift | 4 +- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index bc680d87a4..7b500ea927 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -49,7 +49,7 @@ public protocol AuthenticatorBridgeItemService { /// - Returns: Publisher that will publish the initial list of all items and any future data changes. /// func sharedItemsPublisher() async throws -> - AsyncThrowingPublisher> + AnyPublisher<[AuthenticatorBridgeItemDataView], any Error> } /// A concrete implementation of the `AuthenticatorBridgeItemService` protocol. @@ -146,7 +146,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi } public func sharedItemsPublisher() async throws -> - AsyncThrowingPublisher> { + AnyPublisher<[AuthenticatorBridgeItemDataView], any Error> { let fetchRequest = AuthenticatorBridgeItemData.fetchRequest() fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorBridgeItemData.userId, ascending: true)] return FetchedResultsPublisher( @@ -157,6 +157,5 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi try await self.cryptoService.decryptAuthenticatorItemDatas(items) } .eraseToAnyPublisher() - .values } } diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 4791409fe0..1aad812cf9 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -187,10 +187,16 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase try await subject.replaceAllItems(with: otherUserItems, forUserId: "differentUser") var results: [[AuthenticatorBridgeItemDataView]] = [] - for try await value in try await subject.sharedItemsPublisher().prefix(1) { - results.append(value) - } - + let publisher = try await subject.sharedItemsPublisher() + .sink( + receiveCompletion: { _ in }, + receiveValue: { value in + results.append(value) + } + ) + defer { publisher.cancel() } + + waitFor(results.count == 1) let combined = (otherUserItems + initialItems) XCTAssertEqual(results[0], combined) } @@ -201,12 +207,18 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase let expectedItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id } try await subject.insertItems(expectedItems, forUserId: "userId") - var results: [AuthenticatorBridgeItemDataView] = [] - for try await value in try await subject.sharedItemsPublisher().prefix(1) { - results = value - } - - XCTAssertEqual(results, expectedItems) + var results: [[AuthenticatorBridgeItemDataView]] = [] + let publisher = try await subject.sharedItemsPublisher() + .sink( + receiveCompletion: { _ in }, + receiveValue: { value in + results.append(value) + } + ) + defer { publisher.cancel() } + + waitFor(results.count == 1) + XCTAssertEqual(results[0], expectedItems) } /// Verify that the shared items publisher publishes new lists when items are deleted.. @@ -216,16 +228,18 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase try await subject.insertItems(initialItems, forUserId: "userId") var results: [[AuthenticatorBridgeItemDataView]] = [] - for try await value in try await subject.sharedItemsPublisher().prefix(1) { - results.append(value) - } + let publisher = try await subject.sharedItemsPublisher() + .sink( + receiveCompletion: { _ in }, + receiveValue: { value in + results.append(value) + } + ) + defer { publisher.cancel() } try await subject.replaceAllItems(with: [], forUserId: "userId") - for try await value in try await subject.sharedItemsPublisher().prefix(1) { - results.append(value) - } - + waitFor(results.count == 2) XCTAssertEqual(results[0], initialItems) XCTAssertEqual(results[1], []) } @@ -237,17 +251,19 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase try await subject.insertItems(initialItems, forUserId: "userId") var results: [[AuthenticatorBridgeItemDataView]] = [] - for try await value in try await subject.sharedItemsPublisher().prefix(1) { - results.append(value) - } + let publisher = try await subject.sharedItemsPublisher() + .sink( + receiveCompletion: { _ in }, + receiveValue: { value in + results.append(value) + } + ) + defer { publisher.cancel() } let replacedItems = [AuthenticatorBridgeItemDataView.fixture(name: "New Item")] try await subject.replaceAllItems(with: replacedItems, forUserId: "userId") - for try await value in try await subject.sharedItemsPublisher().prefix(1) { - results.append(value) - } - + waitFor(results.count == 2) XCTAssertEqual(results[0], initialItems) XCTAssertEqual(results[1], replacedItems) } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift index a5c9b96622..fe0d88e515 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift @@ -31,10 +31,10 @@ class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { } func sharedItemsPublisher() async throws -> - AsyncThrowingPublisher> { + AnyPublisher<[AuthenticatorBridgeKit.AuthenticatorBridgeItemDataView], any Error> { if let sharedItemsPublisherError { throw sharedItemsPublisherError } - return sharedItemsSubject.eraseToAnyPublisher().values + return sharedItemsSubject.eraseToAnyPublisher() } } From 774def9069b98711b4285a93d7fa04b5d0017ff8 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 26 Sep 2024 12:17:32 -0400 Subject: [PATCH 40/52] Added test coverage for new Cryptography method --- .../SharedCryptographyServiceTests.swift | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift index da40fd062b..e0b1391fdb 100644 --- a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift @@ -30,20 +30,55 @@ final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { // MARK: Tests + /// Verify that `SharedCryptographyService.decryptAuthenticatorItemDatas(:)` correctly + /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. + /// + func test_decryptAuthenticatorItemDatas_success() async throws { + let sharedDataStore = AuthenticatorBridgeDataStore( + errorReporter: MockErrorReporter(), + groupIdentifier: "com.example.bitwarden-authenticator", + storeType: .memory + ) + let encryptedItems = try await subject.encryptAuthenticatorItems(items) + let decryptedItems = try await subject.decryptAuthenticatorItemDatas( + encryptedItems.compactMap { item in + try? AuthenticatorBridgeItemData( + context: sharedDataStore.persistentContainer.viewContext, + userId: "userId", + authenticatorItem: item + ) + } + ) + + XCTAssertEqual(items, decryptedItems) + } + + /// Verify that `SharedCryptographyService.decryptAuthenticatorItemDatas()' throws + /// when the `SharedKeyRepository` authenticator key is missing. + /// + func test_decryptAuthenticatorItemDatas_throwsKeyMissingError() async throws { + let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) + + try sharedKeychainRepository.deleteAuthenticatorKey() + await assertAsyncThrows(error: error) { + _ = try await subject.decryptAuthenticatorItemDatas([]) + } + } + /// Verify that `SharedCryptographyService.decryptAuthenticatorItemModels(:)` correctly /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. /// - func test_decryptAuthenticatorItems_success() async throws { + func test_decryptAuthenticatorItemModels_success() async throws { let encryptedItems = try await subject.encryptAuthenticatorItems(items) let decryptedItems = try await subject.decryptAuthenticatorItemModels(encryptedItems) XCTAssertEqual(items, decryptedItems) } - /// Verify that `SharedCryptographyService.encryptAuthenticatorItems()' throws + /// Verify that `SharedCryptographyService.decryptAuthenticatorItemModels()' throws /// when the `SharedKeyRepository` authenticator key is missing. /// - func test_decryptAuthenticatorItems_throwsKeyMissingError() async throws { + func test_decryptAuthenticatorItemModels_throwsKeyMissingError() async throws { let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) try sharedKeychainRepository.deleteAuthenticatorKey() From e2e99ed0d3d2cd9cf77b98e305dbd4de2fe25f3b Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 27 Sep 2024 09:37:08 -0400 Subject: [PATCH 41/52] Incorporate suggestions from PR feedback --- .../AuthenticatorBridgeItemService.swift | 13 +++-- .../SharedCryptographyService.swift | 40 ++------------- .../AuthenticatorBridgeItemDataTests.swift | 2 +- .../SharedCryptographyServiceTests.swift | 50 +++---------------- .../MockSharedCryptographyService.swift | 2 +- .../Services/AuthenticatorSyncService.swift | 49 +++++++++--------- 6 files changed, 46 insertions(+), 110 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 7b500ea927..69b579af4d 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -28,10 +28,10 @@ public protocol AuthenticatorBridgeItemService { func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws - /// Returns true if sync has been enabled for one or more accounts in the Bitwarden PM app, false + /// Returns `true` if sync has been enabled for one or more accounts in the Bitwarden PM app, `false` /// if there are no accounts with sync currently turned on. /// - /// - Returns: true if there is one or more accounts with sync turned on. False otherwise. + /// - Returns: `true` if there is one or more accounts with sync turned on; `false` otherwise. /// func isSyncOn() async throws -> Bool @@ -103,7 +103,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi let encryptedItems = result.compactMap { data in data.model } - return try await cryptoService.decryptAuthenticatorItemModels(encryptedItems) + return try await cryptoService.decryptAuthenticatorItems(encryptedItems) } public func isSyncOn() async throws -> Bool { @@ -153,8 +153,11 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi context: dataStore.persistentContainer.viewContext, request: fetchRequest ) - .asyncTryMap { items in - try await self.cryptoService.decryptAuthenticatorItemDatas(items) + .tryMap { dataItems in + dataItems.compactMap(\.model) + } + .asyncTryMap { itemModela in + try await self.cryptoService.decryptAuthenticatorItems(itemModela) } .eraseToAnyPublisher() } diff --git a/AuthenticatorBridgeKit/SharedCryptographyService.swift b/AuthenticatorBridgeKit/SharedCryptographyService.swift index 6703175efd..3831046df2 100644 --- a/AuthenticatorBridgeKit/SharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/SharedCryptographyService.swift @@ -7,21 +7,6 @@ import Foundation /// Bitwarden app and the Authenticator app. /// public protocol SharedCryptographyService: AnyObject { - /// Takes an array of `AuthenticatorBridgeItemData` with encrypted data and - /// returns the list with each member decrypted. - /// - /// Note: if any of the items cannot convert its modelData to a model, it will be dropped - /// from the resulting array. - /// - /// - Parameter items: The encrypted array of items to be decrypted - /// - Returns: the array of items with their data decrypted - /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator - /// key is not in the shared repository. - /// - func decryptAuthenticatorItemDatas( - _ items: [AuthenticatorBridgeItemData] - ) async throws -> [AuthenticatorBridgeItemDataView] - /// Takes an array of `AuthenticatorBridgeItemDataModel` with encrypted data and /// returns the list with each member decrypted. /// @@ -30,11 +15,11 @@ public protocol SharedCryptographyService: AnyObject { /// - Throws: AuthenticatorKeychainServiceError.keyNotFound if the Authenticator /// key is not in the shared repository. /// - func decryptAuthenticatorItemModels( + func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataView] - /// Takes an array of `AuthenticatorBridgeItemDataModel` with decrypted data and + /// Takes an array of `AuthenticatorBridgeItemDataView` with decrypted data and /// returns the list with each member encrypted. /// /// - Parameter items: The decrypted array of items to be encrypted @@ -69,26 +54,7 @@ public class DefaultAuthenticatorCryptographyService: SharedCryptographyService // MARK: Methods - public func decryptAuthenticatorItemDatas( - _ items: [AuthenticatorBridgeItemData] - ) async throws -> [AuthenticatorBridgeItemDataView] { - let key = try await sharedKeychainRepository.getAuthenticatorKey() - let symmetricKey = SymmetricKey(data: key) - - return items.compactMap { data in - guard let item = data.model else { return nil } - - return AuthenticatorBridgeItemDataView( - favorite: item.favorite, - id: item.id, - name: (try? decrypt(item.name, withKey: symmetricKey)) ?? "", - totpKey: try? decrypt(item.totpKey, withKey: symmetricKey), - username: try? decrypt(item.username, withKey: symmetricKey) - ) - } - } - - public func decryptAuthenticatorItemModels( + public func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataView] { let key = try await sharedKeychainRepository.getAuthenticatorKey() diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index 60c99324c1..c9a5d189f5 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift @@ -86,7 +86,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { XCTAssertNotNil(result) XCTAssertEqual(result.count, 1) - let decrypted = try await cryptoService.decryptAuthenticatorItemModels(result.compactMap(\.model)) + let decrypted = try await cryptoService.decryptAuthenticatorItems(result.compactMap(\.model)) let item = try XCTUnwrap(decrypted.first) XCTAssertEqual(item, expectedItem) } diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift index e0b1391fdb..8ae753c884 100644 --- a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift @@ -30,65 +30,31 @@ final class SharedCryptographyServiceTests: AuthenticatorBridgeKitTestCase { // MARK: Tests - /// Verify that `SharedCryptographyService.decryptAuthenticatorItemDatas(:)` correctly + /// Verify that `SharedCryptographyService.decryptAuthenticatorItems(:)` correctly /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. /// - func test_decryptAuthenticatorItemDatas_success() async throws { - let sharedDataStore = AuthenticatorBridgeDataStore( - errorReporter: MockErrorReporter(), - groupIdentifier: "com.example.bitwarden-authenticator", - storeType: .memory - ) - let encryptedItems = try await subject.encryptAuthenticatorItems(items) - let decryptedItems = try await subject.decryptAuthenticatorItemDatas( - encryptedItems.compactMap { item in - try? AuthenticatorBridgeItemData( - context: sharedDataStore.persistentContainer.viewContext, - userId: "userId", - authenticatorItem: item - ) - } - ) - - XCTAssertEqual(items, decryptedItems) - } - - /// Verify that `SharedCryptographyService.decryptAuthenticatorItemDatas()' throws - /// when the `SharedKeyRepository` authenticator key is missing. - /// - func test_decryptAuthenticatorItemDatas_throwsKeyMissingError() async throws { - let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) - - try sharedKeychainRepository.deleteAuthenticatorKey() - await assertAsyncThrows(error: error) { - _ = try await subject.decryptAuthenticatorItemDatas([]) - } - } - - /// Verify that `SharedCryptographyService.decryptAuthenticatorItemModels(:)` correctly - /// decrypts an encrypted array of `AuthenticatorBridgeItemDataModel`. - /// - func test_decryptAuthenticatorItemModels_success() async throws { + func test_decryptAuthenticatorItems_success() async throws { let encryptedItems = try await subject.encryptAuthenticatorItems(items) - let decryptedItems = try await subject.decryptAuthenticatorItemModels(encryptedItems) + let decryptedItems = try await subject.decryptAuthenticatorItems(encryptedItems) XCTAssertEqual(items, decryptedItems) } - /// Verify that `SharedCryptographyService.decryptAuthenticatorItemModels()' throws + /// Verify that `SharedCryptographyService.decryptAuthenticatorItems()' throws /// when the `SharedKeyRepository` authenticator key is missing. /// - func test_decryptAuthenticatorItemModels_throwsKeyMissingError() async throws { + func test_decryptAuthenticatorItems_throwsKeyMissingError() async throws { let error = AuthenticatorKeychainServiceError.keyNotFound(SharedKeychainItem.authenticatorKey) try sharedKeychainRepository.deleteAuthenticatorKey() await assertAsyncThrows(error: error) { - _ = try await subject.decryptAuthenticatorItemModels([]) + _ = try await subject.decryptAuthenticatorItems([]) } } /// Verify that `SharedCryptographyService.encryptAuthenticatorItems(:)` correctly - /// encrypts an array of `AuthenticatorBridgeItemDataModel`. + /// encrypts an array of `AuthenticatorBridgeItemDataView` into + /// `AuthenticatorBridgeItemDataModel`. /// func test_encryptAuthenticatorItems_success() async throws { let encryptedItems = try await subject.encryptAuthenticatorItems(items) diff --git a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift index 58dbce2c02..49c7ba84ad 100644 --- a/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -25,7 +25,7 @@ class MockSharedCryptographyService: SharedCryptographyService { } } - func decryptAuthenticatorItemModels( + func decryptAuthenticatorItems( _ items: [AuthenticatorBridgeItemDataModel] ) async throws -> [AuthenticatorBridgeItemDataView] { decryptCalled = true diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 3df3b04b24..d3d4e0c0f3 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -7,7 +7,7 @@ import UIKit // MARK: - AuthenticatorSyncService -/// The service used to share TOTP codes to and from the Authenticator app.. +/// The service used to share TOTP codes to and from the Authenticator app. /// protocol AuthenticatorSyncService { /// This starts the service listening for updates and writing to the shared store. This method @@ -131,19 +131,18 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// private func decryptTOTPs(_ ciphers: [Cipher], userId: String) async throws -> [AuthenticatorBridgeItemDataView] { - let decryptedCiphers = try await ciphers.asyncMap { cipher in - try await self.clientService.vault().ciphers().decrypt(cipher: cipher) - } - let account = try await stateService.getActiveAccount() - let username = account.profile.name ?? account.profile.email - - let ciphersToUse = decryptedCiphers.filter { cipher in + let totpCiphers = ciphers.filter { cipher in cipher.deletedDate == nil && cipher.type == .login && cipher.login?.totp != nil } + let decryptedCiphers = try await totpCiphers.asyncMap { cipher in + try await self.clientService.vault().ciphers().decrypt(cipher: cipher) + } + let account = try await stateService.getActiveAccount() + let username = account.profile.name ?? account.profile.email - return ciphersToUse.map { cipher in + return decryptedCiphers.map { cipher in AuthenticatorBridgeItemDataView( favorite: false, id: cipher.id ?? UUID().uuidString, @@ -160,18 +159,20 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - Parameter userId: The userId of the user who has turned on sync. /// private func handleSyncOnForUserId(_ userId: String) async { - Logger.application.log("#### sync is on for userId: \(userId)") + Logger.application.debug("#### sync is on for userId: \(userId)") - if !vaultTimeoutService.isLocked(userId: userId) { - Logger.application.log("#### App in foreground and unlocked. Begin key creations.") - do { - try await createAuthenticatorKeyIfNeeded() - } catch { - errorReporter.log(error: error) - } - Logger.application.log("#### Subscribing to cipher updates") - subscribeToCipherUpdates(userId: userId) + guard !vaultTimeoutService.isLocked(userId: userId) else { + return + } + + Logger.application.debug("#### App in foreground and unlocked. Begin key creations.") + do { + try await createAuthenticatorKeyIfNeeded() + } catch { + errorReporter.log(error: error) } + Logger.application.debug("#### Subscribing to cipher updates") + subscribeToCipherUpdates(userId: userId) } /// This function handles stopping sync and cleaning up all sync-related items when a user has turned sync Off. @@ -179,8 +180,8 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - Parameter userId: The userId of the user who has turned off sync. /// private func handleSyncOffForUserId(_ userId: String) { - Logger.application.log("#### sync is off for userId: \(userId)") - Logger.application.log("#### Canceling cipher update subscription") + Logger.application.debug("#### sync is off for userId: \(userId)") + Logger.application.debug("#### Canceling cipher update subscription") cipherPublisherTasks[userId]??.cancel() cipherPublisherTasks[userId] = nil } @@ -190,7 +191,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private func subscribeToAppState() { Task { for await _ in notificationCenterService.willEnterForegroundPublisher() { - Logger.application.log("#### app entered foreground") + Logger.application.debug("#### app entered foreground") subscribeToSyncToAuthenticatorSetting() } } @@ -223,7 +224,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { guard let userId else { continue } - Logger.application.log("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") + Logger.application.debug("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") if shouldSync { await handleSyncOnForUserId(userId) } else { @@ -241,7 +242,7 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// private func writeCiphers(ciphers: [Cipher], userId: String) async throws { let items = try await decryptTOTPs(ciphers, userId: userId) - Logger.application.log("#### replacing data for \(userId)") + Logger.application.debug("#### replacing data for \(userId)") try await authBridgeItemService.replaceAllItems(with: items, forUserId: userId) } } From 26b39522864101a68aadea08f1e0fa504411f9a4 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 27 Sep 2024 10:11:43 -0400 Subject: [PATCH 42/52] Fixed typo --- AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index 69b579af4d..a78d070b33 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -156,8 +156,8 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi .tryMap { dataItems in dataItems.compactMap(\.model) } - .asyncTryMap { itemModela in - try await self.cryptoService.decryptAuthenticatorItems(itemModela) + .asyncTryMap { itemModel in + try await self.cryptoService.decryptAuthenticatorItems(itemModel) } .eraseToAnyPublisher() } From a099e89dca08e1d4b79cb3bf7a9d9307607742a5 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Fri, 27 Sep 2024 14:29:55 -0400 Subject: [PATCH 43/52] Removed debug logs from code; fixed concurrnecy issue with tests --- .../Services/AuthenticatorSyncService.swift | 11 ----------- .../Services/AuthenticatorSyncServiceTests.swift | 16 ++++++++-------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index d3d4e0c0f3..c61bf6817e 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -2,8 +2,6 @@ import AuthenticatorBridgeKit import BitwardenSdk import CryptoKit import Foundation -import OSLog -import UIKit // MARK: - AuthenticatorSyncService @@ -159,19 +157,15 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - Parameter userId: The userId of the user who has turned on sync. /// private func handleSyncOnForUserId(_ userId: String) async { - Logger.application.debug("#### sync is on for userId: \(userId)") - guard !vaultTimeoutService.isLocked(userId: userId) else { return } - Logger.application.debug("#### App in foreground and unlocked. Begin key creations.") do { try await createAuthenticatorKeyIfNeeded() } catch { errorReporter.log(error: error) } - Logger.application.debug("#### Subscribing to cipher updates") subscribeToCipherUpdates(userId: userId) } @@ -180,8 +174,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - Parameter userId: The userId of the user who has turned off sync. /// private func handleSyncOffForUserId(_ userId: String) { - Logger.application.debug("#### sync is off for userId: \(userId)") - Logger.application.debug("#### Canceling cipher update subscription") cipherPublisherTasks[userId]??.cancel() cipherPublisherTasks[userId] = nil } @@ -191,7 +183,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private func subscribeToAppState() { Task { for await _ in notificationCenterService.willEnterForegroundPublisher() { - Logger.application.debug("#### app entered foreground") subscribeToSyncToAuthenticatorSetting() } } @@ -224,7 +215,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { guard let userId else { continue } - Logger.application.debug("#### Sync With Authenticator App Setting: \(shouldSync), userId: \(userId)") if shouldSync { await handleSyncOnForUserId(userId) } else { @@ -242,7 +232,6 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// private func writeCiphers(ciphers: [Cipher], userId: String) async throws { let items = try await decryptTOTPs(ciphers, userId: userId) - Logger.application.debug("#### replacing data for \(userId)") try await authBridgeItemService.replaceAllItems(with: items, forUserId: userId) } } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index ea4de058ea..7f9623d6ef 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -100,8 +100,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { configService.featureFlagsBool[.enableAuthenticatorSync] = true subject.start() stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( id: "1234", @@ -119,6 +117,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ) ), ]) + stateService.syncToAuthenticatorSubject.send(("1", true)) + notificationCenterService.willEnterForegroundSubject.send() waitFor(authBridgeItemService.replaceAllCalled) @@ -133,8 +133,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { configService.featureFlagsBool[.enableAuthenticatorSync] = true subject.start() stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( id: "1234", @@ -150,6 +148,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ) ), ]) + stateService.syncToAuthenticatorSubject.send(("1", true)) + notificationCenterService.willEnterForegroundSubject.send() waitFor(authBridgeItemService.replaceAllCalled) @@ -165,8 +165,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { configService.featureFlagsBool[.enableAuthenticatorSync] = true subject.start() stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( login: .fixture( @@ -175,6 +173,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ) ), ]) + stateService.syncToAuthenticatorSubject.send(("1", true)) + notificationCenterService.willEnterForegroundSubject.send() waitFor(authBridgeItemService.replaceAllCalled) @@ -193,8 +193,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { configService.featureFlagsBool[.enableAuthenticatorSync] = true subject.start() stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() cipherService.ciphersSubject.send([ .fixture( id: "1234", @@ -204,6 +202,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ) ), ]) + stateService.syncToAuthenticatorSubject.send(("1", true)) + notificationCenterService.willEnterForegroundSubject.send() waitFor(authBridgeItemService.replaceAllCalled) From eb567e1eb9fb77b3ecbb74765eaa5a6f5e3daaf9 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 1 Oct 2024 11:11:51 -0400 Subject: [PATCH 44/52] Repsond to PR feedback --- .../AuthenticatorBridgeItemService.swift | 4 ++-- .../Services/AuthenticatorSyncService.swift | 19 ++++++++++-------- .../AuthenticatorSyncServiceTests.swift | 20 +++++++++---------- .../Platform/Services/ServiceContainer.swift | 2 +- .../MockAuthenticatorBridgeItemService.swift | 2 +- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift index a78d070b33..c1dadc32e4 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift @@ -33,7 +33,7 @@ public protocol AuthenticatorBridgeItemService { /// /// - Returns: `true` if there is one or more accounts with sync turned on; `false` otherwise. /// - func isSyncOn() async throws -> Bool + func isSyncOn() async -> Bool /// Deletes all existing items for a given user and inserts new items for the list of items provided. /// @@ -106,7 +106,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi return try await cryptoService.decryptAuthenticatorItems(encryptedItems) } - public func isSyncOn() async throws -> Bool { + public func isSyncOn() async -> Bool { let key = try? await sharedKeychainRepository.getAuthenticatorKey() return key != nil } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index c61bf6817e..7c7085c19c 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -11,14 +11,14 @@ protocol AuthenticatorSyncService { /// This starts the service listening for updates and writing to the shared store. This method /// must be called for the service to do any syncing. /// - func start() + func start() async } // MARK: - DefaultAuthenticatorSyncService /// The default `AuthenticatorSyncService` type for the application. /// -class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { +actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { // MARK: Private Properties /// The service for managing sharing items to/from the Authenticator app. @@ -45,6 +45,9 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// The keychain repository for managing the key shared between the PM and Authenticator apps. private let sharedKeychainRepository: SharedKeychainRepository + /// Whether or not the service has been started. + private var started: Bool = false + /// The service used by the application to manage account state. private let stateService: StateService @@ -96,12 +99,12 @@ class DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { // MARK: Public Methods - public func start() { - Task { - if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, - defaultValue: false) { - subscribeToAppState() - } + public func start() async { + guard !started else { return } + started = true + if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, + defaultValue: false) { + subscribeToAppState() } } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 7f9623d6ef..55375dfc55 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -67,7 +67,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) @@ -82,7 +82,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() let key = sharedKeychainRepository.generateKeyData() try await sharedKeychainRepository.setAuthenticatorKey(key) @@ -98,7 +98,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_filtersOutDeleted() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() stateService.activeAccount = .fixture() cipherService.ciphersSubject.send([ .fixture( @@ -131,7 +131,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() stateService.activeAccount = .fixture() cipherService.ciphersSubject.send([ .fixture( @@ -163,7 +163,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_providesIdIfNil() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() stateService.activeAccount = .fixture() cipherService.ciphersSubject.send([ .fixture( @@ -191,7 +191,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_decryptTOTPs_success() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() stateService.activeAccount = .fixture() cipherService.ciphersSubject.send([ .fixture( @@ -219,7 +219,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_handleSyncOn_error() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() sharedKeychainRepository.errorToThrow = BitwardenTestError.example stateService.activeAccount = .fixture() @@ -233,7 +233,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_handleSyncOff() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", false)) notificationCenterService.willEnterForegroundSubject.send() @@ -256,7 +256,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_start_featureFlagOff() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = false - subject.start() + await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) @@ -270,7 +270,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// func test_subscribeToCipherUpdates_error() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - subject.start() + await subject.start() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 7707453632..63735c3339 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -639,7 +639,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le stateService: stateService, vaultTimeoutService: vaultTimeoutService ) - authenticatorSyncService.start() + Task { await authenticatorSyncService.start() } self.init( apiService: apiService, diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift index fe0d88e515..d1793ca9f9 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockAuthenticatorBridgeItemService.swift @@ -21,7 +21,7 @@ class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService { storedItems[userId] = items } - func isSyncOn() async throws -> Bool { + func isSyncOn() async -> Bool { syncOn } From d7a1fd3724ce97bfadd6474c6a722ffaf79bd8c4 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Tue, 1 Oct 2024 12:45:21 -0400 Subject: [PATCH 45/52] Update to support new MainActor requirement for configService --- .../Tests/AuthenticatorBridgeItemServiceTests.swift | 4 ++-- .../Services/AuthenticatorSyncServiceTests.swift | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift index 1aad812cf9..3171d7fb64 100644 --- a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemServiceTests.swift @@ -115,7 +115,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase /// func test_isSyncOn_false() async throws { try keychainRepository.deleteAuthenticatorKey() - let sync = try await subject.isSyncOn() + let sync = await subject.isSyncOn() XCTAssertFalse(sync) } @@ -124,7 +124,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase func test_isSyncOn_true() async throws { let key = keychainRepository.generateKeyData() try await keychainRepository.setAuthenticatorKey(key) - let sync = try await subject.isSyncOn() + let sync = await subject.isSyncOn() XCTAssertTrue(sync) } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 55375dfc55..96b1bb73d4 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -65,6 +65,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// `createAuthenticatorKeyIfNeeded` method successfully creates the sync key /// if it is not already present /// + @MainActor func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -80,6 +81,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// `createAuthenticatorKeyIfNeeded` method successfully retrieves the key in /// SharedKeyRepository and doesn't recreate it. /// + @MainActor func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -96,6 +98,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// When Ciphers are published. the service filters out ones that have a deletedDate in the past. /// + @MainActor func test_decryptTOTPs_filtersOutDeleted() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -129,6 +132,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// When Ciphers are published. the service ignores any Ciphers with logins that don't contain a TOTP key. /// + @MainActor func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -161,6 +165,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService responds to new Ciphers published and provides a generated UUID if the /// Cipher has no id itself. /// + @MainActor func test_decryptTOTPs_providesIdIfNil() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -189,6 +194,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService responds to new Ciphers published by converting them into ItemViews and /// passes them to the ItemService for storage. /// + @MainActor func test_decryptTOTPs_success() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -217,6 +223,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. /// + @MainActor func test_handleSyncOn_error() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -231,6 +238,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. /// + @MainActor func test_handleSyncOff() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() @@ -254,6 +262,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Starting the service when the feature flag is off should do nothing - no subscriptions or responses. /// + @MainActor func test_start_featureFlagOff() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = false await subject.start() @@ -268,6 +277,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service.. /// + @MainActor func test_subscribeToCipherUpdates_error() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() From 79cc61bc0a0f4f10394f0a5a9b2632f5221c3252 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Wed, 2 Oct 2024 10:09:50 -0400 Subject: [PATCH 46/52] Added new approach to Vault unlocking and new tests. --- .../Services/AuthenticatorSyncService.swift | 99 ++++----- .../AuthenticatorSyncServiceTests.swift | 199 +++++++++++++++--- .../Platform/Services/ServiceContainer.swift | 2 +- .../Vault/Services/CipherServiceTests.swift | 3 +- .../TestHelpers/MockCipherDataStore.swift | 12 +- .../Core/Vault/Services/SyncService.swift | 2 +- 6 files changed, 228 insertions(+), 89 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 7c7085c19c..2875de7faf 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -28,7 +28,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private var cipherPublisherTasks = [String: Task?]() /// The service used to manage syncing and updates to the user's ciphers. - private let cipherService: CipherService + private let cipherDataStore: CipherDataStore /// The service that handles common client functionality such as encryption and decryption. private let clientService: ClientService @@ -55,6 +55,10 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// a user opts-in to Authenticator sync. private var syncSettingSubscriberTask: Task? + /// a Task that subscribes to the vault lock publisher for accounts. This allows us to take action once + /// a user unlocks their vault.. + private var vaultUnlockSubscriberTask: Task? + /// The service used by the application to manage vault access. private let vaultTimeoutService: VaultTimeoutService @@ -64,7 +68,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// /// - Parameters: /// - authBridgeItemService: The service for managing sharing items to/from the Authenticator app. - /// - cipherService: The service used to manage syncing and updates to the user's ciphers. + /// - cipherDataStore: The service used to manage syncing and updates to the user's ciphers. /// - clientService: The service that handles common client functionality such as encryption and decryption. /// - configService: The service to get server-specified configuration. /// - errorReporter: The service used by the application to report non-fatal errors.\ organizations. @@ -76,7 +80,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// init( authBridgeItemService: AuthenticatorBridgeItemService, - cipherService: CipherService, + cipherDataStore: CipherDataStore, clientService: ClientService, configService: ConfigService, errorReporter: ErrorReporter, @@ -86,7 +90,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { vaultTimeoutService: VaultTimeoutService ) { self.authBridgeItemService = authBridgeItemService - self.cipherService = cipherService + self.cipherDataStore = cipherDataStore self.clientService = clientService self.configService = configService self.errorReporter = errorReporter @@ -102,9 +106,33 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { public func start() async { guard !started else { return } started = true - if await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, - defaultValue: false) { - subscribeToAppState() + + guard await configService.getFeatureFlag(FeatureFlag.enableAuthenticatorSync, + defaultValue: false) else { + return + } + + syncSettingSubscriberTask = Task { + for await (userId, _) in await self.stateService.syncToAuthenticatorPublisher().values { + guard let userId else { continue } + + do { + try await determineSyncForUserId(userId) + } catch { + errorReporter.log(error: error) + } + } + } + vaultUnlockSubscriberTask = Task { + for await vaultStatus in await self.vaultTimeoutService.vaultLockStatusPublisher().values { + guard let vaultStatus else { break } + + do { + try await determineSyncForUserId(vaultStatus.userId) + } catch { + errorReporter.log(error: error) + } + } } } @@ -154,43 +182,24 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } - /// This function handles the initial syncing with the Authenticator app as well as listening for updates - /// when the user adds new items. This is called when the sync is turned on. + /// Determine if the given userId has sync turned on and an unlocked vault. This method serves as the + /// integration point of both the sync settings subscriber and the vault subscriber. When the user has sync turned + /// on and the vault unlocked, we can proceed with the sync. /// /// - Parameter userId: The userId of the user who has turned on sync. /// - private func handleSyncOnForUserId(_ userId: String) async { - guard !vaultTimeoutService.isLocked(userId: userId) else { + private func determineSyncForUserId(_ userId: String) async throws { + guard try await stateService.getSyncToAuthenticator(userId: userId), + !vaultTimeoutService.isLocked(userId: userId) else { + cipherPublisherTasks[userId]??.cancel() + cipherPublisherTasks[userId] = nil return } - do { - try await createAuthenticatorKeyIfNeeded() - } catch { - errorReporter.log(error: error) - } + try await createAuthenticatorKeyIfNeeded() subscribeToCipherUpdates(userId: userId) } - /// This function handles stopping sync and cleaning up all sync-related items when a user has turned sync Off. - /// - /// - Parameter userId: The userId of the user who has turned off sync. - /// - private func handleSyncOffForUserId(_ userId: String) { - cipherPublisherTasks[userId]??.cancel() - cipherPublisherTasks[userId] = nil - } - - /// Subscribe to NotificationCenter updates about if the app is in the foreground vs. background. - /// - private func subscribeToAppState() { - Task { - for await _ in notificationCenterService.willEnterForegroundPublisher() { - subscribeToSyncToAuthenticatorSetting() - } - } - } - /// Create a task for the given userId to listen for Cipher updates and sync to the Authenticator store. /// /// - Parameter userId: The userId of the account to listen for. @@ -200,7 +209,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { cipherPublisherTasks[userId] = Task { do { - for try await ciphers in try await self.cipherService.ciphersPublisher().values { + for try await ciphers in self.cipherDataStore.cipherPublisher(userId: userId).values { try await writeCiphers(ciphers: ciphers, userId: userId) } } catch { @@ -209,24 +218,6 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } - /// Subscribe to the Sync to Authenticator setting to handle when the user grants (or revokes) - /// permission to sync items to the Authenticator app. - /// - private func subscribeToSyncToAuthenticatorSetting() { - syncSettingSubscriberTask?.cancel() - syncSettingSubscriberTask = Task { - for await (userId, shouldSync) in await self.stateService.syncToAuthenticatorPublisher().values { - guard let userId else { continue } - - if shouldSync { - await handleSyncOnForUserId(userId) - } else { - handleSyncOffForUserId(userId) - } - } - } - } - /// Takes in a list of encrypted Ciphers, decrypts them, and writes ones with TOTP codes to the shared store. /// /// - Parameters: diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 96b1bb73d4..5876145e85 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -1,12 +1,13 @@ import AuthenticatorBridgeKit +import BitwardenSdk import Combine import XCTest @testable import BitwardenShared -final class AuthenticatorSyncServiceTests: BitwardenTestCase { +final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length var authBridgeItemService: MockAuthenticatorBridgeItemService! - var cipherService: MockCipherService! + var cipherDataStore: MockCipherDataStore! var clientService: MockClientService! var configService: MockConfigService! var errorReporter: MockErrorReporter! @@ -22,7 +23,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { super.setUp() authBridgeItemService = MockAuthenticatorBridgeItemService() - cipherService = MockCipherService() + cipherDataStore = MockCipherDataStore() configService = MockConfigService() clientService = MockClientService() errorReporter = MockErrorReporter() @@ -33,7 +34,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { subject = DefaultAuthenticatorSyncService( authBridgeItemService: authBridgeItemService, - cipherService: cipherService, + cipherDataStore: cipherDataStore, clientService: clientService, configService: configService, errorReporter: errorReporter, @@ -48,7 +49,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { super.tearDown() authBridgeItemService = nil - cipherService = nil + cipherDataStore = nil configService = nil clientService = nil errorReporter = nil @@ -67,12 +68,13 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) } @@ -83,6 +85,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() let key = sharedKeychainRepository.generateKeyData() @@ -90,7 +94,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() waitFor(sharedKeychainRepository.authenticatorKey != nil) XCTAssertEqual(sharedKeychainRepository.authenticatorKey, key) @@ -100,10 +103,13 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_filtersOutDeleted() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -121,8 +127,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - waitFor(authBridgeItemService.replaceAllCalled) let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) @@ -134,10 +138,13 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -153,8 +160,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - waitFor(authBridgeItemService.replaceAllCalled) let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) @@ -167,10 +172,13 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_providesIdIfNil() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( login: .fixture( username: "user@bitwarden.com", @@ -179,8 +187,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - waitFor(authBridgeItemService.replaceAllCalled) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) @@ -196,10 +202,13 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_decryptTOTPs_success() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() stateService.activeAccount = .fixture() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -209,8 +218,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - waitFor(authBridgeItemService.replaceAllCalled) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) @@ -225,27 +232,158 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { /// @MainActor func test_handleSyncOn_error() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = false + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() sharedKeychainRepository.errorToThrow = BitwardenTestError.example stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() - waitFor(!errorReporter.errors.isEmpty) } + /// When the sync is turned on, but the vault is locked, the service should subscribe and wait + /// for the vault unlock to occur. + /// + @MainActor + func test_handleSyncOn_vaultLocked() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = true + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + configService.featureFlagsBool[.enableAuthenticatorSync] = true + await subject.start() + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorSubject.send(("1", true)) + + vaultTimeoutService.isClientLocked["1"] = false + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: false, userId: "1") + ) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.replaceAllCalled) + + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertEqual(item.id, "1234") + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + } + + /// When user "1" has sync turned on and user "2" unlocks their vault, the service should not take + /// any action because "1" has a locked vault and "2" doesn't have sync turned on. + /// + @MainActor + func test_handleSyncOn_unlockDifferentVault() async throws { + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorByUserId["1"] = true + vaultTimeoutService.isClientLocked["1"] = true + configService.featureFlagsBool[.enableAuthenticatorSync] = true + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + + vaultTimeoutService.isClientLocked["2"] = false + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: false, userId: "2") + ) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertFalse(authBridgeItemService.replaceAllCalled) + } + + /// The sync service should handle multiple vaults being sync'd at the same time. + /// + @MainActor + func test_handleSyncOn_unlockMultipleVaults() async throws { + stateService.syncToAuthenticatorByUserId["1"] = true + await stateService.addAccount(.fixture()) + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + cipherDataStore.cipherSubjectByUserId["2"] = CurrentValueSubject<[Cipher], Error>([]) + vaultTimeoutService.isClientLocked["1"] = false + configService.featureFlagsBool[.enableAuthenticatorSync] = true + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + waitFor(authBridgeItemService.replaceAllCalled) + + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertEqual(item.id, "1234") + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + + authBridgeItemService.replaceAllCalled = false + + await stateService.addAccount(.fixture(profile: .fixture(email: "different@bitwarden.com", + userId: "2"))) + stateService.syncToAuthenticatorByUserId["2"] = true + vaultTimeoutService.isClientLocked["2"] = false + stateService.syncToAuthenticatorSubject.send(("2", true)) + + cipherDataStore.cipherSubjectByUserId["2"]?.send([ + .fixture( + id: "4321", + login: .fixture( + username: "different@bitwarden.com", + totp: "totp2" + ) + ), + ]) + waitFor(authBridgeItemService.replaceAllCalled) + + let items = try XCTUnwrap(authBridgeItemService.storedItems["2"]) + let otherItem = try XCTUnwrap(items.first) + XCTAssertEqual(otherItem.favorite, false) + XCTAssertEqual(otherItem.id, "4321") + XCTAssertEqual(otherItem.name, "Bitwarden") + XCTAssertEqual(otherItem.totpKey, "totp2") + XCTAssertEqual(otherItem.username, "different@bitwarden.com") + } + /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. /// @MainActor func test_handleSyncOff() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - await subject.start() stateService.activeAccount = .fixture() + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + await subject.start() stateService.syncToAuthenticatorSubject.send(("1", false)) - notificationCenterService.willEnterForegroundSubject.send() - cipherService.ciphersSubject.send([ + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", login: .fixture( @@ -265,11 +403,11 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { @MainActor func test_start_featureFlagOff() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = false + stateService.activeAccount = .fixture() + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() try await Task.sleep(nanoseconds: 10_000_000) XCTAssertNil(sharedKeychainRepository.authenticatorKey) @@ -280,12 +418,15 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { @MainActor func test_subscribeToCipherUpdates_error() async throws { configService.featureFlagsBool[.enableAuthenticatorSync] = true - await subject.start() stateService.activeAccount = .fixture() + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + stateService.syncToAuthenticatorByUserId["1"] = true + await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) notificationCenterService.willEnterForegroundSubject.send() - cipherService.ciphersSubject.send(completion: .failure(BitwardenTestError.example)) + + cipherDataStore.cipherSubjectByUserId["1"]?.send(completion: .failure(BitwardenTestError.example)) waitFor(!errorReporter.errors.isEmpty) } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index b9ea329b72..3d9a59ffa7 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -631,7 +631,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let authenticatorSyncService = DefaultAuthenticatorSyncService( authBridgeItemService: authBridgeItemService, - cipherService: cipherService, + cipherDataStore: dataStore, clientService: clientService, configService: configService, errorReporter: errorReporter, diff --git a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift index 0f0261728d..eb86e3cae9 100644 --- a/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/CipherServiceTests.swift @@ -78,7 +78,8 @@ class CipherServiceTests: BitwardenTestCase { _ = try await iterator.next() let cipher = Cipher.fixture() - cipherDataStore.cipherSubject.value = [cipher] + let userId = stateService.activeAccount?.profile.userId ?? "" + cipherDataStore.cipherSubjectByUserId[userId]?.value = [cipher] let publisherValue = try await iterator.next() try XCTAssertEqual(XCTUnwrap(publisherValue), [cipher]) } diff --git a/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift b/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift index ac39f5f127..21054b75ad 100644 --- a/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift +++ b/BitwardenShared/Core/Vault/Services/Stores/TestHelpers/MockCipherDataStore.swift @@ -15,7 +15,7 @@ class MockCipherDataStore: CipherDataStore { var fetchCipherId: String? var fetchCipherResult: Cipher? - var cipherSubject = CurrentValueSubject<[Cipher], Error>([]) + var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:] var replaceCiphersValue: [Cipher]? var replaceCiphersUserId: String? @@ -42,8 +42,14 @@ class MockCipherDataStore: CipherDataStore { return fetchCipherResult } - func cipherPublisher(userId _: String) -> AnyPublisher<[Cipher], Error> { - cipherSubject.eraseToAnyPublisher() + func cipherPublisher(userId: String) -> AnyPublisher<[Cipher], Error> { + if let subject = cipherSubjectByUserId[userId] { + return subject.eraseToAnyPublisher() + } else { + let subject = CurrentValueSubject<[Cipher], Error>([]) + cipherSubjectByUserId[userId] = subject + return subject.eraseToAnyPublisher() + } } func replaceCiphers(_ ciphers: [Cipher], userId: String) async throws { diff --git a/BitwardenShared/Core/Vault/Services/SyncService.swift b/BitwardenShared/Core/Vault/Services/SyncService.swift index 23f9a15716..f798233acf 100644 --- a/BitwardenShared/Core/Vault/Services/SyncService.swift +++ b/BitwardenShared/Core/Vault/Services/SyncService.swift @@ -145,7 +145,7 @@ class DefaultSyncService: SyncService { /// /// - Parameters: /// - accountAPIService: The services used by the application to make account related API requests. - /// - cipherService: The service for managing the ciphers for the user. + /// - cipherDataStore: The service for managing the ciphers for the user. /// - clientService: The service that handles common client functionality such as encryption and decryption. /// - collectionService: The service for managing the collections for the user. /// - folderService: The service for managing the folders for the user. From 162b1a4ff4f64957534935259dabc1e79200bd1a Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 3 Oct 2024 09:05:01 -0400 Subject: [PATCH 47/52] Cleaned up tests, fixed bug that tests revealed --- .../Services/AuthenticatorSyncService.swift | 4 +- .../AuthenticatorSyncServiceTests.swift | 216 +++++++++++------- 2 files changed, 133 insertions(+), 87 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 2875de7faf..61581af69f 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -125,7 +125,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } vaultUnlockSubscriberTask = Task { for await vaultStatus in await self.vaultTimeoutService.vaultLockStatusPublisher().values { - guard let vaultStatus else { break } + guard let vaultStatus else { continue } do { try await determineSyncForUserId(vaultStatus.userId) @@ -169,7 +169,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { try await self.clientService.vault().ciphers().decrypt(cipher: cipher) } let account = try await stateService.getActiveAccount() - let username = account.profile.name ?? account.profile.email + let username = account.profile.email return decryptedCiphers.map { cipher in AuthenticatorBridgeItemDataView( diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 5876145e85..76caf8a7ea 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -68,12 +68,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_createAuthenticatorKeyIfNeeded_createsKeyWhenNeeded() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) waitFor(sharedKeychainRepository.authenticatorKey != nil) @@ -85,14 +82,11 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_createAuthenticatorKeyIfNeeded_keyAlreadyExists() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() let key = sharedKeychainRepository.generateKeyData() try await sharedKeychainRepository.setAuthenticatorKey(key) - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) waitFor(sharedKeychainRepository.authenticatorKey != nil) @@ -103,12 +97,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_decryptTOTPs_filtersOutDeleted() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", @@ -127,7 +117,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) XCTAssertEqual(items.count, 1) @@ -138,12 +128,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_decryptTOTPs_ignoresItemsWithoutTOTP() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", @@ -160,7 +146,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) XCTAssertEqual(items.count, 1) @@ -172,12 +158,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_decryptTOTPs_providesIdIfNil() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( login: .fixture( @@ -187,7 +169,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) XCTAssertEqual(item.favorite, false) @@ -202,12 +184,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_decryptTOTPs_success() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - configService.featureFlagsBool[.enableAuthenticatorSync] = true + setupInitialState() await subject.start() - stateService.activeAccount = .fixture() cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", @@ -218,7 +196,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ), ]) stateService.syncToAuthenticatorSubject.send(("1", true)) - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) XCTAssertEqual(item.favorite, false) @@ -231,37 +209,37 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// Verifies that the AuthSyncService handles and reports errors when sync is turned On.. /// @MainActor - func test_handleSyncOn_error() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = false - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - configService.featureFlagsBool[.enableAuthenticatorSync] = true + func test_determineSyncForUserId_error() async throws { + setupInitialState() await subject.start() sharedKeychainRepository.errorToThrow = BitwardenTestError.example - stateService.activeAccount = .fixture() stateService.syncToAuthenticatorSubject.send(("1", true)) waitFor(!errorReporter.errors.isEmpty) } - /// When the sync is turned on, but the vault is locked, the service should subscribe and wait - /// for the vault unlock to occur. + /// Verifies that the AuthSyncService handles and reports errors when vault is unlocked /// @MainActor - func test_handleSyncOn_vaultLocked() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - vaultTimeoutService.isClientLocked["1"] = true - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - configService.featureFlagsBool[.enableAuthenticatorSync] = true + func test_determineSyncForUserId_errorHandledByVaultSubscriber() async throws { + setupInitialState() + sharedKeychainRepository.errorToThrow = BitwardenTestError.example await subject.start() - stateService.activeAccount = .fixture() - stateService.syncToAuthenticatorSubject.send(("1", true)) - vaultTimeoutService.isClientLocked["1"] = false vaultTimeoutService.vaultLockStatusSubject.send( VaultLockStatus(isVaultLocked: false, userId: "1") ) + waitFor(!errorReporter.errors.isEmpty) + } + /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. + /// + @MainActor + func test_determineSyncForUserId_syncOff() async throws { + setupInitialState() + await subject.start() + stateService.syncToAuthenticatorByUserId["1"] = false + stateService.syncToAuthenticatorSubject.send(("1", false)) cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", @@ -272,21 +250,16 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ), ]) - waitFor(authBridgeItemService.replaceAllCalled) + try await Task.sleep(nanoseconds: 100_000_000) - let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) - XCTAssertEqual(item.favorite, false) - XCTAssertEqual(item.id, "1234") - XCTAssertEqual(item.name, "Bitwarden") - XCTAssertEqual(item.totpKey, "totp") - XCTAssertEqual(item.username, "user@bitwarden.com") + XCTAssertFalse(authBridgeItemService.replaceAllCalled) } /// When user "1" has sync turned on and user "2" unlocks their vault, the service should not take /// any action because "1" has a locked vault and "2" doesn't have sync turned on. /// @MainActor - func test_handleSyncOn_unlockDifferentVault() async throws { + func test_determineSyncForUserId_unlockDifferentVault() async throws { stateService.activeAccount = .fixture() stateService.syncToAuthenticatorByUserId["1"] = true vaultTimeoutService.isClientLocked["1"] = true @@ -318,13 +291,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// The sync service should handle multiple vaults being sync'd at the same time. /// @MainActor - func test_handleSyncOn_unlockMultipleVaults() async throws { - stateService.syncToAuthenticatorByUserId["1"] = true - await stateService.addAccount(.fixture()) - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + func test_determineSyncForUserId_unlockMultipleVaults() async throws { + setupInitialState() cipherDataStore.cipherSubjectByUserId["2"] = CurrentValueSubject<[Cipher], Error>([]) - vaultTimeoutService.isClientLocked["1"] = false - configService.featureFlagsBool[.enableAuthenticatorSync] = true await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) @@ -337,7 +306,7 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ) ), ]) - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) XCTAssertEqual(item.favorite, false) @@ -346,8 +315,6 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa XCTAssertEqual(item.totpKey, "totp") XCTAssertEqual(item.username, "user@bitwarden.com") - authBridgeItemService.replaceAllCalled = false - await stateService.addAccount(.fixture(profile: .fixture(email: "different@bitwarden.com", userId: "2"))) stateService.syncToAuthenticatorByUserId["2"] = true @@ -363,10 +330,9 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ) ), ]) - waitFor(authBridgeItemService.replaceAllCalled) + waitFor(authBridgeItemService.storedItems["2"]?.first != nil) - let items = try XCTUnwrap(authBridgeItemService.storedItems["2"]) - let otherItem = try XCTUnwrap(items.first) + let otherItem = try XCTUnwrap(authBridgeItemService.storedItems["2"]?.first) XCTAssertEqual(otherItem.favorite, false) XCTAssertEqual(otherItem.id, "4321") XCTAssertEqual(otherItem.name, "Bitwarden") @@ -374,15 +340,20 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa XCTAssertEqual(otherItem.username, "different@bitwarden.com") } - /// Verifies that the AuthSyncService stops listening for Cipher updates when the user has sync turned off. + /// When the sync is turned on, but the vault is locked, the service should subscribe and wait + /// for the vault unlock to occur. /// @MainActor - func test_handleSyncOff() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true - stateService.activeAccount = .fixture() - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + func test_determineSyncForUserId_vaultUnlocked() async throws { + setupInitialState(vaultLocked: true) await subject.start() - stateService.syncToAuthenticatorSubject.send(("1", false)) + stateService.syncToAuthenticatorSubject.send(("1", true)) + + vaultTimeoutService.isClientLocked["1"] = false + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: false, userId: "1") + ) + cipherDataStore.cipherSubjectByUserId["1"]?.send([ .fixture( id: "1234", @@ -393,18 +364,50 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa ), ]) - try await Task.sleep(nanoseconds: 100_000_000) + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) - XCTAssertFalse(authBridgeItemService.replaceAllCalled) + let item = try XCTUnwrap(authBridgeItemService.storedItems["1"]?.first) + XCTAssertEqual(item.favorite, false) + XCTAssertEqual(item.id, "1234") + XCTAssertEqual(item.name, "Bitwarden") + XCTAssertEqual(item.totpKey, "totp") + XCTAssertEqual(item.username, "user@bitwarden.com") + } + + /// Verifies that the AuthSyncService stops listening for Cipher updates when the user's vault is locked. + /// + @MainActor + func test_determineSyncForUserId_vaultLocked() async throws { + setupInitialState() + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + try await Task.sleep(nanoseconds: 10_000_000) + + vaultTimeoutService.isClientLocked["1"] = true + vaultTimeoutService.vaultLockStatusSubject.send( + VaultLockStatus(isVaultLocked: true, userId: "1") + ) + try await Task.sleep(nanoseconds: 10_000_000) + + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertNil(authBridgeItemService.storedItems["1"]?.first) } /// Starting the service when the feature flag is off should do nothing - no subscriptions or responses. /// @MainActor func test_start_featureFlagOff() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = false - stateService.activeAccount = .fixture() - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + setupInitialState(syncOn: false) await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() stateService.syncToAuthenticatorSubject.send(("1", true)) @@ -413,20 +416,63 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa XCTAssertNil(sharedKeychainRepository.authenticatorKey) } - /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service.. + /// If the `start()` method is called multiple times, it should only start once - i.e. only one set of listeners, + /// no double sync, etc. + /// + @MainActor + func test_start_multipleStartsIgnored() async throws { + setupInitialState() + await subject.start() + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + + waitFor(authBridgeItemService.storedItems["1"]?.first != nil) + let items = try XCTUnwrap(authBridgeItemService.storedItems["1"]) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.id, "1234") + } + + /// Verifies that the AuthSyncService handles and reports errors thrown by the Cipher service. /// @MainActor func test_subscribeToCipherUpdates_error() async throws { - configService.featureFlagsBool[.enableAuthenticatorSync] = true - stateService.activeAccount = .fixture() - cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) - stateService.syncToAuthenticatorByUserId["1"] = true + setupInitialState() await subject.start() stateService.syncToAuthenticatorSubject.send(("1", true)) - notificationCenterService.willEnterForegroundSubject.send() cipherDataStore.cipherSubjectByUserId["1"]?.send(completion: .failure(BitwardenTestError.example)) waitFor(!errorReporter.errors.isEmpty) } + + // MARK: - Private Methods + + /// Helper function that sets up testing parameters based on the flags passed in + /// + /// Note: The defaults passed in set everything up for sync to work immediately - sync on and vault unlocked. + /// All that is necessary is to publish the sync setting or the vault status as-is to kick off sync. Override + /// to turn sync off. + /// + /// - Parameters: + /// - syncOn: The state of the syncToAuthenticator feature flag. Defaults to `true`. `true` means sync is enabled. + /// - vaultLocked: The state of the vault - `true` means the vault is locked. + /// `false` means the vault is unlocked. Defaults to `false` + /// + @MainActor + private func setupInitialState(syncOn: Bool = true, vaultLocked: Bool = false) { + cipherDataStore.cipherSubjectByUserId["1"] = CurrentValueSubject<[Cipher], Error>([]) + configService.featureFlagsBool[.enableAuthenticatorSync] = true + stateService.activeAccount = .fixture() + stateService.syncToAuthenticatorByUserId["1"] = syncOn + vaultTimeoutService.isClientLocked["1"] = vaultLocked + } } // swiftlint:disable:this file_length From 019ba964da10c8727f11e78958a8b9d2e38db933 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 3 Oct 2024 09:40:41 -0400 Subject: [PATCH 48/52] Clean up doc comments --- .../Core/Platform/Services/AuthenticatorSyncService.swift | 2 +- BitwardenShared/Core/Vault/Services/SyncService.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 61581af69f..5c1590bc6f 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -186,7 +186,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// integration point of both the sync settings subscriber and the vault subscriber. When the user has sync turned /// on and the vault unlocked, we can proceed with the sync. /// - /// - Parameter userId: The userId of the user who has turned on sync. + /// - Parameter userId: The userId of the user whose sync status is being determined. /// private func determineSyncForUserId(_ userId: String) async throws { guard try await stateService.getSyncToAuthenticator(userId: userId), diff --git a/BitwardenShared/Core/Vault/Services/SyncService.swift b/BitwardenShared/Core/Vault/Services/SyncService.swift index f798233acf..23f9a15716 100644 --- a/BitwardenShared/Core/Vault/Services/SyncService.swift +++ b/BitwardenShared/Core/Vault/Services/SyncService.swift @@ -145,7 +145,7 @@ class DefaultSyncService: SyncService { /// /// - Parameters: /// - accountAPIService: The services used by the application to make account related API requests. - /// - cipherDataStore: The service for managing the ciphers for the user. + /// - cipherService: The service for managing the ciphers for the user. /// - clientService: The service that handles common client functionality such as encryption and decryption. /// - collectionService: The service for managing the collections for the user. /// - folderService: The service for managing the folders for the user. From cb2fd64bcb15a6a4f777be0254b3496ef8d3fcd0 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 3 Oct 2024 09:44:13 -0400 Subject: [PATCH 49/52] Fixed test for feature flag off --- .../Core/Platform/Services/AuthenticatorSyncServiceTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 76caf8a7ea..0620f8b222 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -407,7 +407,8 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa /// @MainActor func test_start_featureFlagOff() async throws { - setupInitialState(syncOn: false) + setupInitialState() + configService.featureFlagsBool[.enableAuthenticatorSync] = false await subject.start() try sharedKeychainRepository.deleteAuthenticatorKey() stateService.syncToAuthenticatorSubject.send(("1", true)) From f877aa35eb1c64780a281469b91cbd21cfffc8cc Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Thu, 3 Oct 2024 12:14:41 -0400 Subject: [PATCH 50/52] Removed property references to the two long-running tasks, per PR suggestion --- .../Platform/Services/AuthenticatorSyncService.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 5c1590bc6f..311a87362e 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -51,14 +51,6 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// The service used by the application to manage account state. private let stateService: StateService - /// a Task that subscribes to the sync setting publisher for accounts. This allows us to take action once - /// a user opts-in to Authenticator sync. - private var syncSettingSubscriberTask: Task? - - /// a Task that subscribes to the vault lock publisher for accounts. This allows us to take action once - /// a user unlocks their vault.. - private var vaultUnlockSubscriberTask: Task? - /// The service used by the application to manage vault access. private let vaultTimeoutService: VaultTimeoutService @@ -112,7 +104,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { return } - syncSettingSubscriberTask = Task { + Task { for await (userId, _) in await self.stateService.syncToAuthenticatorPublisher().values { guard let userId else { continue } @@ -123,7 +115,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { } } } - vaultUnlockSubscriberTask = Task { + Task { for await vaultStatus in await self.vaultTimeoutService.vaultLockStatusPublisher().values { guard let vaultStatus else { continue } From 5735a983c22be71a06475704308d4b745c04a564 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 7 Oct 2024 09:08:59 -0400 Subject: [PATCH 51/52] Respond to PR feedback - ensure Vault is unlocked when decrypting --- .../Services/AuthenticatorSyncService.swift | 6 +++-- .../AuthenticatorSyncServiceTests.swift | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index 311a87362e..bc8db33ba2 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -158,7 +158,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { && cipher.login?.totp != nil } let decryptedCiphers = try await totpCiphers.asyncMap { cipher in - try await self.clientService.vault().ciphers().decrypt(cipher: cipher) + try await self.clientService.vault(for: userId).ciphers().decrypt(cipher: cipher) } let account = try await stateService.getActiveAccount() let username = account.profile.email @@ -184,7 +184,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { guard try await stateService.getSyncToAuthenticator(userId: userId), !vaultTimeoutService.isLocked(userId: userId) else { cipherPublisherTasks[userId]??.cancel() - cipherPublisherTasks[userId] = nil + cipherPublisherTasks.removeValue(forKey: userId) return } @@ -217,6 +217,8 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { /// - userId: The userId of the account to which the Ciphers belong. /// private func writeCiphers(ciphers: [Cipher], userId: String) async throws { + guard !vaultTimeoutService.isLocked(userId: userId) else { return } + let items = try await decryptTOTPs(ciphers, userId: userId) try await authBridgeItemService.replaceAllItems(with: items, forUserId: userId) } diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift index 0620f8b222..b57b7ebfaf 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncServiceTests.swift @@ -455,6 +455,31 @@ final class AuthenticatorSyncServiceTests: BitwardenTestCase { // swiftlint:disa waitFor(!errorReporter.errors.isEmpty) } + /// The AuthService may not get notified about Vault locking if the user has switched accounts. Verify + /// that it checks the vault lock before beginning to decrypt and write new Ciphers received. + /// + @MainActor + func test_writeCiphers_vaultLocked() async throws { + setupInitialState() + await subject.start() + stateService.syncToAuthenticatorSubject.send(("1", true)) + try await Task.sleep(nanoseconds: 10_000_000) + + vaultTimeoutService.isClientLocked["1"] = true + cipherDataStore.cipherSubjectByUserId["1"]?.send([ + .fixture( + id: "1234", + login: .fixture( + username: "user@bitwarden.com", + totp: "totp" + ) + ), + ]) + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertNil(authBridgeItemService.storedItems["1"]?.first) + XCTAssertTrue(errorReporter.errors.isEmpty) + } + // MARK: - Private Methods /// Helper function that sets up testing parameters based on the flags passed in From d15186ce2197fa8f81c2a5ae90b117a4b0c054f8 Mon Sep 17 00:00:00 2001 From: Brant DeBow Date: Mon, 7 Oct 2024 12:43:48 -0400 Subject: [PATCH 52/52] Removed the unneeded optional --- .../Core/Platform/Services/AuthenticatorSyncService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift index bc8db33ba2..fa78463fe7 100644 --- a/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift +++ b/BitwardenShared/Core/Platform/Services/AuthenticatorSyncService.swift @@ -25,7 +25,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private let authBridgeItemService: AuthenticatorBridgeItemService /// The Tasks listening for Cipher updates (one for each user, indexed by the userId). - private var cipherPublisherTasks = [String: Task?]() + private var cipherPublisherTasks = [String: Task]() /// The service used to manage syncing and updates to the user's ciphers. private let cipherDataStore: CipherDataStore @@ -183,7 +183,7 @@ actor DefaultAuthenticatorSyncService: NSObject, AuthenticatorSyncService { private func determineSyncForUserId(_ userId: String) async throws { guard try await stateService.getSyncToAuthenticator(userId: userId), !vaultTimeoutService.isLocked(userId: userId) else { - cipherPublisherTasks[userId]??.cancel() + cipherPublisherTasks[userId]?.cancel() cipherPublisherTasks.removeValue(forKey: userId) return }