From 4e4e448fcb7e217346bf106b6c8ca7cc78667007 Mon Sep 17 00:00:00 2001 From: Anya Mallon Date: Fri, 20 Dec 2024 13:57:53 +0100 Subject: [PATCH 1/2] Fallback migrator for users impacted by enabling the credential before the app could perform migration on app version 7.149.0 --- Core/AutofillVaultKeychainMigrator.swift | 102 +++++++++++++++++++++++ DuckDuckGo.xcodeproj/project.pbxproj | 4 + DuckDuckGo/AutofillUsageMonitor.swift | 4 + 3 files changed, 110 insertions(+) create mode 100644 Core/AutofillVaultKeychainMigrator.swift diff --git a/Core/AutofillVaultKeychainMigrator.swift b/Core/AutofillVaultKeychainMigrator.swift new file mode 100644 index 0000000000..ac18bbde89 --- /dev/null +++ b/Core/AutofillVaultKeychainMigrator.swift @@ -0,0 +1,102 @@ +// +// AutofillVaultKeychainMigrator.swift +// DuckDuckGo +// +// 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 BrowserServicesKit +import os.log + +public struct AutofillVaultKeychainMigrator { + + public init() {} + + public func resetVaultMigrationIfRequired(fileManager: FileManager = FileManager.default) { + let originalVaultLocation = DefaultAutofillDatabaseProvider.defaultDatabaseURL() + let sharedVaultLocation = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL() + + // only care about users who have have both the original and migrated vaults + guard fileManager.fileExists(atPath: originalVaultLocation.path), + fileManager.fileExists(atPath: sharedVaultLocation.path) else { + return + } + + let hasV4Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v4") + + guard hasV4Items else { + return + } + + let hasV3Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v3") + let hasV2Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v2") + let hasV1Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault") + + // Only continue if there are original keychain items to migrate from + guard hasV1Items || hasV2Items || hasV3Items else { + return + } + + deleteKeychainItems(matching: "DuckDuckGo Secure Vault v4") + let backupFilePath = sharedVaultLocation.appendingPathExtension("bak") + do { + // Creating a backup of the migrated file + try fileManager.moveItem(at: sharedVaultLocation, to: backupFilePath) + Logger.autofill.info("Move migrated file to backup \(backupFilePath.path)") + } catch { + Logger.autofill.error("Failed to create backup of migrated file: \(error.localizedDescription)") + return + } + } + + private func hasKeychainItemsMatching(serviceName: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + if let service = item[kSecAttrService as String] as? String, + service.lowercased() == serviceName.lowercased() { + Logger.autofill.debug("Found keychain items matching service name: \(serviceName)") + return true + } + } + } + + return false + } + + private func deleteKeychainItems(matching serviceName: String) { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName + ] + + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + if deleteStatus == errSecSuccess { + Logger.autofill.debug("Deleted keychain item: \(serviceName)") + } else { + Logger.autofill.debug("Failed to delete keychain item: \(serviceName), error: \(deleteStatus)") + } + } +} diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9017f26c33..96d0432a39 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -951,6 +951,7 @@ C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CDA31D2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift */; }; C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D21E2C293A5965006E5A05 /* AutofillLoginSession.swift */; }; C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */; }; + C1E12B7A2D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E12B792D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift */; }; C1E42C7B2C5CD8AE00509204 /* AutofillCredentialsDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E42C7A2C5CD8AD00509204 /* AutofillCredentialsDebugViewController.swift */; }; C1E4E9A62D0861AD00AA39AF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4E9A42D0861AD00AA39AF /* InfoPlist.strings */; }; C1E4E9A92D0861AD00AA39AF /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4E9A72D0861AD00AA39AF /* Localizable.strings */; }; @@ -2833,6 +2834,7 @@ C1D21E2E293A599C006E5A05 /* AutofillLoginSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginSessionTests.swift; sourceTree = ""; }; C1DCF3502D0862330055F8B0 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1DCF3512D0862330055F8B0 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + C1E12B792D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillVaultKeychainMigrator.swift; sourceTree = ""; }; C1E42C7A2C5CD8AD00509204 /* AutofillCredentialsDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillCredentialsDebugViewController.swift; sourceTree = ""; }; C1E490582D08646400F86C5A /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/InfoPlist.strings; sourceTree = ""; }; C1E490592D08646400F86C5A /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; @@ -5474,6 +5476,7 @@ C19D90D02CFE3A7F00D17DF3 /* AutofillLoginListSectionType.swift */, C1CAAA992CFCAD3E00C37EE6 /* AutofillLoginItem.swift */, C1CAAA9B2CFCB39800C37EE6 /* AutofillLoginListSorting.swift */, + C1E12B792D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift */, ); name = Autofill; sourceTree = ""; @@ -8750,6 +8753,7 @@ B652DF0D287C2A6300C12A9C /* PrivacyFeatures.swift in Sources */, F10E522D1E946F8800CE1253 /* NSAttributedStringExtension.swift in Sources */, 9F678B892C9BAA4800CA0E19 /* Debouncer.swift in Sources */, + C1E12B7A2D14B86A0079AD8F /* AutofillVaultKeychainMigrator.swift in Sources */, 85AB84C32CF624D8007E679F /* HTTPCookieExtension.swift in Sources */, 9887DC252354D2AA005C85F5 /* Database.swift in Sources */, F143C3171E4A99D200CFDE3A /* AppURLs.swift in Sources */, diff --git a/DuckDuckGo/AutofillUsageMonitor.swift b/DuckDuckGo/AutofillUsageMonitor.swift index 6123c43180..cd4c04d71c 100644 --- a/DuckDuckGo/AutofillUsageMonitor.swift +++ b/DuckDuckGo/AutofillUsageMonitor.swift @@ -35,6 +35,10 @@ final class AutofillUsageMonitor { init() { NotificationCenter.default.addObserver(self, selector: #selector(didReceiveSaveEvent), name: .autofillSaveEvent, object: nil) + if autofillExtensionEnabled != nil { + AutofillVaultKeychainMigrator().resetVaultMigrationIfRequired() + } + ASCredentialIdentityStore.shared.getState({ [weak self] state in if state.isEnabled { if self?.autofillExtensionEnabled == nil { From 5eac6613baa6757c98f668fd8f2ff183999160f4 Mon Sep 17 00:00:00 2001 From: Anya Mallon Date: Mon, 6 Jan 2025 17:58:27 +0100 Subject: [PATCH 2/2] Unit tests added + user default migration check --- .../Shared/SecureVaultReporter.swift | 3 +- Core/AutofillVaultKeychainMigrator.swift | 179 +++++++++++---- Core/Pixel.swift | 1 + Core/PixelEvent.swift | 10 +- Core/UserDefaultsPropertyWrapper.swift | 1 + DuckDuckGo.xcodeproj/project.pbxproj | 20 +- .../AutofillVaultKeychainMigratorTests.swift | 215 ++++++++++++++++++ 7 files changed, 377 insertions(+), 52 deletions(-) create mode 100644 DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift diff --git a/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift b/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift index 2a001a714f..0c584f8741 100644 --- a/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift +++ b/AutofillCredentialProvider/CredentialProvider/Shared/SecureVaultReporter.swift @@ -29,7 +29,8 @@ final class SecureVaultReporter: SecureVaultReporting { guard !ProcessInfo().arguments.contains("testing") else { return } #endif let pixelParams = [PixelParameters.isBackgrounded: "false", - PixelParameters.appVersion: AppVersion.shared.versionAndBuildNumber] + PixelParameters.appVersion: AppVersion.shared.versionAndBuildNumber, + PixelParameters.isExtension: "true"] switch error { case .initFailed(let error): diff --git a/Core/AutofillVaultKeychainMigrator.swift b/Core/AutofillVaultKeychainMigrator.swift index ac18bbde89..6ea03c7c05 100644 --- a/Core/AutofillVaultKeychainMigrator.swift +++ b/Core/AutofillVaultKeychainMigrator.swift @@ -20,49 +20,20 @@ import Foundation import BrowserServicesKit import os.log +import Persistence +import GRDB +import SecureStorage -public struct AutofillVaultKeychainMigrator { - - public init() {} - - public func resetVaultMigrationIfRequired(fileManager: FileManager = FileManager.default) { - let originalVaultLocation = DefaultAutofillDatabaseProvider.defaultDatabaseURL() - let sharedVaultLocation = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL() - - // only care about users who have have both the original and migrated vaults - guard fileManager.fileExists(atPath: originalVaultLocation.path), - fileManager.fileExists(atPath: sharedVaultLocation.path) else { - return - } - - let hasV4Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v4") - - guard hasV4Items else { - return - } +public protocol AutofillKeychainService { + func hasKeychainItemsMatching(serviceName: String) -> Bool + func deleteKeychainItems(matching serviceName: String) +} - let hasV3Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v3") - let hasV2Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v2") - let hasV1Items = hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault") +public struct DefaultAutofillKeychainService: AutofillKeychainService { - // Only continue if there are original keychain items to migrate from - guard hasV1Items || hasV2Items || hasV3Items else { - return - } - - deleteKeychainItems(matching: "DuckDuckGo Secure Vault v4") - let backupFilePath = sharedVaultLocation.appendingPathExtension("bak") - do { - // Creating a backup of the migrated file - try fileManager.moveItem(at: sharedVaultLocation, to: backupFilePath) - Logger.autofill.info("Move migrated file to backup \(backupFilePath.path)") - } catch { - Logger.autofill.error("Failed to create backup of migrated file: \(error.localizedDescription)") - return - } - } + public init() {} - private func hasKeychainItemsMatching(serviceName: String) -> Bool { + public func hasKeychainItemsMatching(serviceName: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecReturnAttributes as String: true, @@ -70,7 +41,6 @@ public struct AutofillVaultKeychainMigrator { ] var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecSuccess, let items = result as? [[String: Any]] { @@ -86,7 +56,7 @@ public struct AutofillVaultKeychainMigrator { return false } - private func deleteKeychainItems(matching serviceName: String) { + public func deleteKeychainItems(matching serviceName: String) { let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName @@ -100,3 +70,130 @@ public struct AutofillVaultKeychainMigrator { } } } + +public class AutofillVaultKeychainMigrator { + + internal let keychainService: AutofillKeychainService + + private let store: KeyValueStoring + + @UserDefaultsWrapper(key: .autofillVaultMigrated, defaultValue: false) + var wasVaultMigrated: Bool + + private var vaultMigrated: Bool { + get { + return (store.object(forKey: UserDefaultsWrapper.Key.autofillVaultMigrated.rawValue) as? Bool) ?? false + } + set { + store.set(newValue, forKey: UserDefaultsWrapper.Key.autofillVaultMigrated.rawValue) + } + } + + public init(keychainService: AutofillKeychainService = DefaultAutofillKeychainService(), store: KeyValueStoring = UserDefaults.app) { + self.keychainService = keychainService + self.store = store + } + + public func resetVaultMigrationIfRequired(fileManager: FileManager = FileManager.default) { + guard !vaultMigrated else { + return + } + + let originalVaultLocation = DefaultAutofillDatabaseProvider.defaultDatabaseURL() + let sharedVaultLocation = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL() + let hasV4Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v4") + + // only care about users who have have both the original and migrated vaults, as well as v4 keychain items + guard fileManager.fileExists(atPath: originalVaultLocation.path), + fileManager.fileExists(atPath: sharedVaultLocation.path), + hasV4Items else { + vaultMigrated = true + return + } + + let hasV3Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v3") + let hasV2Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault v2") + let hasV1Items = keychainService.hasKeychainItemsMatching(serviceName: "DuckDuckGo Secure Vault") + + // Only continue if there are original keychain items to migrate from + guard hasV1Items || hasV2Items || hasV3Items else { + vaultMigrated = true + return + } + + let backupFilePath = sharedVaultLocation.appendingPathExtension("bak") + do { + // only complete the migration if the shared database is empty + let databaseIsEmpty = try databaseIsEmpty(at: sharedVaultLocation) + if !databaseIsEmpty { + Pixel.fire(pixel: .secureVaultV4MigrationSkipped) + } else { + // Creating a backup of the migrated file + try fileManager.moveItem(at: sharedVaultLocation, to: backupFilePath) + keychainService.deleteKeychainItems(matching: "DuckDuckGo Secure Vault v4") + Pixel.fire(pixel: .secureVaultV4Migration) + } + wasVaultMigrated = true + } catch { + Logger.autofill.error("Failed to create backup of migrated file: \(error.localizedDescription)") + return + } + } + + internal func databaseIsEmpty(at url: URL) throws -> Bool { + let keyStoreProvider: SecureStorageKeyStoreProvider = AutofillSecureVaultFactory.makeKeyStoreProvider(nil) + guard let existingL1Key = try keyStoreProvider.l1Key() else { + return false + } + + var config = Configuration() + config.prepareDatabase { + try $0.usePassphrase(existingL1Key) + } + + let dbQueue = try DatabaseQueue(path: url.path, configuration: config) + + try dbQueue.write { db in + try db.usePassphrase(existingL1Key) + } + + let isEmpty = try dbQueue.read { db in + // Find all user-created tables (excluding system tables) + let tableNames = try Row.fetchAll( + db, + sql: """ + SELECT name + FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + """ + ).map { row -> String in + row["name"] + } + + // No user tables at all -> definitely empty + if tableNames.isEmpty { + return true + } + + // Check each table for rows + for table in tableNames { + if table == "grdb_migrations" { + continue + } + + let rowCount = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)") ?? 0 + if rowCount > 0 { + // Found data in at least one table -> not empty + return false + } + } + + // There's at least one user table, but all are empty + return true + } + + return isEmpty + } + +} diff --git a/Core/Pixel.swift b/Core/Pixel.swift index ded3c22a2c..0434329098 100644 --- a/Core/Pixel.swift +++ b/Core/Pixel.swift @@ -148,6 +148,7 @@ public struct PixelParameters { // Autofill public static let countBucket = "count_bucket" public static let backfilled = "backfilled" + public static let isExtension = "is_extension" // Privacy Dashboard public static let daysSinceInstall = "daysSinceInstall" diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 18c6b0b761..de8e518dc2 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -359,7 +359,10 @@ extension Pixel { // Replacing secureVaultIsEnabledCheckedWhenEnabledAndBackgrounded with data protection check case secureVaultIsEnabledCheckedWhenEnabledAndDataProtected - + + case secureVaultV4Migration + case secureVaultV4MigrationSkipped + // MARK: Ad Click Attribution pixels case adClickAttributionDetected @@ -1317,7 +1320,10 @@ extension Pixel.Event { case .secureVaultFailedToOpenDatabaseError: return "m_secure-vault_error_failed-to-open-database" case .secureVaultIsEnabledCheckedWhenEnabledAndDataProtected: return "m_secure-vault_is-enabled-checked_when-enabled-and-data-protected" - + + case .secureVaultV4Migration: return "m_secure-vault_v4-migration" + case .secureVaultV4MigrationSkipped: return "m_secure-vault_v4-migration-skipped" + // MARK: Ad Click Attribution pixels case .adClickAttributionDetected: return "m_ad_click_detected" diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 4b0b9682ab..da55022245 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -96,6 +96,7 @@ public struct UserDefaultsWrapper { case autofillOnboardedUser = "com.duckduckgo.app.autofill.OnboardedUser" case autofillSurveysCompleted = "com.duckduckgo.app.autofill.SurveysCompleted" case autofillExtensionEnabled = "com.duckduckgo.app.autofill.ExtensionEnabled" + case autofillVaultMigrated = "com.duckduckgo.app.autofill.VaultMigrated" case syncPromoBookmarksDismissed = "com.duckduckgo.app.sync.PromoBookmarksDismissed" case syncPromoPasswordsDismissed = "com.duckduckgo.app.sync.PromoPasswordsDismissed" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 96d0432a39..5d219364bf 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -917,6 +917,7 @@ C1BF0BA529B63D7200482B73 /* AutofillLoginPromptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF0BA429B63D7200482B73 /* AutofillLoginPromptHelper.swift */; }; C1BF0BA929B63E2200482B73 /* AutofillLoginPromptViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF0BA729B63E1A00482B73 /* AutofillLoginPromptViewModelTests.swift */; }; C1BF26152C74D10F00F6405E /* SyncPromoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */; }; + C1BF4BAA2D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BF4BA92D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift */; }; C1C1FF452D085A280017ACCE /* CredentialProviderListDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C1FF412D085A280017ACCE /* CredentialProviderListDetailsView.swift */; }; C1C1FF462D085A280017ACCE /* CredentialProviderListDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C1FF422D085A280017ACCE /* CredentialProviderListDetailsViewController.swift */; }; C1C1FF472D085A280017ACCE /* CredentialProviderListDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C1FF432D085A280017ACCE /* CredentialProviderListDetailsViewModel.swift */; }; @@ -2798,6 +2799,7 @@ C1BF0BA429B63D7200482B73 /* AutofillLoginPromptHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillLoginPromptHelper.swift; sourceTree = ""; }; C1BF0BA729B63E1A00482B73 /* AutofillLoginPromptViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillLoginPromptViewModelTests.swift; sourceTree = ""; }; C1BF26142C74D10F00F6405E /* SyncPromoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoManager.swift; sourceTree = ""; }; + C1BF4BA92D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillVaultKeychainMigratorTests.swift; sourceTree = ""; }; C1C1FF412D085A280017ACCE /* CredentialProviderListDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderListDetailsView.swift; sourceTree = ""; }; C1C1FF422D085A280017ACCE /* CredentialProviderListDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderListDetailsViewController.swift; sourceTree = ""; }; C1C1FF432D085A280017ACCE /* CredentialProviderListDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderListDetailsViewModel.swift; sourceTree = ""; }; @@ -3662,13 +3664,6 @@ name = Downloads; sourceTree = ""; }; - 310EEA2C2CFFCD9B0043CA1A /* New Group */ = { - isa = PBXGroup; - children = ( - ); - path = "New Group"; - sourceTree = ""; - }; 310EEA2D2CFFCDB60043CA1A /* AIChat */ = { isa = PBXGroup; children = ( @@ -5391,6 +5386,14 @@ name = AutofillLoginUI; sourceTree = ""; }; + C1BF4BA82D26E08F00A83C77 /* Autofill */ = { + isa = PBXGroup; + children = ( + C1BF4BA92D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift */, + ); + name = Autofill; + sourceTree = ""; + }; C1C1FF442D085A280017ACCE /* CredentialProviderListDetails */ = { isa = PBXGroup; children = ( @@ -6657,7 +6660,7 @@ F1E092B31E92A6B900732CCC /* Core */ = { isa = PBXGroup; children = ( - 310EEA2C2CFFCD9B0043CA1A /* New Group */, + C1BF4BA82D26E08F00A83C77 /* Autofill */, 316790E32C9350980090B0A2 /* MarketplaceAdPostback */, 858479CA2B8795BF00D156C1 /* History */, EA7EFE662677F5BD0075464E /* PrivacyReferenceTests */, @@ -8316,6 +8319,7 @@ 9FE05CF12C36468A00D9046B /* OnboardingPixelReporterTests.swift in Sources */, 9FDEC7B42C8FD62F00C7A692 /* OnboardingAddressBarPositionPickerViewModelTests.swift in Sources */, 85BA58581F34F72F00C6E8CA /* AppUserDefaultsTests.swift in Sources */, + C1BF4BAA2D26E0B300A83C77 /* AutofillVaultKeychainMigratorTests.swift in Sources */, F1134EBC1F40D45700B73467 /* MockStatisticsStore.swift in Sources */, 9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */, 983C52E72C2C0ACB007B5747 /* BookmarkStateRepairTests.swift in Sources */, diff --git a/DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift b/DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift new file mode 100644 index 0000000000..7d5083ad09 --- /dev/null +++ b/DuckDuckGoTests/AutofillVaultKeychainMigratorTests.swift @@ -0,0 +1,215 @@ +// +// AutofillVaultKeychainMigratorTests.swift +// DuckDuckGo +// +// Copyright © 2025 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 Core +import BrowserServicesKit +import TestUtils + +final class AutofillVaultKeychainMigratorTests: XCTestCase { + + private var mockKeychain: MockKeychainService! + private var mockFileManager: MockFileManager! + private var mockStore: MockKeyValueStore! + private var migrator: TestableAutofillVaultKeychainMigrator! + private let autofillVaultMigratedKey = "com.duckduckgo.app.autofill.VaultMigrated" + + override func setUpWithError() throws { + super.setUp() + mockKeychain = MockKeychainService() + mockFileManager = MockFileManager() + mockStore = MockKeyValueStore() + + // Create a testable migrator that overrides databaseIsEmpty + migrator = TestableAutofillVaultKeychainMigrator( + keychainService: mockKeychain, + store: mockStore + ) + } + + override func tearDownWithError() throws { + migrator = nil + mockStore.clearAll() + mockStore = nil + mockFileManager = nil + mockKeychain = nil + + try super.tearDownWithError() + } + + func testSkipsIfAlreadyMigrated() { + // Mark vault as already migrated + mockStore.set(true, forKey: autofillVaultMigratedKey) + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + // Because vault was already migrated, we skip everything + XCTAssertFalse(mockFileManager.didMoveItem, "Should not move files if already migrated") + XCTAssertTrue(mockKeychain.deletedServices.isEmpty, "Should not delete keychain items if already migrated") + } + + func testSetsVaultMigratedToTrueIfOriginalMissing() { + // Arrange: + // Suppose only shared vault exists + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [sharedPath] // Missing the original vault + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if original is missing") + } + + func testSetsVaultMigratedToTrueIfSharedMissing() { + // Suppose only original vault exists + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + mockFileManager.existingPaths = [originalPath] // Missing the shared vault + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if shared is missing") + } + + func testSetsVaultMigratedToTrueIfNoV4Items() { + // Both vaults exist but no V4 items in keychain + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // Simulate no v4 items + mockKeychain.servicesWithItems = ["DuckDuckGo Secure Vault v3"] // just v3, no v4 + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if v4 items not found") + } + + func testSkipsIfNoOriginalKeychainItems() { + // We have v4 items, but no v1/v2/v3 items + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // v4 exists, but no older items + mockKeychain.servicesWithItems = ["DuckDuckGo Secure Vault v4"] + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + // No file move, no keychain deletion, wasVaultMigrated should remain false if we read it + XCTAssertFalse(mockFileManager.didMoveItem, "Should not move item if no original keychain items") + XCTAssertTrue(mockKeychain.deletedServices.isEmpty, "Should not delete any keychain service if no older items exist") + + let migratedValue = mockStore.object(forKey: autofillVaultMigratedKey) as? Bool + XCTAssertTrue(migratedValue == true, "Migrator should set wasVaultMigrated = true if no v1/v2/v3 items items found") + } + + func testSkipsIfDatabaseIsNotEmpty() { + // We have original and shared vaults, plus v4 items, plus older items + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // Keychain has both v4 and older items + mockKeychain.servicesWithItems = [ + "DuckDuckGo Secure Vault v4", + "DuckDuckGo Secure Vault v3" + ] + + // Simulate the "database is NOT empty" to cause skipping + migrator.databaseIsEmptyReturnValue = false + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + // The code checks the DB, sees it’s not empty, logs and sets wasVaultMigrated = true, + // but does NOT move or delete the v4 items. + XCTAssertFalse(mockFileManager.didMoveItem, "Should not move item if DB is not empty") + XCTAssertTrue(mockKeychain.deletedServices.isEmpty, "Should not delete if DB is not empty") + } + + func testDeletesV4AndMovesFileIfDatabaseIsEmpty() { + // Arrange: Both vaults exist, v4 + older items exist + let originalPath = DefaultAutofillDatabaseProvider.defaultDatabaseURL().path + let sharedPath = DefaultAutofillDatabaseProvider.defaultSharedDatabaseURL().path + mockFileManager.existingPaths = [originalPath, sharedPath] + + // Keychain has v4 and older items + mockKeychain.servicesWithItems = [ + "DuckDuckGo Secure Vault v4", + "DuckDuckGo Secure Vault v2" + ] + + // Simulate the DB is empty + migrator.databaseIsEmptyReturnValue = true + + migrator.resetVaultMigrationIfRequired(fileManager: mockFileManager) + + XCTAssertTrue(mockFileManager.didMoveItem, "Should move the shared vault file to .bak if DB is empty") + XCTAssertEqual(mockKeychain.deletedServices, ["DuckDuckGo Secure Vault v4"], "Should delete v4 items") + } + +} + +private class MockKeychainService: AutofillKeychainService { + + var servicesWithItems: Set = [] + var deletedServices: [String] = [] + + func hasKeychainItemsMatching(serviceName: String) -> Bool { + return servicesWithItems.contains(serviceName) + } + + func deleteKeychainItems(matching serviceName: String) { + deletedServices.append(serviceName) + // Simulate that they no longer exist + servicesWithItems.remove(serviceName) + } +} + +private class MockFileManager: FileManager { + + var existingPaths = Set() + + var didMoveItem = false + var movedFromPath: String? + var movedToPath: String? + + override func fileExists(atPath path: String) -> Bool { + return existingPaths.contains(path) + } + + override func moveItem(at srcURL: URL, to dstURL: URL) throws { + didMoveItem = true + movedFromPath = srcURL.path + movedToPath = dstURL.path + // In a real test double, you might simulate an error if you want to test error paths + } +} + +private class TestableAutofillVaultKeychainMigrator: AutofillVaultKeychainMigrator { + + var databaseIsEmptyReturnValue: Bool = true + var databaseIsEmptyCalledCount = 0 + + override func databaseIsEmpty(at url: URL) throws -> Bool { + databaseIsEmptyCalledCount += 1 + return databaseIsEmptyReturnValue + } +}