From 6a820f49ddf61248d7a90d61aa94e66f6071903a Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Fri, 20 Sep 2024 10:30:15 -0400 Subject: [PATCH] [BITAU-122] [BITAU-133] Add encryption to/from shared the CoreData store (#938) --- .../AuthenticatorBridgeItemDataModel.swift | 20 +-- .../AuthenticatorBridgeItemDataView.swift | 40 +++++ .../AuthenticatorBridgeItemService.swift | 34 +++-- .../SharedCryptographyService.swift | 143 ++++++++++++++++++ .../AuthenticatorBridgeItemDataTests.swift | 22 ++- .../AuthenticatorBridgeItemServiceTests.swift | 30 ++-- .../SharedCryptographyServiceTests.swift | 97 ++++++++++++ ...nticatorBridgeItemDataModel+Fixtures.swift | 34 ----- ...enticatorBridgeItemDataView+Fixtures.swift | 34 +++++ .../MockSharedCryptographyService.swift | 39 +++++ 10 files changed, 415 insertions(+), 78 deletions(-) create mode 100644 AuthenticatorBridgeKit/AuthenticatorBridgeItemDataView.swift create mode 100644 AuthenticatorBridgeKit/SharedCryptographyService.swift create mode 100644 AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift delete mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift create mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataView+Fixtures.swift create mode 100644 AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeItemDataModel.swift index 38eb095e6..7232cb350 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 000000000..0dd6d3521 --- /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 `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 item. + /// + 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 e47180c6e..e429f9777 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 } @@ -42,6 +42,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 @@ -53,10 +56,14 @@ 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. /// - init(dataStore: AuthenticatorBridgeDataStore, sharedKeychainRepository: SharedKeychainRepository) { + init(cryptoService: SharedCryptographyService, + dataStore: AuthenticatorBridgeDataStore, + sharedKeychainRepository: SharedKeychainRepository) { + self.cryptoService = cryptoService self.dataStore = dataStore self.sharedKeychainRepository = sharedKeychainRepository } @@ -75,13 +82,13 @@ 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) - - 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. @@ -90,10 +97,11 @@ 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( - AuthenticatorBridgeItemData.batchInsertRequest(objects: items, userId: userId) + AuthenticatorBridgeItemData.batchInsertRequest(objects: encryptedItems, userId: userId) ) } @@ -103,10 +111,14 @@ 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) - 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/SharedCryptographyService.swift b/AuthenticatorBridgeKit/SharedCryptographyService.swift new file mode 100644 index 000000000..de5eba78d --- /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 -> [AuthenticatorBridgeItemDataView] + + /// 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: [AuthenticatorBridgeItemDataView] + ) 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 -> [AuthenticatorBridgeItemDataView] { + let key = try await sharedKeychainRepository.getAuthenticatorKey() + let symmetricKey = SymmetricKey(data: key) + + return items.map { item in + 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 encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataView] + ) 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: encrypt(item.name, withKey: symmetricKey) ?? "", + 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/AuthenticatorBridgeItemDataTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeItemDataTests.swift index cb228cc6e..c9a5d189f 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! @@ -16,6 +17,7 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { override func setUp() { super.setUp() + cryptoService = MockSharedCryptographyService() errorReporter = MockErrorReporter() dataStore = AuthenticatorBridgeDataStore( errorReporter: errorReporter, @@ -23,12 +25,14 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase { storeType: .memory ) itemService = DefaultAuthenticatorBridgeItemService( + cryptoService: cryptoService, dataStore: dataStore, sharedKeychainRepository: MockSharedKeychainRepository() ) } override func tearDown() { + cryptoService = nil dataStore = nil errorReporter = nil subject = nil @@ -58,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") @@ -71,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) @@ -81,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) } @@ -89,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" @@ -106,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 cdac503b7..59db1c7a1 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 @@ -43,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" @@ -73,16 +77,18 @@ 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 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) @@ -96,10 +102,12 @@ 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") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } @@ -108,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 @@ -123,15 +131,17 @@ 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") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) XCTAssertFalse(result.contains { $0 == initialItems.first }) } @@ -141,11 +151,13 @@ 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") + XCTAssertTrue(cryptoService.encryptCalled, + "Items should have been encrypted before inserting!!") XCTAssertEqual(result, expectedItems) } } diff --git a/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift b/AuthenticatorBridgeKit/Tests/SharedCryptographyServiceTests.swift new file mode 100644 index 000000000..4d415de90 --- /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: [AuthenticatorBridgeItemDataView] = AuthenticatorBridgeItemDataView.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([]) + } + } + + /// 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) + + // 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 { + 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/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift b/AuthenticatorBridgeKit/Tests/TestHelpers/Fixtures/AuthenticatorBridgeItemDataModel+Fixtures.swift deleted file mode 100644 index 82c2bbc5e..000000000 --- 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 000000000..ec516431a --- /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 new file mode 100644 index 000000000..6d6a08d5e --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/TestHelpers/MockSharedCryptographyService.swift @@ -0,0 +1,39 @@ +import CryptoKit +import Foundation + +@testable import AuthenticatorBridgeKit + +class MockSharedCryptographyService: SharedCryptographyService { + var decryptCalled = false + var encryptCalled = false + + func decryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataModel] + ) async throws -> [AuthenticatorBridgeItemDataView] { + decryptCalled = true + return items.map { model in + AuthenticatorBridgeItemDataView( + favorite: model.favorite, + id: model.id, + name: model.name, + totpKey: model.totpKey, + username: model.username + ) + } + } + + func encryptAuthenticatorItems( + _ items: [AuthenticatorBridgeItemDataView] + ) async throws -> [AuthenticatorBridgeItemDataModel] { + encryptCalled = true + return items.map { view in + AuthenticatorBridgeItemDataModel( + favorite: view.favorite, + id: view.id, + name: view.name, + totpKey: view.totpKey, + username: view.username + ) + } + } +}