diff --git a/Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift b/Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift new file mode 100644 index 000000000..a8523d9b9 --- /dev/null +++ b/Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift @@ -0,0 +1,37 @@ +// +// ASCredentialIdentityStoring.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import AuthenticationServices + +// This is used to abstract the ASCredentialIdentityStore for testing purposes +public protocol ASCredentialIdentityStoring { + func state() async -> ASCredentialIdentityStoreState + func saveCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws + func removeCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws + func replaceCredentialIdentities(with newCredentials: [ASPasswordCredentialIdentity]) async throws + + @available(iOS 17.0, macOS 14.0, *) + func saveCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws + @available(iOS 17.0, macOS 14.0, *) + func removeCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws + @available(iOS 17.0, macOS 14.0, *) + func replaceCredentialIdentities(_ newCredentials: [ASCredentialIdentity]) async throws +} + +extension ASCredentialIdentityStore: ASCredentialIdentityStoring {} diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift b/Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift new file mode 100644 index 000000000..7ecdc5012 --- /dev/null +++ b/Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift @@ -0,0 +1,272 @@ +// +// AutofillCredentialIdentityStoreManager.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import AuthenticationServices +import Common +import os.log + +public protocol AutofillCredentialIdentityStoreManaging { + func credentialStoreStateEnabled() async -> Bool + func populateCredentialStore() async + func replaceCredentialStore(with accounts: [SecureVaultModels.WebsiteAccount]) async + func updateCredentialStore(for domain: String) async + func updateCredentialStoreWith(updatedAccounts: [SecureVaultModels.WebsiteAccount], deletedAccounts: [SecureVaultModels.WebsiteAccount]) async +} + +final public class AutofillCredentialIdentityStoreManager: AutofillCredentialIdentityStoreManaging { + + private let credentialStore: ASCredentialIdentityStoring + private let vault: (any AutofillSecureVault)? + private let tld: TLD + + public init(credentialStore: ASCredentialIdentityStoring = ASCredentialIdentityStore.shared, + vault: (any AutofillSecureVault)?, + tld: TLD) { + self.credentialStore = credentialStore + self.vault = vault + self.tld = tld + } + + // MARK: - Credential Store State + + public func credentialStoreStateEnabled() async -> Bool { + let state = await credentialStore.state() + return state.isEnabled + } + + // MARK: - Credential Store Operations + + public func populateCredentialStore() async { + guard await credentialStoreStateEnabled() else { return } + + do { + let accounts = try fetchAccounts() + try await generateAndSaveCredentialIdentities(from: accounts) + } catch { + Logger.autofill.error("Failed to populate credential store: \(error.localizedDescription, privacy: .public)") + } + } + + public func replaceCredentialStore(with accounts: [SecureVaultModels.WebsiteAccount]) async { + guard await credentialStoreStateEnabled() else { return } + + do { + if #available(iOS 17, macOS 14.0, *) { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [any ASCredentialIdentity] + try await replaceCredentialStoreIdentities(credentialIdentities) + } else { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [ASPasswordCredentialIdentity] + try await replaceCredentialStoreIdentities(with: credentialIdentities) + } + } catch { + Logger.autofill.error("Failed to replace credential store: \(error.localizedDescription, privacy: .public)") + } + } + + public func updateCredentialStore(for domain: String) async { + guard await credentialStoreStateEnabled() else { return } + + do { + if await storeSupportsIncrementalUpdates() { + let accounts = try fetchAccountsFor(domain: domain) + try await generateAndSaveCredentialIdentities(from: accounts) + } else { + await replaceCredentialStore() + } + } catch { + Logger.autofill.error("Failed to update credential store \(error.localizedDescription, privacy: .public)") + } + } + + public func updateCredentialStoreWith(updatedAccounts: [SecureVaultModels.WebsiteAccount], deletedAccounts: [SecureVaultModels.WebsiteAccount]) async { + guard await credentialStoreStateEnabled() else { return } + + do { + if await storeSupportsIncrementalUpdates() { + if !updatedAccounts.isEmpty { + try await generateAndSaveCredentialIdentities(from: updatedAccounts) + } + + if !deletedAccounts.isEmpty { + try await generateAndDeleteCredentialIdentities(from: deletedAccounts) + } + } else { + await replaceCredentialStore() + } + } catch { + Logger.autofill.error("Failed to update credential store with updated / deleted accounts \(error.localizedDescription, privacy: .public)") + } + + } + + // MARK: - Private Store Operations + + private func storeSupportsIncrementalUpdates() async -> Bool { + let state = await credentialStore.state() + return state.supportsIncrementalUpdates + } + + private func replaceCredentialStore() async { + guard await credentialStoreStateEnabled() else { return } + + do { + let accounts = try fetchAccounts() + + Task { + await replaceCredentialStore(with: accounts) + } + } catch { + Logger.autofill.error("Failed to replace credential store: \(error.localizedDescription, privacy: .public)") + } + } + + private func generateAndSaveCredentialIdentities(from accounts: [SecureVaultModels.WebsiteAccount]) async throws { + if #available(iOS 17, macOS 14.0, *) { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [any ASCredentialIdentity] + try await saveToCredentialStore(credentials: credentialIdentities) + } else { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [ASPasswordCredentialIdentity] + try await saveToCredentialStore(credentials: credentialIdentities) + } + } + + private func generateAndDeleteCredentialIdentities(from accounts: [SecureVaultModels.WebsiteAccount]) async throws { + if #available(iOS 17, macOS 14.0, *) { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [any ASCredentialIdentity] + try await removeCredentialStoreIdentities(credentialIdentities) + } else { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [ASPasswordCredentialIdentity] + try await removeCredentialStoreIdentities(credentialIdentities) + } + } + + private func saveToCredentialStore(credentials: [ASPasswordCredentialIdentity]) async throws { + do { + try await credentialStore.saveCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to save credentials to ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func saveToCredentialStore(credentials: [ASCredentialIdentity]) async throws { + do { + try await credentialStore.saveCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to save credentials to ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func replaceCredentialStoreIdentities(with credentials: [ASPasswordCredentialIdentity]) async throws { + do { + try await credentialStore.replaceCredentialIdentities(with: credentials) + } catch { + Logger.autofill.error("Failed to replace credentials in ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func replaceCredentialStoreIdentities(_ credentials: [ASCredentialIdentity]) async throws { + do { + try await credentialStore.replaceCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to replace credentials in ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func removeCredentialStoreIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws { + do { + try await credentialStore.removeCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to remove credentials from ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func removeCredentialStoreIdentities(_ credentials: [ASCredentialIdentity]) async throws { + do { + try await credentialStore.removeCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to remove credentials from ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func generateCredentialIdentities(from accounts: [SecureVaultModels.WebsiteAccount]) async throws -> [ASPasswordCredentialIdentity] { + let sortedAndDedupedAccounts = accounts.sortedAndDeduplicated(tld: tld) + let groupedAccounts = Dictionary(grouping: sortedAndDedupedAccounts, by: { $0.domain ?? "" }) + var credentialIdentities: [ASPasswordCredentialIdentity] = [] + + for (_, accounts) in groupedAccounts { + // Since accounts are sorted, ranking can be assigned based on index + // but first need to be reversed as highest ranking should apply to the most recently used account + for (rank, account) in accounts.reversed().enumerated() { + let credentialIdentity = createPasswordCredentialIdentity(from: account) + credentialIdentity.rank = rank + credentialIdentities.append(credentialIdentity) + } + } + + return credentialIdentities + } + + private func createPasswordCredentialIdentity(from account: SecureVaultModels.WebsiteAccount) -> ASPasswordCredentialIdentity { + let serviceIdentifier = ASCredentialServiceIdentifier(identifier: account.domain ?? "", type: .domain) + return ASPasswordCredentialIdentity(serviceIdentifier: serviceIdentifier, + user: account.username ?? "", + recordIdentifier: account.id) + } + + // MARK: - Private Secure Vault Operations + + private func fetchAccounts() throws -> [SecureVaultModels.WebsiteAccount] { + guard let vault = vault else { + Logger.autofill.error("Vault not created") + return [] + } + + do { + return try vault.accounts() + } catch { + Logger.autofill.error("Failed to fetch accounts \(error.localizedDescription, privacy: .public)") + throw error + } + + } + + private func fetchAccountsFor(domain: String) throws -> [SecureVaultModels.WebsiteAccount] { + guard let vault = vault else { + Logger.autofill.error("Vault not created") + return [] + } + + do { + return try vault.accountsFor(domain: domain) + } catch { + Logger.autofill.error("Failed to fetch accounts \(error.localizedDescription, privacy: .public)") + throw error + } + + } +} diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift index b145523ea..dae41804d 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift @@ -20,6 +20,7 @@ import Common import Foundation import GRDB import SecureStorage +import os.log public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider { @@ -82,14 +83,35 @@ public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider { public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabaseProvider, AutofillDatabaseProvider { + struct Constants { + static let dbDirectoryName = "Vault" + static let dbFileName = "Vault.db" + } + public static func defaultDatabaseURL() -> URL { - return DefaultAutofillDatabaseProvider.databaseFilePath(directoryName: "Vault", fileName: "Vault.db") + return DefaultAutofillDatabaseProvider.databaseFilePath(directoryName: Constants.dbDirectoryName, fileName: Constants.dbFileName, appGroupIdentifier: nil) + } + + public static func defaultSharedDatabaseURL() -> URL { + return DefaultAutofillDatabaseProvider.databaseFilePath(directoryName: Constants.dbDirectoryName, fileName: Constants.dbFileName, appGroupIdentifier: Bundle.main.appGroupPrefix + ".vault") } public init(file: URL = DefaultAutofillDatabaseProvider.defaultDatabaseURL(), key: Data, + fileStorageManager: FileStorageManaging = AppGroupFileStorageManager(), customMigrations: ((inout DatabaseMigrator) -> Void)? = nil) throws { - try super.init(file: file, key: key, writerType: .queue) { migrator in + let databaseURL: URL + +#if os(iOS) + databaseURL = Self.migrateDatabaseToSharedGroupIfNeeded(using: fileStorageManager, + from: Self.defaultDatabaseURL(), + to: Self.defaultSharedDatabaseURL()) +#else + // macOS stays in its sandbox location + databaseURL = file +#endif + + try super.init(file: databaseURL, key: key, writerType: .queue) { migrator in if let customMigrations { customMigrations(&migrator) } else { @@ -110,6 +132,17 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro } } + private static func migrateDatabaseToSharedGroupIfNeeded(using fileStorageManager: FileStorageManaging = AppGroupFileStorageManager(), + from databaseURL: URL, + to sharedDatabaseURL: URL) -> URL { + do { + return try fileStorageManager.migrateDatabaseToSharedStorageIfNeeded(from: databaseURL, to: sharedDatabaseURL) + } catch { + Logger.secureStorage.error("Failed to migrate database to shared storage: \(error.localizedDescription)") + return databaseURL + } + } + public func inTransaction(_ block: @escaping (GRDB.Database) throws -> Void) throws { try db.write { database in try block(database) @@ -1412,3 +1445,17 @@ extension SecureVaultModels.Identity: PersistableRecord, FetchableRecord { public static var databaseTableName: String = "identities" } + +private extension Bundle { + var appGroupPrefix: String { + let groupIdPrefixKey = "DuckDuckGoGroupIdentifierPrefix" + guard let groupIdPrefix = Bundle.main.object(forInfoDictionaryKey: groupIdPrefixKey) as? String else { + #if DEBUG && os(iOS) + return "group.com.duckduckgo.test" + #else + fatalError("Info.plist must contain a \"\(groupIdPrefixKey)\" entry with a string value") + #endif + } + return groupIdPrefix + } +} diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillKeyStoreProvider.swift b/Sources/BrowserServicesKit/SecureVault/AutofillKeyStoreProvider.swift index 0a22e1f0a..b3eed9b99 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillKeyStoreProvider.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillKeyStoreProvider.swift @@ -21,12 +21,55 @@ import Foundation import SecureStorage import os.log +protocol KeyStorePlatformProviding { + var keychainServiceName: String { get } + func keychainIdentifier(for rawValue: String) -> String + var keychainSecurityGroup: String { get } +} + +struct iOSKeyStorePlatformProvider: KeyStorePlatformProviding { + private let appGroupName: String + + // Using appGroupName in the initializer, allowing injection for tests + init(appGroupName: String = Bundle.main.appGroupName) { + self.appGroupName = appGroupName + } + + var keychainServiceName: String { + return AutofillKeyStoreProvider.Constants.v4ServiceName + } + + func keychainIdentifier(for rawValue: String) -> String { + return appGroupName + rawValue + } + + var keychainSecurityGroup: String { + return appGroupName + } +} + +struct macOSKeyStorePlatformProvider: KeyStorePlatformProviding { + var keychainServiceName: String { + return AutofillKeyStoreProvider.Constants.v3ServiceName + } + + func keychainIdentifier(for rawValue: String) -> String { + return (Bundle.main.bundleIdentifier ?? "com.duckduckgo") + rawValue + } + + var keychainSecurityGroup: String { + return "" + } + +} + final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { struct Constants { static let v1ServiceName = "DuckDuckGo Secure Vault" static let v2ServiceName = "DuckDuckGo Secure Vault v2" static let v3ServiceName = "DuckDuckGo Secure Vault v3" + static let v4ServiceName = "DuckDuckGo Secure Vault v4" } // DO NOT CHANGE except if you want to deliberately invalidate all users's vaults. @@ -38,7 +81,12 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { case l2Key = "A5711F4D-7AA5-4F0C-9E4F-BE553F1EA299" // `keychainIdentifier` should be used as Keychain Account names, as app variants (e.g App Store, DMG) should have separate entries - var keychainIdentifier: String { + func keychainIdentifier(using platformProvider: KeyStorePlatformProviding) -> String { + return platformProvider.keychainIdentifier(for: self.rawValue) + } + + // `legacyKeychainIdentifier` is the Keychain Account name pre migration to shared app groups (currently only on iOS) + var legacyKeychainIdentifier: String { (Bundle.main.bundleIdentifier ?? "com.duckduckgo") + rawValue } @@ -53,13 +101,13 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { } } - init?(_ keyValue: String) { + init?(_ keyValue: String, using platformProvider: KeyStorePlatformProviding) { switch keyValue { - case EntryName.generatedPassword.keychainIdentifier: + case platformProvider.keychainIdentifier(for: EntryName.generatedPassword.rawValue), EntryName.generatedPassword.legacyKeychainIdentifier: self = .generatedPassword - case EntryName.l1Key.keychainIdentifier: + case platformProvider.keychainIdentifier(for: EntryName.l1Key.rawValue), EntryName.l1Key.legacyKeychainIdentifier: self = .l1Key - case EntryName.l2Key.keychainIdentifier: + case platformProvider.keychainIdentifier(for: EntryName.l2Key.rawValue), EntryName.l2Key.legacyKeychainIdentifier: self = .l2Key default: return nil @@ -69,27 +117,40 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { let keychainService: KeychainService private var reporter: SecureVaultReporting? + private let platformProvider: KeyStorePlatformProviding init(keychainService: KeychainService = DefaultKeychainService(), - reporter: SecureVaultReporting? = nil) { + reporter: SecureVaultReporting? = nil, + platformProvider: KeyStorePlatformProviding? = nil) { self.keychainService = keychainService self.reporter = reporter + + // Use default platform provider based on the platform. + if let platformProvider = platformProvider { + self.platformProvider = platformProvider + } else { +#if os(iOS) + self.platformProvider = iOSKeyStorePlatformProvider() +#else + self.platformProvider = macOSKeyStorePlatformProvider() +#endif + } } var keychainServiceName: String { - return Constants.v3ServiceName + return platformProvider.keychainServiceName } var generatedPasswordEntryName: String { - return EntryName.generatedPassword.keychainIdentifier + return EntryName.generatedPassword.keychainIdentifier(using: platformProvider) } var l1KeyEntryName: String { - return EntryName.l1Key.keychainIdentifier + return EntryName.l1Key.keychainIdentifier(using: platformProvider) } var l2KeyEntryName: String { - return EntryName.l2Key.keychainIdentifier + return EntryName.l2Key.keychainIdentifier(using: platformProvider) } func readData(named name: String, serviceName: String) throws -> Data? { @@ -103,15 +164,19 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { /// - Returns: Optional data private func readOrMigrate(named name: String, serviceName: String) throws -> Data? { if let data = try read(named: name, serviceName: serviceName) { - Logger.autofill.debug("Autofill Keystore data retrieved") + Logger.autofill.debug("Autofill Keystore \(serviceName) data retrieved") return data } else { - guard let entryName = EntryName(name) else { return nil } + guard let entryName = EntryName(name, using: platformProvider) else { return nil } reporter?.secureVaultKeyStoreEvent(entryName.keyStoreMigrationEvent) + // If V4 migration, look for items in V3 vault (i.e pre-shared Keychain storage) + if isPostV3(serviceName), let data = try migrateEntry(entryName: entryName, serviceName: Constants.v3ServiceName) { + Logger.autofill.debug("Migrated V3 Autofill Keystore data") + return data // Look for items in V2 vault (i.e pre-bundle-specifc Keychain storage) - if let data = try migrateEntry(entryName: entryName, serviceName: Constants.v2ServiceName) { + } else if let data = try migrateEntry(entryName: entryName, serviceName: Constants.v2ServiceName) { Logger.autofill.debug("Migrated V2 Autofill Keystore data") return data // Look for items in V1 vault @@ -120,6 +185,7 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { return data } + Logger.autofill.debug("Keychain migration failed for \(name)") return nil } } @@ -139,7 +205,7 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { let status = keychainService.itemMatching(query, &item) switch status { case errSecSuccess: - if isPostV1(serviceName) { + if isPostV1(serviceName) || isPostV3(serviceName) { guard let itemData = item as? Data, let itemString = String(data: itemData, encoding: .utf8), let decodedData = Data(base64Encoded: itemString) else { @@ -162,14 +228,15 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { /// Migrates an entry to new bundle-specific Keychain storage /// - Parameters: - /// - entryName: Entry to migrate. It's `rawValue` is used when reading from old storage, and it's `keyValue` is used when writing to storage + /// - entryName: Entry to migrate. It's `rawValue` is used when reading from old storage pre-V2, while its `legacyKeychainIdentifier` is used post V2, and it's `keyValue` is used when writing to storage /// - serviceName: Service name to use when querying Keychain for the entry /// - Returns: Optional data private func migrateEntry(entryName: EntryName, serviceName: String) throws -> Data? { - guard let data = try read(named: entryName.rawValue, serviceName: serviceName) else { + let name = serviceName == Constants.v3ServiceName ? entryName.legacyKeychainIdentifier : entryName.rawValue + guard let data = try read(named: name, serviceName: serviceName) else { return nil } - try writeData(data, named: entryName.keychainIdentifier, serviceName: keychainServiceName) + try writeData(data, named: entryName.keychainIdentifier(using: platformProvider), serviceName: keychainServiceName) return data } @@ -177,11 +244,17 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { [Constants.v2ServiceName, Constants.v3ServiceName].contains(serviceName) } + private func isPostV3(_ serviceName: String) -> Bool { + [Constants.v4ServiceName].contains(serviceName) + } + // MARK: - Autofill Attributes func attributesForEntry(named name: String, serviceName: String) -> [String: Any] { if isPostV1(serviceName) { return defaultAttributesForEntry(named: name) + } else if isPostV3(serviceName) { + return defaultAttributesForSharedEntry(named: name) } else { return legacyAttributesForEntry(named: name) } @@ -205,4 +278,29 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider { ] as [String: Any] } + private func defaultAttributesForSharedEntry(named name: String) -> [String: Any] { + return [ + kSecClass: kSecClassGenericPassword, + kSecUseDataProtectionKeychain: false, + kSecAttrSynchronizable: false, + kSecAttrAccount: name, + kSecAttrAccessGroup: platformProvider.keychainSecurityGroup + ] as [String: Any] + } +} + +fileprivate extension Bundle { + + static let vaultAppGroupName = "VAULT_APP_GROUP" + + var appGroupName: String { + guard let appGroup = object(forInfoDictionaryKey: Bundle.vaultAppGroupName) as? String else { + #if DEBUG && os(iOS) + return "com.duckduckgo.vault.test" + #else + fatalError("Info.plist is missing \(Bundle.vaultAppGroupName)") + #endif + } + return appGroup + } } diff --git a/Sources/BrowserServicesKit/SecureVault/FileStorageManaging.swift b/Sources/BrowserServicesKit/SecureVault/FileStorageManaging.swift new file mode 100644 index 000000000..fdcf6e210 --- /dev/null +++ b/Sources/BrowserServicesKit/SecureVault/FileStorageManaging.swift @@ -0,0 +1,94 @@ +// +// FileStorageManaging.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import os.log + +public protocol FileStorageManaging { + func migrateDatabaseToSharedStorageIfNeeded(from databaseURL: URL, to sharedDatabaseURL: URL) throws -> URL +} + +final public class AppGroupFileStorageManager: FileStorageManaging { + + private let fileManager: FileManager + + public init(fileManager: FileManager = FileManager.default) { + self.fileManager = fileManager + } + + private func fileExists(at url: URL) -> Bool { + return fileManager.fileExists(atPath: url.path) + } + + private func createDirectoryIfNeeded(at url: URL) throws { + let directoryPath = url.path + if !fileManager.fileExists(atPath: directoryPath) { + do { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + Logger.secureStorage.info("Created directory at \(directoryPath)") + } catch { + Logger.secureStorage.error("Failed to create directory: \(error.localizedDescription)") + throw error + } + } + } + + private func copyFile(from sourceURL: URL, to destinationURL: URL) throws { + do { + try fileManager.copyItem(at: sourceURL, to: destinationURL) + } catch { + Logger.secureStorage.error("Error moving file: \(error.localizedDescription)") + throw error + } + } + + private func removeFile(at url: URL) throws { + do { + try fileManager.removeItem(at: url) + Logger.secureStorage.info("Removed file at \(url.path)") + } catch { + Logger.secureStorage.error("Error removing file: \(error.localizedDescription)") + throw error + } + } + + public func migrateDatabaseToSharedStorageIfNeeded(from databaseURL: URL, to sharedDatabaseURL: URL) throws -> URL { + if fileExists(at: sharedDatabaseURL) { + return sharedDatabaseURL + } + + do { + // Ensure the shared group directory exists + try createDirectoryIfNeeded(at: sharedDatabaseURL.deletingLastPathComponent()) + + if fileExists(at: databaseURL) { + try copyFile(from: databaseURL, to: sharedDatabaseURL) + + // If the copy was successful, delete the original file + try removeFile(at: databaseURL) + + return sharedDatabaseURL + } + } catch { + Logger.secureStorage.error("Failed to migrate Vault.db: \(error.localizedDescription)") + throw error + } + + return sharedDatabaseURL + } +} diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift index 688cc77cc..2ba635f1f 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift @@ -569,6 +569,38 @@ extension Array where Element == SecureVaultModels.WebsiteAccount { return (removeDuplicates ? result.removeDuplicates() : result).filter { $0.domain?.isEmpty == false } } + public func sortedAndDeduplicated(tld: TLD, urlMatcher: AutofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher()) -> [SecureVaultModels.WebsiteAccount] { + + let groupedBySignature = Dictionary(grouping: self) { $0.signature ?? "" } + + let deduplicatedAccounts = groupedBySignature + .flatMap { (signature, accounts) -> [SecureVaultModels.WebsiteAccount] in + + // no need to dedupe accounts with no signature, or where a signature group only has 1 account + if signature.isEmpty || accounts.count == 1 { + return accounts + } + + // This set is required as accounts can have duplicate signatures but different domains if the domain has a SLD + TLD like `co.uk` + // e.g. accounts with the same username & password for `example.co.uk` and `domain.co.uk` will have the same signature + var uniqueHosts = Set() + + for account in accounts { + if let domain = account.domain, + let urlComponents = urlMatcher.normalizeSchemeForAutofill(domain), + let host = urlComponents.eTLDplus1(tld: tld) ?? urlComponents.host { + uniqueHosts.insert(host) + } + } + + return uniqueHosts.flatMap { host in + accounts.sortedForDomain(host, tld: tld, removeDuplicates: true) + } + } + + return deduplicatedAccounts.sorted { compareAccount($0, $1) } + } + private func extractTLD(domain: String, tld: TLD, urlMatcher: AutofillDomainNameUrlMatcher) -> String? { guard var urlComponents = urlMatcher.normalizeSchemeForAutofill(domain) else { return nil } guard urlComponents.host != .localhost else { return domain } diff --git a/Sources/SecureStorageTestsUtils/MockKeystoreProvider.swift b/Sources/SecureStorageTestsUtils/MockKeystoreProvider.swift index 689641386..dc47eec1f 100644 --- a/Sources/SecureStorageTestsUtils/MockKeystoreProvider.swift +++ b/Sources/SecureStorageTestsUtils/MockKeystoreProvider.swift @@ -23,6 +23,7 @@ public final class MockKeychainService: KeychainService { public enum Mode { case nothingFound + case v4Found case v3Found case v2Found case v1Found @@ -55,9 +56,21 @@ public final class MockKeychainService: KeychainService { switch mode { case .nothingFound: return errSecItemNotFound + case .v4Found: + setResult() + return errSecSuccess case .v3Found: +#if os(iOS) + if itemMatchingCallCount == 2 { + setResult() + return errSecSuccess + } else { + return errSecItemNotFound + } +#else setResult() return errSecSuccess +#endif case .v2Found: if itemMatchingCallCount == 2 { setResult() diff --git a/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift b/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift index 1110adfe2..10ad4386b 100644 --- a/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift +++ b/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift @@ -25,19 +25,30 @@ import GRDB import SecureStorage import os.log +public struct CredentialsInput { + public var modifiedAccounts: [SecureVaultModels.WebsiteAccount] + public var deletedAccounts: [SecureVaultModels.WebsiteAccount] +} + public final class CredentialsProvider: DataProvider { + public private(set) var credentialsInput: CredentialsInput = .init(modifiedAccounts: [], deletedAccounts: []) + public init( secureVaultFactory: AutofillVaultFactory = AutofillSecureVaultFactory, secureVaultErrorReporter: SecureVaultReporting, metadataStore: SyncMetadataStore, metricsEvents: EventMapping? = nil, - syncDidUpdateData: @escaping () -> Void + syncDidUpdateData: @escaping () -> Void, + syncDidFinish: @escaping (CredentialsInput?) -> Void ) throws { self.secureVaultFactory = secureVaultFactory self.secureVaultErrorReporter = secureVaultErrorReporter self.metricsEvents = metricsEvents super.init(feature: .init(name: "credentials"), metadataStore: metadataStore, syncDidUpdateData: syncDidUpdateData) + self.syncDidFinish = { [weak self] in + syncDidFinish(self?.credentialsInput) + } } // MARK: - DataProviding @@ -166,6 +177,9 @@ public final class CredentialsProvider: DataProvider { ) try responseHandler.processReceivedCredentials() + + self.credentialsInput.modifiedAccounts = responseHandler.incomingModifiedAccounts + self.credentialsInput.deletedAccounts = responseHandler.incomingDeletedAccounts #if DEBUG try self.willSaveContextAfterApplyingSyncResponse() #endif diff --git a/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift b/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift index b3d00bcbb..80add013c 100644 --- a/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift +++ b/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift @@ -34,6 +34,9 @@ final class CredentialsResponseHandler { let allReceivedIDs: Set private var credentialsByUUID: [String: SecureVaultModels.SyncableCredentials] = [:] + var incomingModifiedAccounts = [SecureVaultModels.WebsiteAccount]() + var incomingDeletedAccounts = [SecureVaultModels.WebsiteAccount]() + private let decrypt: (String) throws -> String private let metricsEvents: EventMapping? @@ -117,6 +120,7 @@ final class CredentialsResponseHandler { if syncable.isDeleted { try secureVault.deleteSyncableCredentials(existingEntity, in: database) + trackCredentialChange(of: existingEntity, with: syncable) } else if isModifiedAfterSyncTimestamp { metricsEvents?.fire(.localTimestampResolutionTriggered(feature: feature)) } else { @@ -126,10 +130,10 @@ final class CredentialsResponseHandler { in: database, encryptedUsing: secureVaultEncryptionKey, hashedUsing: secureVaultHashingSalt) + trackCredentialChange(of: existingEntity, with: syncable) } } else if !syncable.isDeleted { - let newEntity = try SecureVaultModels.SyncableCredentials(syncable: syncable, decryptedUsing: decrypt) assert(newEntity.metadata.lastModified == nil, "lastModified should be nil for a new metadata entity") try secureVault.storeSyncableCredentials(newEntity, @@ -137,6 +141,7 @@ final class CredentialsResponseHandler { encryptedUsing: secureVaultEncryptionKey, hashedUsing: secureVaultHashingSalt) credentialsByUUID[syncableUUID] = newEntity + trackCredentialChange(of: newEntity, with: syncable) } } @@ -183,6 +188,18 @@ final class CredentialsResponseHandler { } return syncableCredentials.first(where: { $0.credentialsRecord?.password == nil }) } + + private func trackCredentialChange(of entity: SecureVaultModels.SyncableCredentials, with syncable: SyncableCredentialsAdapter) { + guard let account = entity.account else { + return + } + + if syncable.isDeleted { + incomingDeletedAccounts.append(account) + } else { + incomingModifiedAccounts.append(account) + } + } } extension SecureVaultModels.SyncableCredentials { diff --git a/Tests/BrowserServicesKitTests/SecureVault/AppGroupFileStorageManagerTests.swift b/Tests/BrowserServicesKitTests/SecureVault/AppGroupFileStorageManagerTests.swift new file mode 100644 index 000000000..ff9cda5e6 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/AppGroupFileStorageManagerTests.swift @@ -0,0 +1,116 @@ +// +// AppGroupFileStorageManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import BrowserServicesKit + +final class AppGroupFileStorageManagerTests: XCTestCase { + + var mockFileManager: MockFileManager! + var fileStorageManager: AppGroupFileStorageManager! + + override func setUp() { + super.setUp() + mockFileManager = MockFileManager() + fileStorageManager = AppGroupFileStorageManager(fileManager: mockFileManager) + } + + func testWhenMigrateDatabaseToSharedStorageIfNeeded_ThenMigratesDatabaseSuccessfully() throws { + let originalURL = URL(fileURLWithPath: "/path/to/Vault.db") + let sharedDatabaseURL = URL(fileURLWithPath: "/shared/path/Vault.db") + + mockFileManager.files.insert(originalURL) + + let resultURL = try fileStorageManager.migrateDatabaseToSharedStorageIfNeeded(from: originalURL, to: sharedDatabaseURL) + + XCTAssertEqual(resultURL, sharedDatabaseURL, "The shared database URL should be returned after migration.") + XCTAssertTrue(mockFileManager.files.contains(sharedDatabaseURL), "Shared database should exist after migration.") + XCTAssertTrue(mockFileManager.files.contains(sharedDatabaseURL), "The file should have been copied to the shared location.") + XCTAssertFalse(mockFileManager.files.contains(originalURL), "The original file should have been deleted after a successful migration.") + } + + func testWhenMigrateDatabaseToSharedStorageIfNeeded_ThenDoesNotMigrateIfSharedDatabaseExists() throws { + let originalURL = URL(fileURLWithPath: "/path/to/Vault.db") + let sharedDatabaseURL = URL(fileURLWithPath: "/shared/path/Vault.db") + + mockFileManager.files.insert(originalURL) + mockFileManager.files.insert(sharedDatabaseURL) + + let resultURL = try fileStorageManager.migrateDatabaseToSharedStorageIfNeeded(from: originalURL, to: sharedDatabaseURL) + + XCTAssertEqual(resultURL, sharedDatabaseURL, "The shared database URL should be returned since it already exists.") + XCTAssertTrue(mockFileManager.files.contains(sharedDatabaseURL), "The shared database should still exist.") + XCTAssertTrue(mockFileManager.files.contains(originalURL), "The original file should not be deleted if the shared database already exists.") + } + + func testWhenMigrateDatabaseToSharedStorageIfNeeded_ThenRestoresOriginalIfCopyFails() throws { + let originalURL = URL(fileURLWithPath: "/path/to/Vault.db") + let sharedDatabaseURL = URL(fileURLWithPath: "/shared/path/Vault.db") + + mockFileManager.files.insert(originalURL) + // Simulate copy failure + mockFileManager.shouldFailOnCopy = true + + var returnedURL: URL? + do { + returnedURL = try fileStorageManager.migrateDatabaseToSharedStorageIfNeeded(from: originalURL, to: sharedDatabaseURL) + XCTFail("Expected failure when copying the file.") + } catch { + // Expected failure + } + + XCTAssertNil(returnedURL, "The migration should fail and no URL should be returned.") + XCTAssertTrue(mockFileManager.files.contains(originalURL), "The original file should still exist after a failed migration.") + } +} + +final class MockFileManager: FileManager { + var files: Set = [] + var createdDirectories: Set = [] + var copiedFiles: [(from: URL, to: URL)] = [] + var movedFiles: [(from: URL, to: URL)] = [] + var removedFiles: [URL] = [] + var shouldFailOnCopy = false + + override func fileExists(atPath path: String) -> Bool { + return files.contains(URL(fileURLWithPath: path)) + } + + override func copyItem(at srcURL: URL, to dstURL: URL) throws { + if shouldFailOnCopy { + throw NSError(domain: "MockFileManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Simulated copy failure"]) + } + if !files.contains(srcURL) { + throw NSError(domain: "MockFileManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "File not found"]) + } + files.insert(dstURL) + copiedFiles.append((from: srcURL, to: dstURL)) + } + + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey: Any]? = nil) throws { + createdDirectories.insert(url) + } + + override func removeItem(at URL: URL) throws { + if !files.contains(URL) { + throw NSError(domain: "MockFileManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "File not found"]) + } + files.remove(URL) + removedFiles.append(URL) + } +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift b/Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift new file mode 100644 index 000000000..0fed822e4 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift @@ -0,0 +1,207 @@ +// +// AutofillCredentialIdentityStoreManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import AuthenticationServices +import Common +import SecureStorage +import SecureStorageTestsUtils +@testable import BrowserServicesKit + +final class AutofillCredentialIdentityStoreManagerTests: XCTestCase { + + var mockCryptoProvider = MockCryptoProvider() + var mockDatabaseProvider = (try! MockAutofillDatabaseProvider()) + var mockKeystoreProvider = MockKeystoreProvider() + var mockVault: (any AutofillSecureVault)! + var tld: TLD! + + var manager: AutofillCredentialIdentityStoreManaging! + var mockStore: MockASCredentialIdentityStore! + + override func setUp() { + super.setUp() + mockStore = MockASCredentialIdentityStore() + let providers = SecureStorageProviders(crypto: mockCryptoProvider, + database: mockDatabaseProvider, + keystore: mockKeystoreProvider) + + mockVault = DefaultAutofillSecureVault(providers: providers) + + tld = TLD() + manager = AutofillCredentialIdentityStoreManager(credentialStore: mockStore, vault: mockVault, tld: tld) + } + + override func tearDown() { + manager = nil + mockStore = nil + mockVault = nil + tld = nil + super.tearDown() + } + + func testCredentialStoreStateEnabled() async { + let isEnabled = await manager.credentialStoreStateEnabled() + XCTAssertTrue(isEnabled) + } + + func testPopulateCredentialStore() async throws { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.org", username: "user2", signature: "5678") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 2) + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 2) + } + } + + func testPopulateCredentialStoreWithDuplicateAccounts() async throws { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 1) + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 1) + } + } + + func testReplaceCredentialStore() async throws { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.org", username: "user2", signature: "5678"), + createWebsiteAccount(id: "3", domain: "example.org", username: "newUser3", signature: "44") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + let replacementAccounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "newUser1", signature: "123"), + createWebsiteAccount(id: "2", domain: "example.org", username: "newUser2", signature: "567") + ] + mockDatabaseProvider._accounts = accounts + + await manager.replaceCredentialStore(with: replacementAccounts) + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 2) + // loop through the saved credential identities and check if the username is updated + for identity in mockStore.savedCredentialIdentities { + let replacedAccount = replacementAccounts.first { $0.id == identity.recordIdentifier } + XCTAssertEqual(identity.user, replacedAccount?.username) + } + + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 2) + for identity in mockStore.savedPasswordCredentialIdentities { + let replacedAccount = replacementAccounts.first { $0.id == identity.recordIdentifier } + XCTAssertEqual(identity.user, replacedAccount?.username) + } + } + } + + func testUpdateCredentialStoreForDomain() async { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2", signature: "5678"), + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + let updatedAccounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234", lastUsed: Date() - TimeInterval(60)), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2", signature: "5678", lastUpdated: Date() - TimeInterval(60)), + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44", lastUsed: Date()) + ] + mockDatabaseProvider._accounts = updatedAccounts + + await manager.updateCredentialStore(for: "example.com") + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 3) + + let rankedCredentials = mockStore.savedCredentialIdentities.sorted { $0.rank < $1.rank } + XCTAssertEqual(rankedCredentials[0].recordIdentifier, "2") + XCTAssertEqual(rankedCredentials[1].recordIdentifier, "1") + XCTAssertEqual(rankedCredentials[2].recordIdentifier, "3") + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 3) + + let rankedCredentials = mockStore.savedPasswordCredentialIdentities.sorted { $0.rank < $1.rank } + XCTAssertEqual(rankedCredentials[0].recordIdentifier, "2") + XCTAssertEqual(rankedCredentials[1].recordIdentifier, "1") + XCTAssertEqual(rankedCredentials[2].recordIdentifier, "3") + + } + + } + + func testUpdateCredentialStoreWithUpdatedAndDeletedAccounts() async { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2", signature: "5678"), + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44"), + createWebsiteAccount(id: "4", domain: "example.com", username: "user4", signature: "4422") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + let updatedAccounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1a", signature: "1234", lastUsed: Date() - TimeInterval(60)), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2b", signature: "5678"), + createWebsiteAccount(id: "5", domain: "example.com", username: "user5IsNew", signature: "1111") + ] + + let deletedAccounts = [ + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44") + ] + + await manager.updateCredentialStoreWith(updatedAccounts: updatedAccounts, deletedAccounts: deletedAccounts) + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 4) + XCTAssertEqual(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "1" }?.user, "user1a") + XCTAssertEqual(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "2" }?.user, "user2b") + XCTAssertEqual(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "5" }?.user, "user5IsNew") + XCTAssertNil(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "3" }) + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 4) + } + } + + // MARK: - Helper Methods + + private func createWebsiteAccount(id: String, domain: String, username: String, signature: String, created: Date = Date(), lastUpdated: Date = Date(), lastUsed: Date? = nil) -> SecureVaultModels.WebsiteAccount { + return SecureVaultModels.WebsiteAccount(id: id, username: username, domain: domain, signature: signature, created: created, lastUpdated: lastUpdated, lastUsed: lastUsed) + } + +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/AutofillKeyStoreProviderTests.swift b/Tests/BrowserServicesKitTests/SecureVault/AutofillKeyStoreProviderTests.swift index 89bce30c3..0c53a0b59 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/AutofillKeyStoreProviderTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/AutofillKeyStoreProviderTests.swift @@ -23,6 +23,82 @@ import SecureStorageTestsUtils final class AutofillKeyStoreProviderTests: XCTestCase { +#if os(iOS) + let iOSPlatformProvider = iOSKeyStorePlatformProvider(appGroupName: "MockAppGroup") + + func testV1ToV4Migration() throws { + try AutofillKeyStoreProvider.EntryName.allCases.forEach { entry in + // Given + let keychainService = MockKeychainService() + keychainService.mode = .v1Found // Simulate a v1 keychain entry found + let sut = AutofillKeyStoreProvider(keychainService: keychainService, platformProvider: iOSPlatformProvider) + + // When + _ = try sut.readData(named: entry.keychainIdentifier(using: iOSPlatformProvider), serviceName: sut.keychainServiceName) + + // Then + XCTAssertEqual(keychainService.addCallCount, 1) // Migration should trigger a write + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrService as String] as! String, AutofillKeyStoreProvider.Constants.v4ServiceName) // Ensure it's migrated to v4 + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccessGroup as String] as! String, iOSPlatformProvider.keychainSecurityGroup as String) // Ensure correct accessibility + } + } + + func testV2ToV4Migration() throws { + try AutofillKeyStoreProvider.EntryName.allCases.forEach { entry in + // Given + let keychainService = MockKeychainService() + keychainService.mode = .v2Found // Simulate a v2 keychain entry found + let sut = AutofillKeyStoreProvider(keychainService: keychainService, platformProvider: iOSPlatformProvider) + + // When + _ = try sut.readData(named: entry.keychainIdentifier(using: iOSPlatformProvider), serviceName: sut.keychainServiceName) + + // Then + XCTAssertEqual(keychainService.addCallCount, 1) // Migration should trigger a write + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrService as String] as! String, AutofillKeyStoreProvider.Constants.v4ServiceName) // Ensure it's migrated to v4 + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccessGroup as String] as! String, iOSPlatformProvider.keychainSecurityGroup as String) // Ensure correct accessibility + } + } + + func testV3ToV4Migration() throws { + try AutofillKeyStoreProvider.EntryName.allCases.forEach { entry in + // Given + let keychainService = MockKeychainService() + keychainService.mode = .v3Found // Simulate a v3 keychain entry found + let sut = AutofillKeyStoreProvider(keychainService: keychainService, platformProvider: iOSPlatformProvider) + + // When + _ = try sut.readData(named: entry.keychainIdentifier(using: iOSPlatformProvider), serviceName: sut.keychainServiceName) + + // Then + XCTAssertEqual(keychainService.addCallCount, 1) // Migration should trigger a write + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrService as String] as! String, AutofillKeyStoreProvider.Constants.v4ServiceName) // Ensure it's migrated to v4 + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccessGroup as String] as! String, iOSPlatformProvider.keychainSecurityGroup as String) // Ensure correct accessibility + } + } + + func testWhenWriteData_v4KeychainUsed() throws { + try AutofillKeyStoreProvider.EntryName.allCases.forEach { entry in + // Given + let originalString = "Mock Keychain data for v4!" + let data = originalString.data(using: .utf8)! + let encodedString = data.base64EncodedString() + let mockData = encodedString.data(using: .utf8)! + let keychainService = MockKeychainService() + let sut = AutofillKeyStoreProvider(keychainService: keychainService, platformProvider: iOSPlatformProvider) + + // When + _ = try sut.writeData(mockData, named: entry.keychainIdentifier(using: iOSPlatformProvider), serviceName: AutofillKeyStoreProvider.Constants.v4ServiceName) + + // Then + XCTAssertEqual(keychainService.addCallCount, 1) + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrService as String] as! String, AutofillKeyStoreProvider.Constants.v4ServiceName) // Ensure v4 is used for write + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccessible as String] as! String, kSecAttrAccessibleWhenUnlocked as String) // Ensure correct accessibility + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccessGroup as String] as! String, iOSPlatformProvider.keychainSecurityGroup as String) // Ensure correct accessibility + } + } + + #else func testWhenReadData_AndValueIsFound_NoFallbackSearchIsPerformed() throws { try AutofillKeyStoreProvider.EntryName.allCases.forEach { entry in @@ -32,7 +108,7 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - let result = try sut.readData(named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + let result = try sut.readData(named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.itemMatchingCallCount, 1) @@ -48,7 +124,7 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - _ = try sut.readData(named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + _ = try sut.readData(named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.itemMatchingCallCount, 3) @@ -64,7 +140,7 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - let result = try sut.readData(named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + let result = try sut.readData(named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.itemMatchingCallCount, 2) @@ -83,7 +159,7 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - _ = try sut.readData(named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + _ = try sut.readData(named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.itemMatchingCallCount, 3) @@ -101,11 +177,11 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - let result = try sut.readData(named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + let result = try sut.readData(named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.addCallCount, 1) - XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccount as String] as! String, entry.keychainIdentifier) + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccount as String] as! String, entry.keychainIdentifier(using: macOSKeyStorePlatformProvider())) XCTAssertEqual(keychainService.latestAddQuery[kSecAttrService as String] as! String, AutofillKeyStoreProvider.Constants.v3ServiceName) XCTAssertEqual(String(decoding: result!, as: UTF8.self), "Mock Keychain data!") } @@ -120,11 +196,11 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - _ = try sut.readData(named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + _ = try sut.readData(named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.addCallCount, 1) - XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccount as String] as! String, entry.keychainIdentifier) + XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccount as String] as! String, entry.keychainIdentifier(using: macOSKeyStorePlatformProvider())) XCTAssertEqual(keychainService.latestAddQuery[kSecAttrService as String] as! String, AutofillKeyStoreProvider.Constants.v3ServiceName) } } @@ -140,11 +216,12 @@ final class AutofillKeyStoreProviderTests: XCTestCase { let sut = AutofillKeyStoreProvider(keychainService: keychainService) // When - _ = try sut.writeData(mockData, named: entry.keychainIdentifier, serviceName: sut.keychainServiceName) + _ = try sut.writeData(mockData, named: entry.keychainIdentifier(using: macOSKeyStorePlatformProvider()), serviceName: sut.keychainServiceName) // Then XCTAssertEqual(keychainService.addCallCount, 1) XCTAssertEqual(keychainService.latestAddQuery[kSecAttrAccessible as String] as! String, kSecAttrAccessibleWhenUnlocked as String) } } +#endif } diff --git a/Tests/BrowserServicesKitTests/SecureVault/DatabaseProviderTests.swift b/Tests/BrowserServicesKitTests/SecureVault/DatabaseProviderTests.swift index f791954ae..8ad2e578d 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/DatabaseProviderTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/DatabaseProviderTests.swift @@ -34,6 +34,13 @@ class DatabaseProviderTests: XCTestCase { try FileManager.default.removeItem(atPath: dbFileContainer.appendingPathComponent(file).path) } + #if os(iOS) + let sharedDbFileContainer = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().deletingLastPathComponent() + for file in try FileManager.default.contentsOfDirectory(atPath: sharedDbFileContainer.path) { + guard ["db", "bak"].contains((file as NSString).pathExtension) else { continue } + try FileManager.default.removeItem(atPath: sharedDbFileContainer.appendingPathComponent(file).path) + } + #endif } catch let error as NSError { // File not found if error.domain != NSCocoaErrorDomain || error.code != 4 { diff --git a/Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift b/Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift new file mode 100644 index 000000000..6f00fab29 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift @@ -0,0 +1,125 @@ +// +// MockASCredentialIdentityStore.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import AuthenticationServices +@testable import BrowserServicesKit + +final class MockASCredentialIdentityStore: ASCredentialIdentityStoring { + var isEnabled = true + var supportsIncrementalUpdates = true + var savedPasswordCredentialIdentities: [ASPasswordCredentialIdentity] = [] + var error: Error? + + // Using this computed property to handle iOS 17 availability for ASCredentialIdentity + private var _savedCredentialIdentities: [Any] = [] + + func state() async -> ASCredentialIdentityStoreState { + return MockASCredentialIdentityStoreState(isEnabled: isEnabled, supportsIncrementalUpdates: supportsIncrementalUpdates) + } + + func saveCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws { + if let error = error { + throw error + } + + for credential in credentials { + if let index = savedPasswordCredentialIdentities.firstIndex(where: { $0.recordIdentifier == credential.recordIdentifier }) { + savedPasswordCredentialIdentities[index] = credential + } else { + savedPasswordCredentialIdentities.append(credential) + } + } + } + + func removeCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws { + if let error = error { + throw error + } + let identifiersToRemove = Set(credentials.map { $0.recordIdentifier }) + savedPasswordCredentialIdentities.removeAll { identifiersToRemove.contains($0.recordIdentifier) } + } + + func replaceCredentialIdentities(with newCredentials: [ASPasswordCredentialIdentity]) async throws { + if let error = error { + throw error + } + savedPasswordCredentialIdentities = newCredentials + } + +} + +@available(iOS 17.0, macOS 14.0, *) +extension MockASCredentialIdentityStore { + + var savedCredentialIdentities: [ASCredentialIdentity] { + get { + return _savedCredentialIdentities as? [ASCredentialIdentity] ?? [] + } + set { + _savedCredentialIdentities = newValue + } + } + + func saveCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws { + if let error = error { + throw error + } + for credential in credentials { + if let index = savedCredentialIdentities.firstIndex(where: { $0.recordIdentifier == credential.recordIdentifier }) { + savedCredentialIdentities[index] = credential + } else { + savedCredentialIdentities.append(credential) + } + } + } + + func removeCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws { + if let error = error { + throw error + } + let identifiersToRemove = Set(credentials.map { $0.recordIdentifier }) + savedCredentialIdentities.removeAll { identifiersToRemove.contains($0.recordIdentifier) } + } + + func replaceCredentialIdentities(_ newCredentials: [ASCredentialIdentity]) async throws { + if let error = error { + throw error + } + savedCredentialIdentities = newCredentials + } +} + +private class MockASCredentialIdentityStoreState: ASCredentialIdentityStoreState { + private var _isEnabled: Bool + private var _supportsIncrementalUpdates: Bool + + override var isEnabled: Bool { + return _isEnabled + } + + override var supportsIncrementalUpdates: Bool { + return _supportsIncrementalUpdates + } + + init(isEnabled: Bool, supportsIncrementalUpdates: Bool) { + self._isEnabled = isEnabled + self._supportsIncrementalUpdates = supportsIncrementalUpdates + super.init() + } +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift index 634e382bd..527e56c0a 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift @@ -527,6 +527,161 @@ class SecureVaultModelTests: XCTestCase { } } + func testSortedAndDeduplicatedForSameSignatureReturnsTLD() { + let controlAccounts = [ + testAccount("user1", "example.com", "sig1", 0), + testAccount("user1", "sub1.example.com", "sig1", 0, 1 * days), + testAccount("user1", "sub2.example.com", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "example.com") + XCTAssertEqual(sortedAccounts.count, 1) + } + + func testSortedAndDeduplicatedForSameSignatureReturnsWww() { + let controlAccounts = [ + testAccount("user1", "sub.example.com", "sig1", 0, 1 * days), + testAccount("user1", "sub1.example.com", "sig1", 0), + testAccount("user1", "sub2.example.com", "sig1", 0), + testAccount("user1", "www.example.com", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "www.example.com") + XCTAssertEqual(sortedAccounts.count, 1) + } + + func testSortedAndDeduplicatedForSameSignatureDifferentSubdomainsReturnsSortedLastUsed() { + let controlAccounts = [ + testAccount("user1", "sub.example.com", "sig1", 0), + testAccount("user1", "sub1.example.com", "sig1", 0), + testAccount("user1", "sub2.example.com", "sig1", 0, 1 * days), + testAccount("user1", "any.example.com", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "sub2.example.com") + XCTAssertEqual(sortedAccounts.count, 1) + } + + func testSortedAndDeduplicatedForSameSignatureDifferentDomainsReturnsUniqueDomains() { + let controlAccounts = [ + testAccount("user1", "example.co.uk", "sig1", 0), + testAccount("user1", "sub.example.co.uk", "sig1", 0), + testAccount("user1", "domain.co.uk", "sig1", 0), + testAccount("user1", "www.domain.co.uk", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "domain.co.uk") + XCTAssertEqual(sortedAccounts[1].domain, "example.co.uk") + XCTAssertEqual(sortedAccounts.count, 2) + } + + func testSortedAndDeduplicatedForNoSignatureReturnsAllAccounts() { + let controlAccounts = [ + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "example.co.uk", + created: Date(), + lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "sub.example.co.uk", + created: Date(), + lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "domain.co.uk", + created: Date(), + lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "www.domain.co.uk", + created: Date(), + lastUpdated: Date()) + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts.count, 4) + } + + func testSortedAndDeduplicatedWithComplexDomains() { + let accounts = [ + // Multiple subdomains + testAccount("user1", "deep.sub.example.com", "sig1", 0), + testAccount("user1", "other.sub.example.com", "sig1", 0), + + // Different ports + testAccount("user2", "example.com:8080", "sig2", 0), + testAccount("user2", "example.com:443", "sig2", 0), + + // Mix of www and non-www + testAccount("user3", "www.example.com", "sig3", 0), + testAccount("user3", "example.com", "sig3", 0), + + // Different TLDs + testAccount("user4", "example.com", "sig4", 0), + testAccount("user4", "example.net", "sig4", 0), + testAccount("user4", "example.org", "sig4", 0) + ] + + let sortedAccounts = accounts.sortedAndDeduplicated(tld: tld) + + // Verify subdomains are properly handled + let sig1Accounts = sortedAccounts.filter { $0.signature == "sig1" } + XCTAssertEqual(sig1Accounts[0].domain, "deep.sub.example.com") + XCTAssertEqual(sig1Accounts.count, 1) + + // Verify ports are considered in deduplication + let sig2Accounts = sortedAccounts.filter { $0.signature == "sig2" } + XCTAssertEqual(sig2Accounts[0].domain, "example.com:443") + XCTAssertEqual(sig2Accounts.count, 1) + + // Verify www and non-www are considered same domain + let sig3Accounts = sortedAccounts.filter { $0.signature == "sig3" } + XCTAssertEqual(sig3Accounts[0].domain, "example.com") + XCTAssertEqual(sig3Accounts.count, 1) + + // Verify different TLDs are preserved + let sig4Accounts = sortedAccounts.filter { $0.signature == "sig4" } + XCTAssertEqual(sig4Accounts.count, 3) + } + + func testSortedAndDeduplicatedWithLastUsedDates() { + let accounts = [ + // Same signature, different last used dates + testAccount("user1", "example.com", "sig1", 0, 3 * days), + testAccount("user1", "sub.example.com", "sig1", 0, 1 * days), + testAccount("user1", "other.example.com", "sig1", 0, 2 * days), + + // Different signatures, same domain, mixed dates + testAccount("user2", "example.com", "sig2", 0, 1 * days), + testAccount("user3", "example.com", "sig3", 0, 2 * days), + testAccount("user4", "example.com", "sig4", 0) // No last used date + ] + + let sortedAccounts = accounts.sortedAndDeduplicated(tld: tld) + + // Verify accounts are sorted by last used date + XCTAssertEqual(sortedAccounts[0].domain, "example.com") // 3 days ago + XCTAssertEqual(sortedAccounts[1].username, "user3") // 2 days ago + XCTAssertEqual(sortedAccounts[2].username, "user2") // 1 day ago + XCTAssertEqual(sortedAccounts[3].username, "user4") // No last used date + + // Verify deduplication still works with different dates + let sig1Accounts = sortedAccounts.filter { $0.signature == "sig1" } + XCTAssertEqual(sig1Accounts.count, 1) + // Verify the most recently used account is kept + XCTAssertEqual(sig1Accounts[0].lastUsed?.timeIntervalSince1970, 3 * days) + } + func testPatternMatchedTitle() { let domainTitles: [String] = [ diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultSyncableCredentialsTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultSyncableCredentialsTests.swift index 26c0a649a..2270750e1 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultSyncableCredentialsTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultSyncableCredentialsTests.swift @@ -286,6 +286,13 @@ class SecureVaultSyncableCredentialsTests: XCTestCase { try FileManager.default.removeItem(atPath: dbFileContainer.appendingPathComponent(file).path) } +#if os(iOS) + let sharedDbFileContainer = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().deletingLastPathComponent() + for file in try FileManager.default.contentsOfDirectory(atPath: sharedDbFileContainer.path) { + guard ["db", "bak"].contains((file as NSString).pathExtension) else { continue } + try FileManager.default.removeItem(atPath: sharedDbFileContainer.appendingPathComponent(file).path) + } +#endif } catch let error as NSError { // File not found if error.domain != NSCocoaErrorDomain || error.code != 4 { diff --git a/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift b/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift index 0de0a3f27..7ea3a282f 100644 --- a/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift +++ b/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift @@ -90,7 +90,8 @@ internal class CredentialsProviderTestsBase: XCTestCase { secureVaultFactory: secureVaultFactory, secureVaultErrorReporter: MockSecureVaultErrorReporter(), metadataStore: LocalSyncMetadataStore(database: metadataDatabase), - syncDidUpdateData: {} + syncDidUpdateData: {}, + syncDidFinish: { _ in } ) }